首页 / 文章 / 让资深开发者也感到意外的 TypeScript 行为及其原因

让资深开发者也感到意外的 TypeScript 行为及其原因

结构化类型、多余的属性检查、as const、satisfies、条件类型和映射类型,以及将这些特性转化为更安全代码的设计原则。

4295 词

大多数开发者最初将 TypeScript 视为带有注解的 JavaScript,但随后会遇到与这种认知不符的情况:某个对象在某些地方被接受,而在其他地方却被拒绝;类型断言根本无法“转换”任何内容;readonly 仍然允许嵌套值被修改。在这些基本注解之下,其实存在着一种拥有自身兼容性、推理和计算规则的类型级语言。本指南将逐一解释这些令人意外的现象,从运行时类型擦除、结构化类型到 infer、模板字面量类型以及 satisfies,并将其转化为可用于实际代码库的实用设计原则。

代码运行后类型便不再存在

以下是一个接口以及用它进行注解的对象:

interface User {
  id: number;
  name: string;
}

const user: User = {
  id: 1,
  name: "Lakhveer"
};

人们很容易认为正在运行的程序知道user是一个User类型。其实并非如此。编译过程会移除该接口,最终剩下的本质就是这样:

const user = {
  id: 1,
  name: "Lakhveer"
};

运行时并不存在User类型的值。TypeScript首先进行静态分析,然后再生成JavaScript代码,只有这些JavaScript代码才会被传递给引擎:

TypeScript
    ↓
Type Checking
    ↓
JavaScript Generation
    ↓
Browser / Node.js

类型用于告知编译器信息,它们并非运行时对象。实际后果是TypeScript永远不会验证来自程序外部的数据。下面的注解仅用于声明服务器返回的内容,并不会对其进行任何检查:

const response: User = await fetch("/api/user")
  .then(res => res.json());

对于外部数据,需要进行运行时验证。像Zod这样的模式库可以一次性定义数据结构,在数据到达时对其进行校验:

const UserSchema = z.object({
  id: z.number(),
  name: z.string()
});

const user = UserSchema.parse(data);

记住这种区分的方法:编译器保护你的代码,而运行时验证则保护你的应用程序。该博客关于在 React 前端和 Node 后端之间共享同一个 Zod schema的指南展示了如何在两端应用这一方法。

any、unknown 与举证责任

any 会禁用检查

使用 any时,所有这些无意义的操作都能顺利编译:

let value: any = "hello";

value.foo.bar.baz();
value();
value.notARealProperty;

any实际上是在告诉编译器信任你,不再继续检查。正因如此,用这种方式定义的参数会悄悄放弃 TypeScript 的大部分优势:

function processUser(user: any) {
  console.log(user.name);
}

unknown 要求提供证据

unknown也能接受任何值:

let value: unknown = "hello";

但直接使用它会失败:

value.foo;

首先需要将其限定为特定类型,例如字符串:

if (typeof value === "string") {
  console.log(value.toUpperCase());
}

或者数字:

if (typeof value === "number") {
  console.log(value.toFixed(2));
}

这两种态度的差异很容易概括:

any
 ↓
"Trust me"

unknown
 ↓
"Prove it first"

因此,当您确实不知道某个值的类型时,请使用

unknown

而非

any

让编译器在您使用该值之前强制要求您证明其类型。

兼容性关注的是结构而非名称

来自 Java、C# 或 C++ 的开发者常常会对这种赋值方式的存在感到惊讶:

interface User {
  name: string;
}

const employee = {
  name: "Lakhveer",
  salary: 100000
};

const user: User = employee;

TypeScript 采用结构化类型系统:兼容性取决于一个值具有哪些属性,而非它被声明为何类型。User 只需要这些:

name: string

而 employee 不仅具备这些,还有更多属性。编译器采用的判断逻辑如下:

User requires:
    name: string

employee has:
    name: string
    salary: number

Therefore:
    employee satisfies User

或者,用决策流程表示为:

Required properties
        ↓
Does object contain them?
        ↓
Yes
        ↓
Compatible

对新的字面量会进行多余的属性检查

现在的问题是:直接在对象字面量中写入额外的字段会被拒绝:

interface User {
  name: string;
}

const user: User = {
  name: "Lakhveer",
  salary: 100000
};

会出现如下错误:

Object literal may only specify known properties

然而,通过中间变量赋值相同的数据则可以成功:

const employee = {
  name: "Lakhveer",
  salary: 100000
};

const user: User = employee;

原因是TypeScript会对在赋值时直接写成的新对象字面量进行多余的属性检查,以此防止拼写错误。这种检查与结构兼容性是分开的。所以“TypeScript拒绝额外的属性”仅适用于字面量;一旦对象经过了变量处理,额外的字段就没问题了。

字面量类型与派生联合类型

使用精确值而非宽泛类型

变量可以被限制为特定的值:

let direction: "left" | "right";
direction = "left";

因此该赋值会失败:

direction = "up";

声明中并未说明

direction: string

它说明了

direction must be EXACTLY:
"left"
OR
"right"

更高的精度能显著提升API的性能。部署函数只能接受已知的环境:

type Environment =
  | "development"
  | "staging"
  | "production";
function deploy(env: Environment) {
  // ...
}

这样,拼写错误就会导致编译错误,而不会造成部署失败:

deploy("testing");

as const会改变推断结果

一个普通的字符串数组字面量:

const colors = ["red", "blue", "green"];

会被推断为

string[]

添加as const后:

const colors = ["red", "blue", "green"] as const;

则会生成一个只读的字面量类型元组:

readonly ["red", "blue", "green"]

通过使用number对元组进行索引,可以从中得到一个联合类型:

type Color = typeof colors[number];

其结果为:

type Color = "red" | "blue" | "green";

这可以避免常见的重复代码。如果不这样做,就需要同时维护一个联合类型和数组,并手动确保两者保持同步:

type Color = "red" | "blue" | "green";

const colors: Color[] = [
  "red",
  "blue",
  "green"
];

使用这种方式后,数组成为唯一的真实数据来源,类型也会随之自动确定:

const colors = [
  "red",
  "blue",
  "green"
] as const;

type Color = typeof colors[number];

这一原则具有普遍性:当类型系统能够推导出信息时,就不要重复编写。

在类型层面起作用的关键词

typeof有两个作用

在JavaScript中,

typeof value

是一个运行时运算符。例如,

typeof "hello";

的计算结果为

"string"

在类型检查场景中,TypeScript会重用该关键词来获取变量的静态类型:

const user = {
  id: 1,
  name: "Lakhveer"
};

type User = typeof user;

在这里

User

变为

{
  id: number;
  name: string;
}

同一个关键词,两种不同的使用场景:

Runtime:
typeof value

Type system:
typeof variable

keyof可将键转换为联合类型

给定一个接口,

interface User {
  id: number;
  name: string;
  email: string;
}

这种类型

type UserKeys = keyof User;

是

"id" | "name" | "email"

结合泛型使用,可以编写仅接受有效键的属性访问器:

function getValue<T, K extends keyof T>(
  object: T,
  key: K
) {
  return object[key];
}

使用现有键调用它是可行的:

const user = {
  id: 1,
  name: "Lakhveer"
};

getValue(user, "name");

而缺失的键会在编译时被拒绝:

getValue(user, "salary");

返回类型也非常明确:T[K]对应该特定属性的类型。

泛型将各个值相互关联

传统的泛型只是返回其接收到的内容:

function identity<T>(value: T): T {
  return value;
}

当泛型将多个值绑定在一起时,就会变得更为有趣。此时两个参数必须具有相同的类型:

function pair<T>(first: T, second: T): [T, T] {
  return [first, second];
}

因此这样的调用是被接受的:

pair(10, 20);

但这种方式会失败,因为从第一个参数推断出 T 的类型为 number,而字符串无法赋值给该类型:

pair(10, "hello");

泛型也可以如实地将输入与输出关联起来,包括空情况:

function first<T>(items: T[]): T | undefined {
  return items[0];
}

对于像这样的调用

const numbers = first([1, 2, 3]);

编译器会将结果报告为

number | undefined

类型计算

条件类型即类型层面的 if 语句

条件类型会根据某种判断在两个结果之间进行选择:

type IsString<T> =
  T extends string
    ? true
    : false;

因此

type A = IsString<string>;

最终结果为

true

并且

type B = IsString<number>;

最终结果为

false

从概念上讲,你其实是在编写这样的代码,只不过它是在编译器中运行而非在你的程序中:

if T is string
    return true
else
    return false

infer 用于提取类型的部分特征

在条件类型中,infer会引入一个类型变量,TypeScript通过匹配来填充该变量。这实际上是对内置的ReturnType的重实现:

type ReturnTypeOf<T> =
  T extends (...args: any[]) => infer R
    ? R
    : never;

将其应用于实际函数时,它可以提取出返回对象的类型:

function getUser() {
  return {
    id: 1,
    name: "Lakhveer"
  };
}

type User = ReturnTypeOf<typeof getUser>;

其思维模型类似于模式匹配:

Function
   ↓
infer R
   ↓
Extract return type

许多标准工具类型都是以这种方式构建的。

映射类型会转换所有属性

以接口为起点,

interface User {
  id: number;
  name: string;
  email: string;
}

映射类型会遍历其键以生成只读版本:

type ReadonlyUser = {
  readonly [K in keyof User]: User[K];
};

或者生成可选版本:

type OptionalUser = {
  [K in keyof User]?: User[K];
};

从而无需手动重写每个字段:

id?: number;
name?: string;
email?: string;

这些组件构成了工具类型

TypeScript提供了这类辅助函数的库:

Partial<T>
Required<T>
Readonly<T>
Pick<T, K>
Omit<T, K>
Record<K, T>
Exclude<T, U>
Extract<T, U>
NonNullable<T>
ReturnType<T>
Parameters<T>

给定如下模型,

interface User {
  id: number;
  name: string;
  email: string;
}

Pick 会保留选中的键:

type UserPreview = Pick<User, "id" | "name">;

生成

{
  id: number;
  name: string;
}

而 Omit 会移除它们:

type UserWithoutEmail = Omit<User, "email">;

如需完整内容,请参阅博客中关于 TypeScript 内置实用类型 的指南。

never、窄化与保护条件

never 证明你已经处理了一切

never 是指不可能存在的值的类型,比如总是抛出异常的函数返回值:

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

它的真正作用体现在穷尽性检查中。以状态联合类型为例:

type Status =
  | "loading"
  | "success"
  | "error";

以及一个 default 分支将值传递给仅接受 never 的函数的 switch 语句:

function handleStatus(status: Status) {
  switch (status) {
    case "loading":
      return "Loading";
    case "success":
      return "Success";
    case "error":
      return "Error";
    default:
      return assertNever(status);
  }
}

function assertNever(value: never): never {
  throw new Error("Unexpected value: " + value);
}

当所有情况都处理完毕后,status 的值在进入 default 分支时已缩小为 never,此时会进行类型检查。现在假设有人扩展了该联合类型:

type Status =
  | "loading"
  | "success"
  | "error"
  | "cancelled";

未被处理的 "cancelled" 值会到达 assertNever,它无法被赋值为 never,编译器会指出所有需要更新的开关语句。

值的范围缩小遵循控制流

编译器会追踪各种检查是如何改变变量可能取值的范围的:

function print(value: string | number) {
  if (typeof value === "string") {
    console.log(value.toUpperCase());
  } else {
    console.log(value.toFixed(2));
  }
}

在第一个分支中,

value

其值已知为

string

而在第二个分支中则是

number

自定义类型守卫

你可以通过一个返回类型为类型谓词的函数来让编译器识别你自定义的类型,例如 value is User:

interface User {
  name: string;
}

function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "name" in value
  );
}

在守卫条件满足后,

const data: unknown = getData();

if (isUser(data)) {
  console.log(data.name);
}

编译器会将该值视为

data: User

位于代码块内部。需要注意的是,编译器完全信任该判别条件。此守卫仅检查name是否存在,而不验证它是否为字符串,因此这种粗略的守卫实际上等同于未经检查的断言。

使用区分联合来建模状态

一种常见但较为薄弱的设计方式是将所有可能的情况都放入一个带有可选字段的对象中:

interface State {
  status: string;
  data?: User;
  error?: string;
}

区分联合通过status标签分别对每种状态进行建模:

type State =
  | {
      status: "loading";
    }
  | {
      status: "success";
      data: User;
    }
  | {
      status: "error";
      error: string;
    };

根据标签进行切换后,每个分支仅包含该处存在的字段:

function render(state: State) {
  switch (state.status) {
    case "loading":
      return "Loading...";
    case "success":
      return state.data.name;
    case "error":
      return state.error;
  }
}

这样可以避免诸如

status = success
error = "Something went wrong"

宽松版本可以轻松实现这一点。其指导原则是建模有效状态,而非允许无效状态并在各处进行检测。

在不覆盖原值的情况下通过检查

satisfies 会验证表达式是否符合某种类型,同时保留该表达式本身推断出的类型:

const config = {
  port: 3000,
  host: "localhost"
} satisfies {
  port: number;
  host: string;
};

这对于配置对象来说非常理想,因为既需要进行检查,又希望保留字面值和精确的键名。可以对比一下断言的使用:

const config = {...} as Config;

as 会告诉编译器将该值视为某种类型,然后它会根据你的声明接受很多情况。而 satisfies 则要求编译器确认其是否符合该类型。当你的目的是进行验证而非覆盖检查器时,应优先选择

satisfies

而非

as

容易让人犯错的限制

readonly仅具有浅层限制

考虑一种包含只读属性的类型,其中某个属性是一个对象:

type User = {
  readonly name: string;
  readonly address: {
    city: string;
  };
};

无法重新赋值顶层属性:

user.name = "New Name";

但可以修改嵌套对象内的字段:

user.address.city = "Indore";

readonly仅适用于其标记的属性本身,不会递归生效。实现深度不可变性需要使用递归映射类型或运行时机制,另外请注意Object.freeze同样也仅具有浅层限制。

断言不会转换值

这样的双重断言可以正常编译:

const value = "123" as unknown as number;

但实际上并未发生任何转换。在运行时,

typeof value

仍然会显示

string

如果需要数字类型,需显式进行转换:

const value = Number("123");

断言只会改变编译器的认知,而不会改变值的实际内容。

可选属性并不总是等同于未定义

可选属性:

interface User {
  name?: string;
}

通常意味着该键可能不存在,因此对应一个空对象

{}

是有效的,同样

{
  name: "Lakhveer"
}

但是否允许显式指定

{
  name: undefined
}

则取决于配置设置。启用该功能后

{
  "exactOptionalPropertyTypes": true
}

可使编译器区分这两种情况。这对于那些

property missing

以及

property explicitly undefined

具有不同含义的API来说非常重要,例如在PATCH请求中,缺失的字段表示“保持不变”,而显式指定的值则表示“清除该字段”。

索引操作可能掩盖未定义的情况

TypeScript刻意不会试图防止所有运行时错误。尝试访问数组末尾之后的元素:

const numbers = [1, 2, 3];
const value = numbers[100];

在默认设置下,会被视为始终存在对应数值的类型。开启该选项后

{
  "noUncheckedIndexedAccess": true
}

实现访问功能

numbers[100]

作为报告输出

number | undefined

这迫使你需要处理缺失的情况。

字符串类型与关系型类型

模板字面量类型

TypeScript能够从其他字符串类型构建字符串类型:

type EventName =
  `user:${"created" | "updated" | "deleted"}`;

其展开结果为

"user:created"
"user:updated"
"user:deleted"

同样的技术也可用于描述路由:

type HttpMethod = "GET" | "POST";

type Endpoint =
  `${HttpMethod} /users`;

因此有效的值是

"GET /users"
"POST /users"

将各部分组合为计算型API

这些特性共同构成了:

keyof
typeof
conditional types
mapped types
template literals
infer
generics

首先创建一个从事件名称到有效载荷类型的映射:

type EventMap = {
  userCreated: {
    id: number;
  };

userDeleted: {
    id: number;
  };
};

然后,通用发射器可以通过keyof和索引访问将每个事件名称与其有效载荷关联起来:

class EventEmitter<Events extends Record<string, unknown>> {
  on<K extends keyof Events>(
    event: K,
    callback: (payload: Events[K]) => void
  ) {
    // ...
  }

emit<K extends keyof Events>(
    event: K,
    payload: Events[K]
  ) {
    // ...
  }
}

使用正确的有效载荷发送已知事件时可以成功编译:

const emitter =
  new EventEmitter<EventMap>();

emitter.emit("userCreated", {
  id: 1
});

而使用错误的有效载荷则会被拒绝:

emitter.emit("userCreated", {
  name: "Lakhveer"
});

编译器现在能够理解事件名称与其所需附带数据之间的关系。

启用严格模式

一个专业的项目通常应该从以下内容开始:

{
  "compilerOptions": {
    "strict": true
  }
}

那个单一的标志可以启用一系列检查功能,包括:

strictNullChecks
noImplicitAny
strictFunctionTypes
strictPropertyInitialization
useUnknownInCatchVariables

它还会启用诸如 strictBindCallApply 和 noImplicitThis 这样的选项。在出现错误后才添加安全措施,其成本远高于让编译器充当第一道防线。

使无效状态无法表示

考虑将请求生命周期建模为联合类型:

type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: User }
  | { status: "error"; error: string };

再将其与基于标志和可选字段的设计进行比较:

interface RequestState {
  loading: boolean;
  data?: User;
  error?: string;
}

后一种设计允许出现诸如这样的不合理情况

{
  loading: true,
  data: user,
  error: "Something failed"
}

而第一种方式则使得这些组合无法构建。如果要从 TypeScript 中汲取一条设计原则,那就是这条。如需更深入的了解,请参阅TypeScript 中超越基础类型注解的领域建模。

生产级 TypeScript 的设计原则

了解相关特性并不等同于能够运用它们进行良好的设计。以下原则旨在指导你如何构建系统。

将任何类型视为最后手段

与其

function process(data: any) {
  // ...
}

优先选择

function process(data: unknown) {
  // validate/narrow first
}

而且,在明确其结构后,更是如此

function process(data: User) {
  // ...
}

只有在你完全清楚自己放弃了什么时,才使用 any。

让推断处理显而易见的情况

这类注解只会增加冗余信息:

const name: string = "Lakhveer";
const age: number = 28;

编译器已经知晓:

const name = "Lakhveer";
const age = 28;

在需要明确约定契约的地方使用显式类型。

让类型承载意图

单纯的字符串信息量很少:

function process(value: string) {}

命名类型能说明值的含义:

type UserId = string;
function processUser(userId: UserId) {}

需要注意一点:像 UserId = string 这样的别名虽然能体现意图,但并不能阻止你在期望 UserId 的地方传递 ProductId,因为两者本质上都是字符串。如果混淆它们会带来实际风险,那么使用专用类型就能起到约束作用。

让类型贴近业务领域

由原始字符串构成的接口:

function createOrder(
  userId: string,
  productId: string,
  status: string
) {}

使用领域类型后会清晰得多:

type OrderStatus =
  | "pending"
  | "paid"
  | "cancelled";

function createOrder(
  userId: UserId,
  productId: ProductId,
  status: OrderStatus
) {}

现在编译器能理解你的业务术语,而不仅仅是基本数据类型。

优先使用联合类型而非布尔标志

独立的布尔值允许出现不可能的组合:

interface State {
  loading: boolean;
  success: boolean;
  error: boolean;
}

联合类型一次只能表示一种状态:

type State =
  | "loading"
  | "success"
  | "error";

当某个状态需要独立的数据时,应使用带区分符的联合类型。

在系统边界进行验证

编译器无法保证从外部传入的任何内容的可靠性:

API
Database
User input
Environment variables
Files
Third-party services
JSON
Local storage

应将所有这些内容视为不可信的,并通过统一的处理流程进行处理:

External data
     ↓
Runtime validation
     ↓
Trusted typed data
     ↓
Application logic

经过验证的数据会成为可信的类型化数据,只有这类数据才能进入应用程序逻辑。

避免过度设计

你可以创建极其复杂的类型,但像这样的接口定义

type Something<T, U, V, X extends ...> = ...

如果六个月后团队里没人能解释它,那就是技术债务。类型应当让代码库更清晰,而非展示聪明才智。

为正确使用设计 API

使用多个位置标志的调用很容易出错:

createUser(
  "Lakhveer",
  "admin",
  true,
  false,
  undefined
);

类型化的选项对象具有自描述性,能提供更好的自动补全功能:

createUser({
  name: "Lakhveer",
  role: "admin",
  active: true
});

采用组合方式而非构建庞大的接口

包含数十个字段的单一接口

interface User {
  // 50 properties
}

比通过交集组合的较小概念更难理解:

type Identifiable = {
  id: string;
};

type Timestamped = {
  createdAt: Date;
  updatedAt: Date;
};

type User =
  Identifiable &
  Timestamped & {
    name: string;
  };

将编译器纳入测试策略中

类型不能替代测试,但它们能在测试运行前消除整类错误。假设

type PaymentStatus =
  | "pending"
  | "paid"
  | "failed";

添加一个新成员,例如

"refunded"

结合穷尽性检查,就能发现所有未处理该成员的地方。

在思维中区分编译时与运行时

先弄清楚自己当前处于哪一层。这只是编译时的操作:

interface User {
  id: number;
}

这是一种运行时检查:

if (typeof value === "object") {
}

而这是对外部数据的运行时验证:

UserSchema.parse(data);

查看生成的 JavaScript 代码

当行为令人困惑时,先弄清楚该代码最终会生成怎样的 JavaScript。了解这两层内容就能解释大多数意外现象。

深入理解 JavaScript

TypeScript 是建立在 JavaScript 之上的,因此基础知识依然很重要:

Closures
Promises
Event Loop
Prototypes
this
Modules
Destructuring
Async/Await
Objects
Arrays
Functions
Hoisting
Scopes

谨慎配置 tsconfig.json

不要盲目复制配置文件。要明白每个选项的利弊:

{
  "strict": true,
  "noUncheckedIndexedAccess": true,
  "exactOptionalPropertyTypes": true,
  "noImplicitOverride": true
}

每一个标志都会影响安全性与便捷性之间的平衡;例如,noImplicitOverride 要求任何替换基类中方法的实现都必须使用 override 关键字。

分层思维模型

可以将 TypeScript 想象为两层平行结构:运行时的 JavaScript 与编译时的类型系统,各层的类型相关功能相互依托:

                TypeScript
                     │
        ┌────────────┴────────────┐
        │                         │
   JavaScript                 Type System
        │                         │
 Runtime Behavior          Compile-Time Safety
        │                         │
 Browser / Node            Type Relationships
                                  │
                       ┌──────────┼──────────┐
                       │          │          │
                    Generics    Unions    Inference
                       │          │          │
                    keyof      never     conditional
                       │          │          │
                    mapped     guards      infer
                       │          │          │
                       └──────────┴──────────┘

这样看来,TypeScript 就不再只是一堆语法规则,而变成了一种用于描述值之间关系的编程语言:哪些值是允许的、对象之间如何关联、可能出现哪些状态、函数接受什么参数并返回什么结果,以及还有哪些情况尚未处理。

核心要点

最值得掌握的功能并非那些最炫酷的:

Generics
Unions
Narrowing
Inference
keyof
typeof
Mapped Types
Conditional Types
infer
Discriminated Unions
never
unknown
satisfies
Template Literal Types
  • 类型在运行时会被消除,因此外部数据始终需要验证。
  • 兼容性是基于结构层面的,仅对新的对象字面量进行额外检查。
  • as const、typeof、keyof以及条件类型和映射类型能让您推导出新类型,而非重复创建现有类型。
  • 当您的目的是进行检查而非覆盖时,建议使用unknown代替any,使用代替as。
  • 利用区分联合类型和never,可以让编译器判断某个状态是否真的可能发生。
  • 目标并非创建最复杂的类型,而是让编译器在程序运行之前就能回答“这种状态会出现吗?”的问题。使用TypeScript是为了设计更安全的代码,而不仅仅是描述您已编写的代码。

    相关阅读

  • 将运行时错误转化为编译错误的十种 TypeScript 模式 — 了解十种 TypeScript 技巧,包括区分联合类型、satisfies 运算、带标记的类型以及 infer 函数,这些技巧能让编译器在代码发布前就拒绝无效状态。
  • TypeScript 编译器能捕获而 JavaScript 却会漏过的内容 — 对比 JavaScript 和 TypeScript 的差异:类型推断、注解、原始类型、any 类型、联合类型以及带类型的函数,还有 tsc 实际会标记并报告的问题。