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。如果编译器报错,那正是你需要检查数据的地方。

typescripttype-safetyunknownneverany