TypeScript 中的 unknown、any 和 never:该用哪个
1. 总览
TypeScript 有三个容易混淆的特殊类型:any、unknown 和 never。这三个都出现在类型系统的“边界”处。any 关闭类型检查,unknown 强制你在使用前进行检查,而 never 表示不可能发生的情况。
读完这篇文章后,你将知道为未知来源的数据选择哪种类型。你还将学会如何使用 never 来让编译器在你忘记处理某个分支时报错。
所有示例都使用 TypeScript 5.x 和 tsconfig.json 中的 "strict": true 运行。
2. any:关闭类型检查器
any 允许一个值做任何事情。编译器不检查任何内容:
let payload: any = "你好";
payload.toUpperCase(); // OK
payload.foo.bar(); // 编译时 OK,运行时错误
payload = 42;
const total: number = payload * 2; // OK
第三行编译正常。运行时,它会抛出错误:
TypeError: Cannot read properties of undefined (reading 'bar')
对于 any,TypeScript 认为你已经承担了责任。你失去了自动完成、属性名称检查和安全重构的好处。
2.1. any 传播
any 最大的问题是它传播到所有接触它的东西:
function loadConfig(): any {
return JSON.parse('{"port": "8080"}');
}
const config = loadConfig();
const port = config.port; // port: any
const next = port + 1; // next: any
console.log(next);
80801
port 是字符串 "8080",所以 port + 1 产生 "80801" 而不是 8081。编译器不会警告,因为 any 已经通过 config、port 然后 next 传播了。开头的一个 any 可能会导致整个函数调用链的类型检查丧失。
注意:
JSON.parse、fetch API 中的response.json()和许多旧库返回any。这是现实代码中any泄露的最常见来源。
3. unknown:类型未知时的安全选择
unknown 也接收任何值,就像 any 一样。不同之处在于你不能使用这个值,直到你已经缩小了类型(narrowing):
let payload: unknown = "你好";
payload.toUpperCase();
error TS18046: 'payload' is of type 'unknown'.
编译器立即阻止。你必须先证明 payload 是一个字符串:
if (typeof payload === "string") {
console.log(payload.toUpperCase()); // payload: string
}
你好
在 if 块内,TypeScript 知道 payload 是 string,所以 toUpperCase() 是有效的。
3.1. 缩小 unknown 的方式
有四个主要工具:
class PaymentError extends Error {
constructor(message: string, public readonly code: string) {
super(message);
}
}
function describe(value: unknown): string {
if (typeof value === "number") {
return `数字:${value.toFixed(2)}`;
}
if (value instanceof PaymentError) {
return `支付错误 ${value.code}`;
}
if (typeof value === "object" && value !== null && "id" in value) {
return `包含 id 的对象:${String(value.id)}`;
}
return "未知";
}
console.log(describe(12.5));
console.log(describe(new PaymentError("余额不足", "INSUFFICIENT")));
console.log(describe({ id: 7 }));
console.log(describe(null));
数字:12.50
支付错误 INSUFFICIENT
包含 id 的对象:7
未知
typeof用于原始类型:string、number、boolean等。instanceof用于类。in检查属性是否存在。从 TypeScript 4.9 开始,在"id" in value之后,value的类型是object & Record<"id", unknown>。- 必须检查
value !== null,因为typeof null === "object"。
3.2. 来自外部数据的类型保护
对于复杂对象,你应该编写一个类型保护,即返回 value is T 的函数:
interface Order {
id: string;
customerName: string;
total: number;
}
export function isOrder(value: unknown): value is Order {
if (typeof value !== "object" || value === null) return false;
const v = value as Record<string, unknown>;
return (
typeof v.id === "string" &&
typeof v.customerName === "string" &&
typeof v.total === "number"
);
}
export function parseOrder(json: string): Order {
const data: unknown = JSON.parse(json);
if (!isOrder(data)) {
throw new Error("订单数据无效");
}
return data; // data: Order
}
console.log(parseOrder('{"id":"A1","customerName":"兰","total":250000}'));
{ id: 'A1', customerName: '兰', total: 250000 }
关键是行 const data: unknown = JSON.parse(json)。显式地将类型分配给 unknown 会阻止 JSON.parse 的 any 泄露出去。从这里开始,编译器强制你在使用 data 之前进行检查。
在大型项目中,你可以用 Zod 等 schema 验证库代替手写类型保护。原则保持不变:输入数据是 unknown,验证后才成为具体类型。
3.3. catch 中的 unknown
使用 strict: true(具体来说是 useUnknownInCatchVariables 标志),catch 中的变量类型是 unknown:
try {
parseOrder("{}");
} catch (err) {
// err: unknown
const message = err instanceof Error ? err.message : String(err);
console.log(message);
}
订单数据无效
这与现实相符。JavaScript 允许 throw 任何东西,甚至字符串或数字,所以你不能假设 err 总是 Error。
4. never:没有值的类型
never 是空类型。没有值属于 never 类型。它出现在三种主要情况下。
4.1. 函数永不返回
一个总是抛出错误或运行无限循环的函数的返回类型是 never:
function fail(message: string): never {
throw new Error(message);
}
function getDiscount(code: string): number {
if (code === "TET2026") return 0.2;
return fail(`折扣代码不存在:${code}`);
}
由于 never 可以分配给任何类型,return fail(...) 在返回 number 的函数中是有效的。编译器还理解 fail() 调用后的代码是无法到达的。
4.2. 穷尽性检查
这是 never 最有用的用法。假设你有一个支付方法的 union:
type PaymentMethod =
| { kind: "card"; last4: string }
| { kind: "cash" }
| { kind: "momo"; phone: string };
export function assertNever(value: never): never {
throw new Error(`未处理的情况:${JSON.stringify(value)}`);
}
export function paymentLabel(method: PaymentMethod): string {
switch (method.kind) {
case "card":
return `卡 **** ${method.last4}`;
case "cash":
return "现金";
case "momo":
return `MoMo ${method.phone}`;
default:
return assertNever(method);
}
}
处理完所有三个 case 后,method 在 default 分支中被缩小为 never。调用 assertNever(method) 因此是有效的。
现在,假设同事将 { kind: "zalopay"; phone: string } 添加到 union 中但忘记更新 switch。编译器会立即报错:
error TS2345: Argument of type '{ kind: "zalopay"; phone: string; }' is not assignable to parameter of type 'never'.
错误在编译时被发现,而不是让用户在支付屏幕上看到 undefined 标签。如果陌生数据仍然在运行时泄露,例如来自旧 API,assertNever 将抛出明确的错误。
4.3. 条件类型中的 never
在条件类型中,never 用于从 union 中排除成员,因为 A | never 就是 A:
type OnlyStrings<T> = T extends string ? T : never;
type Mixed = "vnd" | "usd" | 404 | null;
type Currencies = OnlyStrings<Mixed>; // "vnd" | "usd"
TypeScript 将条件类型分配到 union 的每个成员。成员 404 和 null 变成 never 然后消失。内置的实用类型如 Exclude、Extract 和 NonNullable 都按这个机制工作。
5. 快速对比
核心差异在于可分配性(assignability):
| 将其他值分配给此类型 | 将此类型分配给其他类型 | 直接使用(调用方法、访问属性) | |
|---|---|---|---|
any |
可以 | 可以(除了 never) |
可以,无类型检查 |
unknown |
可以 | 仅限 unknown、any |
不可以,需要缩小 |
never |
不可以(除了 never) |
可以,分配给任何类型 | 没有值可用 |
可以总结如下:
unknown是顶部类型的安全选择。一切都是unknown。never是底部类型。never是所有类型的子类型。any是一个逃脱门。它既充当顶部类型又充当底部类型,因此破坏了类型系统的完整性。
6. 测试行为
你可以使用 Vitest 同时测试运行时行为和编译时类型。安装:
npm install -D vitest typescript
假设第 3.2 和 4.2 节中的函数分别位于 order.ts 和 payment.ts 中:
// payment.test.ts
import { describe, it, expect, expectTypeOf } from "vitest";
import { isOrder, parseOrder } from "./order";
import { paymentLabel, assertNever } from "./payment";
describe("unknown 和类型保护", () => {
it("接受有效的订单", () => {
const order = parseOrder('{"id":"A1","customerName":"兰","total":1000}');
expect(order.total).toBe(1000);
expectTypeOf(order.total).toEqualTypeOf<number>();
});
it("拒绝类型错误的数据", () => {
expect(isOrder({ id: "A1", customerName: "兰", total: "1000" })).toBe(false);
expect(() => parseOrder("null")).toThrow("订单数据无效");
});
});
describe("never 和穷尽性检查", () => {
it("为每种支付方法返回正确的标签", () => {
expect(paymentLabel({ kind: "card", last4: "4242" })).toBe("卡 **** 4242");
expect(paymentLabel({ kind: "cash" })).toBe("现金");
});
it("运行时遇到 union 外的数据时抛出错误", () => {
const legacy = { kind: "paypal" } as unknown as never;
expect(() => assertNever(legacy)).toThrow("未处理的情况");
});
});
npx vitest run
✓ payment.test.ts (4 tests)
Test Files 1 passed (1)
Tests 4 passed (4)
expectTypeOf 在编译时检查类型。如果某个更改使 order.total 变成 any,在运行类型检查时测试会失败(vitest --typecheck)。最后的测试模拟来自旧系统的“陌生”数据,以确保运行时安全网仍然有效。
7. 常见错误和何时不应使用
**使用 any 来“绕过”编译错误。**这是最坏的习惯。如果你真的不知道类型,请使用 unknown。启用 @typescript-eslint/no-explicit-any 规则来捕获这些情况。
使用 as 强制转换 unknown 而不是缩小。data as Order 只是安抚编译器,不进行任何运行时检查。本质上,它的危险程度与 any 不相上下。
const data: unknown = JSON.parse('{"id": 1}');
const order = data as Order; // 编译 OK
order.customerName.trim(); // 运行时 TypeError
对所有东西都使用 unknown。unknown 用于系统边界:用户输入、API 响应、catch、反序列化数据。在领域内,使用具体类型。calculateTotal(items: unknown) 形式的函数是设计糟糕的。
将 never 与 void 混淆。void 表示函数返回但没有有用的值。never 表示函数永不返回。一个普通的 log() 函数应该是 void,而不是 never。
**在 switch 中忘记 default。**没有 assertNever,只有当函数有显式返回类型且启用 noImplicitReturns 时,TypeScript 才会报错。即便如此,你仍然会失去运行时安全网。
注意:
any仍然有合理的用途。那就是逐步迁移一个大型 JavaScript 代码库,或者在编写库的声明文件时,类型确实太动态了。将其严格限制在范围内,并留下解释原因的注释。
8. 结论
这三种类型有三个明确的角色:
- 对于系统边界处的未知类型数据使用
unknown,然后使用typeof、instanceof、in或类型保护来缩小范围。 - 对总是抛出错误的函数使用
never,对assertNever进行穷尽性检查,以及在条件类型中过滤 union。 - 避免
any,除非你有具体的理由且已经将其限制在范围内。
实用规则非常简单。当你想输入 any 时,先尝试 unknown。如果编译器报错,那正是你需要检查数据的地方。