《TypeScript编程入门》3.3 any·unknown·never·void 与类型断言

本节处理类型系统里的四个边界类型:any、unknown、never、void。先说明 any 为什么是「类型安全的漏洞」以及它的传染性,再讲 unknown 如何作为安全的 any 迫使你收窄,never 如何支撑穷尽性检查与永不返回的函数,void 在回调签名里的特殊地位。随后讲解类型断言 as、非空断言 !、双重断言与 satisfies 运算符的正确用法和滥用后果,并附真实报错对照。

本节目标:读完这一节,你能准确说出 any、unknown、never、void 各自表达的语义,并在代码里对号入座;知道 any 是如何「污染」整条调用链的,以及为什么 unknown 是它的安全替代;能用 never 写出编译期强制的穷尽性检查;能分清 as、!、as unknown as T、satisfies 四种写法的适用边界,不再靠断言「骗过」编译器。

3.3 any·unknown·never·void 与类型断言

前面两节讲的都是「有明确形状」的类型。但真实代码里总有一部分值是模糊的:接口返回的原始 JSON、从 localStorage 读出来的字符串、第三方库里没写类型的回调参数。TypeScript 为此准备了四个特殊的边界类型,它们既是逃生舱,也是踩坑高发区。

先把四者的语义摆在一起:

类型可以赋给它什么它能赋给谁一句话语义
any任何值任何类型「放弃检查」
unknown任何值只有 unknown / any「还不知道是什么」
never什么都不行任何类型「不可能存在」
voidundefined(宽松时任意)只有 any / unknown「不关心返回值」

any:一个会传染的漏洞

any 的规则只有一条:双向无条件兼容。任何值可以赋给 any,any 可以赋给任何类型。

let value: any = "hello";
value = 42;        // ✅
value = { a: 1 };  // ✅

const n: number = value;  // ✅ 不报错,但运行时可能是个对象
n.toFixed(2);
// 💥 运行时报错,编译期毫无提示

真正危险的不是单点,而是传染性:

function readConfig(): any {
  return JSON.parse(localStorage.getItem("config") ?? "{}");
}

const config = readConfig();
config.port.toUpperCase();       // ✅ 编译通过
config.host.split(".").map((x) => x); // ✅ 编译通过
const total: number = config.retries; // ✅ 编译通过

从 readConfig() 返回那一刻起,下游所有代码的类型检查都被关闭了。这就是社区说「一个 any 毁掉一整条调用链」的原因。

any 最常见的三个来源:

// 1. JSON.parse 的返回值
const data = JSON.parse(text);        // any

// 2. 未标注的参数(noImplicitAny 会报错,但很多人关掉了)
function handler(event) {}            // any

// 3. 显式写 any 图省事
const cache: Record<string, any> = {};

第三条尤其要注意:Record<string, any> 是很多项目里最隐蔽的漏洞。应该写 Record<string, unknown>,或者给出具体的值类型。

any 不是不能用,而是必须有边界。 允许 any 出现在「与外部世界交接的那一层」,并在同层立刻收窄成具体类型;绝不允许它流进业务逻辑。

unknown:类型安全的 any

unknown 与 any 的唯一区别在赋值方向:

let u: unknown = "hello";
u = 42;              // ✅ 任何值都能赋给它

const s: string = u;
// ❌ Type 'unknown' is not assignable to type 'string'.

强制你在使用前先收窄:

if (typeof u === "string") {
  console.log(u.toUpperCase()); // ✅ 这里 u 是 string
}

这就是「安全」的全部含义:把风险集中在必须处理的那一行。上面那个 readConfig 例子,只要把返回类型从 any 改成 unknown,config.port 这一行就会立刻报 'config' is of type 'unknown',编译器逼着你写出真正的校验逻辑。这也是为什么 13.1 类型擦除带来的运行时盲区 强调:unknown 是「在类型层面承认无知」的正确姿势,而 any 是「假装自己知道」。

常用的收窄手法有 typeof、Array.isArray、instanceof、in 和相等比较,它们背后的机制是控制流分析,属于 7.3 类型守卫与控制流分析 的主题。如果你要校验的是复杂嵌套结构,手写 typeof 判断会迅速失控,工业界的答案是模式校验库——见 13.2 Zod 模式验证与类型推导 ,站内也有对应的实践文 TypeScript 运行时校验与类型安全 。

never:不可能存在的值

never 是「空集」——没有任何值属于它。它出现在两类场景。

场景一:永远不会正常返回的函数。 抛出异常或死循环的函数,返回值是 never:

function fail(message: string): never {
  throw new Error(message);
}

function loopForever(): never {
  while (true) {}
}

never 与 void 的区别很关键:void 是「返回了,但值没意义」,never 是「根本回不来」。

场景二:穷尽性检查。 这是 never 最有价值的用法。在判别联合上做 switch 时,把 default 分支的值赋给 never,一旦将来新增成员忘了处理,编译立刻失败:

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.side ** 2;
    default:
      // 到这里 shape 的类型被收窄成 never
      const exhaustive: never = shape;
      return exhaustive;
  }
}

现在往 Shape 里加一个 { kind: "triangle"; ... },default 分支立刻报错:

Type '{ kind: "triangle"; base: number; height: number; }' is not assignable to type 'never'.

编译期强制你处理新分支,这正是类型系统最实用的能力之一。判别联合与穷尽检查的完整讨论在 7.2 判别联合(Discriminated Unions) 。

never 还会出现在两个容易忽略的地方:string & number 这种不可能的交叉类型会被求值为 never;而在联合类型里,never 是单位元,never | string 就是 string。因此 Extract<"a" | 1, string> 能精确算出 "a"。

void:没有意义的返回值

void 表示函数不返回有用的值:

function log(msg: string): void {
  console.log(msg);
}

它的特殊之处在于函数类型赋值的宽松规则。考虑回调场景:

type Callback = () => void;

const cb: Callback = () => {
  return 123; // ✅ 不报错,返回值被忽略
};

为什么允许?因为调用方根本不看返回值,多返回一个值无害——这条规则的正式表述是「返回值类型为 void 的函数类型,可以接受返回任意值的函数」。但反过来不成立,() => number 不能接收 () => void:

type MustReturn = () => number;
const f: MustReturn = () => {
  // ❌ Type 'void' is not assignable to type 'number'.
};

一个实践中的小技巧:如果回调可能被异步执行,不要用 void。

// ⚠️ 容易出错的写法
function onDone(cb: () => void) {
  setTimeout(cb, 100);
}

onDone(async () => {
  await save(); // 这个 Promise 被静默丢弃,错误无人接收
});

上例中 async 箭头函数返回 Promise<void>,在 () => void 的宽松规则下不会报错,于是 save() 抛出的异常变成「未处理的 Promise 拒绝」。正确做法是把签名写成 () => void | Promise<void>,或者干脆用 () => Promise<void> 强制调用方处理。

类型断言 as

断言的作用是覆盖编译器的推断,语法是 值 as 类型:

const el = document.getElementById("app") as HTMLDivElement;
el.innerHTML = "hello";

getElementById 的返回类型是 HTMLElement | null,as 让你直接当它是 HTMLDivElement。断言不做任何运行时检查——如果你猜错了,运行时照样崩。

关于语法还有一点历史遗留问题:<T>value 这种尖括号写法在 .ts 里合法,但在 .tsx(JSX)里会被解析成标签:

const value: unknown = "hello";
// .ts 里可以,但 .tsx 里会被当成 JSX
const angleValue = <string>value;
// as 语法在 .ts / .tsx 都可用;断言仍不做运行时验证
const assertedValue = value as string;

因此本项目一律使用 as。 断言的合法用途只有少数几种:DOM 元素(你确实知道标签类型)、as const 收窄字面量、以及外部数据经校验后确认类型。反例则是用断言掩盖真实错误:

const canvas = document.querySelector("#c") as HTMLCanvasElement; // ✅ 场景一
const level = "debug" as const;                                    // ✅ 场景二

const user = await fetchUser(id) as User; // ❌ 接口可能返回 null
console.log(user.name);                   // 💥 Cannot read properties of null

非空断言 !

! 是 as NonNullable<T> 的简写,告诉编译器「这里一定不是 null / undefined」:

const el = document.getElementById("app")!;
el.innerHTML = "hello"; // 不再报 'possibly null'

它与 as 一样没有运行时效果。! 是新手最爱滥用的符号,判断标准很简单:能改成 if 判断就改成 if,测试代码里适度使用可以接受,而在 strictNullChecks 下每一次 ! 都应该被 review。典型的安全替代:

// ❌ 危险:找不到元素时直接崩
const el = document.querySelector<HTMLInputElement>("#name")!;
el.value = "";

// ✅ 明确失败:给出可读的错误信息
const el2 = document.querySelector<HTMLInputElement>("#name");
if (!el2) throw new Error("找不到 #name 输入框");
el2.value = "";

双重断言 as unknown as T

当两个类型「毫无关系」时,单次断言会被拒绝:

const n = 1 as string;
// ❌ Conversion of type 'number' to type 'string' may be a mistake
//    because neither type sufficiently overlaps with the other.

于是有人用双重断言强行绕过:

const n = 1 as unknown as string; // ✅ 编译通过

这在 99% 的情况下是代码坏味道。 它等于彻底关闭这一行的类型检查,而且比 any 更隐蔽——review 时很容易被误认为「有讲究」。真正需要它的场景只有两个:编写类型测试(见 15.2 类型测试(tsd/expect-type) ),以及处理库的类型声明缺陷(并附上注释说明原因)。

satisfies:只校验、不改变类型

as 的问题是它会覆盖推断结果。有时你只想「确认这个值符合某个类型」,但仍希望保留更精确的字面量类型——这正是 TS 4.9 引入 satisfies 的动机:

// 用注解:类型被放宽成 Record<string, string | string[]>
const routes1: Record<string, string | string[]> = {
  home: "/",
  posts: ["/a", "/b"],
};

routes1.home.toUpperCase();
// ❌ Property 'toUpperCase' does not exist on type 'string | string[]'.

// 用 satisfies:校验通过,但保留字面量类型
const routes2 = {
  home: "/",
  posts: ["/a", "/b"],
} satisfies Record<string, string | string[]>;

routes2.home.toUpperCase(); // ✅ home 精确推断为 string
routes2.posts.push("/c");   // ✅ posts 精确推断为 string[]

对照表:

写法检查结果类型
const x: T = v有放宽为 T
const x = v as T弱(只查重叠)覆盖为 T
const x = v satisfies T有保留推断结果

satisfies 最常见的用途是「配置对象既要符合约束,又要保留键名」:

const theme = {
  primary: "#3b82f6",
  danger: "#ef4444",
} satisfies Record<string, `#${string}`>;

type ThemeColor = keyof typeof theme; // "primary" | "danger"

如果写成 const theme: Record<string, string>,ThemeColor 就退化成 string 了。

常见报错速查

报错信息含义修法
'x' is of type 'unknown'直接使用了未收窄的 unknown加 typeof / in 判断
Property 'y' does not exist on type 'never'对 never 取值,通常是分支已穷尽检查 default 分支逻辑
Conversion of type 'A' to type 'B' may be a mistake两类型不重叠先收窄,别用双重断言
Object is possibly 'null'严格空值检查用 if 或 ?? 处理
Type 'void' is not assignable to type 'number'用了无返回值函数的结果让函数真正返回
Argument of type 'any' is not assignable极少见,通常来自 lib 定义冲突检查是否误引入两份类型声明

一个完整的边界处理范式

把本节所有概念串成一个真实函数——读取并校验环境变量:

function getEnv(key: string): unknown {
  return process.env[key]; // 可能是 string | undefined
}

function requireEnv(key: string, fallback: string): string {
  const raw = getEnv(key);          // unknown,安全
  if (typeof raw === "string" && raw.length > 0) {
    return raw;                     // 收窄成 string
  }
  if (raw !== undefined && raw !== null) {
    fail(`环境变量 ${key} 必须是字符串`); // 用 never 返回,杜绝继续执行
  }
  return fallback;
}

function fail(message: string): never {
  throw new Error(message);
}

这段代码里:unknown 负责「不知道」,控制流收窄负责「搞清楚」,never 负责「保证失败路径不会继续」,全程没有一处 as 或 !——这就是理想状态。关于错误的类型化表达(用返回值而不是抛异常来承载失败),14.1 错误类型与 Result 模式 有完整讨论,站内也有对应实践文 TypeScript 错误处理与 Result 模式 。

小结

这一节把四个边界类型和四种断言手法讲完了:

  • any 双向兼容、会传染,只应存在于外部数据交界处,且必须立刻收窄。
  • unknown 是安全的 any,任何值可赋值给它,但它只能赋给 unknown/any,逼迫你校验。
  • never 是空集,用于永不返回的函数和穷尽性检查——它是编译期强制完备性的核心工具。
  • void 表示返回值无意义,回调签名里对返回值的宽松规则有陷阱,异步回调要显式写 Promise<void>。
  • as 覆盖推断、! 去掉空值、as unknown as T 关闭检查、satisfies 只校验不覆盖;后两者滥用是坏味道,satisfies 才是配置对象的正确选择。

到这里,第 3 章「基本类型系统」就完整了:从原始类型到数组、元组、枚举,再到四个边界类型。你已经能给绝大多数数据写出准确的类型。

下一章 4.1 签名、可选参数与默认值 会把镜头转向函数——参数怎么标可选、默认值如何影响推断、返回值如何与重载配合,这是从「描述数据」迈向「描述行为」的关键一步。

阅读导航:上一节:3.2 数组、元组与枚举 · 下一节:4.1 签名、可选参数与默认值 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes