# 字面量类型

Source: https://codewiki.com/zh/typescript/literal-types/

> - **what**: 字面量类型把类型限制为某个精确值；多个字面量组成的联合可以直接表达协议方法、状态和其他封闭选项。
> - **trap**: 可变位置中的字面量通常会拓宽为 `string`、`number` 或 `boolean`，而类型断言又可能掩盖运行时输入根本不属于该联合。
> - **fix**: 用注解声明契约，用 `as const` 保留常量数据的精度，用 `satisfies` 检查形状，并在外部数据进入联合前执行运行时验证。

## 是什么，为什么存在

字面量类型（literal type）只包含一个精确值，例如字符串值 `"queued"`、数字值 `404`、大整数值 `10n`，或者布尔值 `true`。
`string` 表示所有字符串，而 `"queued"` 只表示这一个字符串。
这种精度让编译器判断一个值是否真的属于某个有限协议，而不只是拥有相同的原始类型。

实际代码通常把多个字面量放进联合类型（union type），例如 `"GET" | "POST"`。
这个类型表示一个封闭集合：值可以是 `"GET"`，也可以是 `"POST"`，但不能是任意其他字符串。
字符串字面量最常见，数字、`bigint` 和布尔字面量也遵循相同的可赋值规则。

字面量类型解决的是“这个位置允许哪些精确值”，而不是“这个位置存放哪种原始数据”。
当业务规则由少量稳定选项组成时，这个区别很有用，例如任务状态、命令名称、日志级别、重试次数或响应标签。
拼错的状态可以在编译阶段被拒绝，编辑器也能根据联合给出补全。

字面量字段还可以连接一个对象的标签与载荷。
如果 `status: "complete"` 的成员一定带有 `url`，而 `status: "failed"` 的成员一定带有 `message`，检查 `status` 后就能安全访问对应字段。
这种结构叫作可辨识联合（discriminated union）。

字面量类型不是输入验证器，也不会把类型信息带进生成的 JavaScript。
网络响应、JSON、环境变量和普通 JavaScript 调用方仍可能提供集合外的值。
这些值应先以 `unknown` 或宽泛的运行时类型进入边界，通过真实检查后才能获得字面量联合类型。

如果允许值本来就是开放的，例如用户名或任意 URL，就应继续使用 `string`。
把每个示例值都做成联合会把数据误写成封闭协议，并让正常扩展变成无意义的类型改动。
字面量类型适合有限、可枚举而且由程序维护的选择。

## 工作原理

类型检查器把类型看作一组可能值。
字面量类型对应单元素集合，联合操作符 `|` 则合并多个集合。
因此，`type Speed = "standard" | "express"` 恰好包含两个字符串值，并且仍然可以赋给接受任意 `string` 的位置。

反方向不成立。
一个普通 `string` 可能在运行时是 `"overnight"`，所以不能赋给只接受 `Speed` 的参数。
赋值是否有效取决于源类型的每个可能值能否落在目标集合中，而不是当前运行时值碰巧看起来正确。

### 值、类型与可赋值性

表达式 `"express"` 既产生 JavaScript 字符串值，也向检查器提供精确的字面量信息。
类型注解 `const speed: Speed = "express"` 检查初始化值属于联合，并把变量的公开契约固定为 `Speed`。
以后修改联合时，所有依赖该契约的位置会一起重新检查。

字面量联合没有自己的运行时容器。
它不会像普通 `enum` 那样生成可读取的对象，也不能用 `Object.values(Speed)` 枚举，因为 `Speed` 只存在于类型位置。
运行时也需要选项列表时，可以从一个 `as const` 元组派生联合，让值与类型共享一份来源。

布尔类型可以理解为 `true | false`。
单独的 `true` 类型适合表达已经满足的条件或泛型分支结果，但普通功能开关通常仍应使用 `boolean`。
数字字面量适合少量有领域含义的代码或档位，不适合描述任意整数范围。

`null` 和 `undefined` 也各自只有一个运行时值，并且经常出现在联合中。
它们主要用于缺失值建模；本文重点讨论可由字符串、数字、`bigint` 和布尔表达式产生并发生拓宽的字面量。

### 推断与字面量拓宽

类型推断（type inference）会从初始化表达式、调用参数和周围的预期类型收集信息。
检查器需要在精度与后续可变性之间取舍。
一个不会重新赋值的原始 `const` 可以保持精确，而 `let` 通常要允许同类的其他值。

例如，`const mode = "dark"` 通常得到类型 `"dark"`，因为变量绑定不能换成别的值。
`let mode = "dark"` 通常得到 `string`，否则下一行合法的 `mode = "light"` 就会被初始化值意外禁止。
从精确字面量移动到更宽原始类型的过程叫作字面量拓宽（literal widening）。

`const` 只固定变量绑定，不会让对象属性自动只读。
在 `const request = { method: "GET" }` 中，之后仍能执行 `request.method = "POST"`，所以 `method` 通常推断为 `string`。
同理，普通数组字面量 `const methods = ["GET", "POST"]` 通常得到 `string[]`，而不是固定长度元组。

显式上下文可以避免过早拓宽。
如果变量声明为 `const method: "GET" | "POST" = "GET"`，或者对象传给已声明该联合的参数，初始化值会按这个预期类型检查。
上下文给出允许集合，表达式则必须落在集合内。

下面的对照描述常见声明产生的静态结果。

| 写法 | 主要结果 | 是否限制运行时修改 |
| --- | --- | --- |
| `const value = "on"` | 绑定通常保留 `"on"` | 只禁止重新绑定变量 |
| `let value = "on"` | 通常拓宽为 `string` | 否 |
| `const item = { mode: "on" }` | `mode` 通常是 `string` | 否 |
| `const item = { mode: "on" } as const` | `mode` 是只读的 `"on"` | 不会调用 `Object.freeze` |

### `as const` 保留字面量信息

`as const` 断言（const assertion）要求检查器不要拓宽字面量表达式。
对象字面量的属性会成为 `readonly`，数组字面量会成为只读元组，其中每个元素保留自己的字面量类型。
它适合选项表、路由表、动作定义和其他由源码维护的常量数据。

`as const` 是静态指令，不是运行时冻结操作。
生成的 JavaScript 中没有这段类型语法，引用到的已有可变对象也不会被递归复制或冻结。
如果运行时调用方可能修改对象，需要另外设计所有权或使用真实的冻结操作。

`as const` 还可能得到比 API 契约更窄的类型。
如果对象之后确实要在多个允许值之间变化，应该给它一个可变的联合注解，而不是先做常量断言再到处强制转换。
精确并不总是更好；精度必须符合值的生命周期。

### `satisfies` 检查而不替换表达式类型

`satisfies` 操作符检查左侧表达式可以赋给右侧类型，同时保留左侧有用的推断细节。
它很适合检查配置对象是否覆盖规定的键、属性值是否属于联合，同时让每个已写入的值继续保持具体类型。
与直接注解相比，它通常不会把整个变量的可见类型替换成目标类型。

`satisfies` 不会让对象自动只读，也不会验证运行时数据。
需要只读字面量时可以在合适位置结合 `as const`；需要验证 JSON 时必须执行值检查。
它们解决的是不同问题，不能因为语法都出现在表达式末尾就互换。

### 比较会触发收窄

当联合值与某个字面量比较时，控制流收窄（control-flow narrowing）会在对应分支移除不可能的成员。
对于 `Speed`，条件 `speed === "express"` 让真分支中的值精确为 `"express"`，假分支则只剩 `"standard"`。
这是普通 JavaScript 比较与静态联合信息共同产生的结果。

可辨识联合把这个规则扩展到整个对象。
联合的每个成员共享一个属性名，但给它不同的字面量值；检查该属性后，TypeScript 会同时收窄其他字段。
标签必须真正区分各成员，不能在每个成员上都写成相同的宽泛 `string`。

`switch` 很适合处理封闭联合，但只有写出穷尽性检查，新增成员时才一定会暴露遗漏。
所有已知分支返回后，剩余值应为 `never`；把它传给只接受 `never` 的辅助函数即可让编译器验证这一点。
一个什么都接受的 `default` 分支反而会隐藏新成员。

## 示例

下面四个示例从单个联合开始，接着建立值与类型的单一来源，再把字面量用作对象标签，最后比较 `satisfies` 与直接注解。
每段代码都可以单独运行，不依赖网络或其他文件。

### 限制函数输入

`ShippingSpeed` 把配送速度限制为两个允许值。
函数内部的比较也会把两个分支分别收窄为对应字面量。

<!-- quick -->

```typescript
// file: shipping.ts
type ShippingSpeed = "standard" | "express";

function dispatch(orderId: string, speed: ShippingSpeed): string {
  const transit = speed === "express" ? "1 day" : "4 days";
  return `${orderId}: ${speed} (${transit})`;
}

const selected: ShippingSpeed = "express";

console.log(dispatch("order-104", selected));
console.log(dispatch("order-105", "standard"));
```

```text
order-104: express (1 day)
order-105: standard (4 days)
```

<!-- /quick -->

注解把函数边界写成封闭契约，调用方仍然直接传普通字符串字面量。
如果传入 `"overnight"`，错误会出现在调用位置，而不是等到函数内部落入意外分支。

运行输出只包含普通字符串。
`ShippingSpeed` 在编译后被擦除，所以这个精确类型没有额外的运行时对象或转换成本。

### 从运行时元组派生联合

当程序既要在运行时枚举选项，又要在类型系统中复用它们时，先写值列表。
`typeof CHANNELS[number]` 取得只读元组所有数字索引位置的元素类型，因此生成 `"email" | "sms" | "push"`。

```typescript
// file: channels.ts
const CHANNELS = ["email", "sms", "push"] as const;
type Channel = (typeof CHANNELS)[number];

function isChannel(value: string): value is Channel {
  return CHANNELS.some((channel) => channel === value);
}

function subscribe(input: string): string {
  if (!isChannel(input)) {
    return `unsupported: ${input}`;
  }

  return `subscribed: ${input}`;
}

console.log(CHANNELS.join(", "));
console.log(subscribe("push"));
console.log(subscribe("fax"));
```

```text
email, sms, push
subscribed: push
unsupported: fax
```

这里的元组是运行时事实，`Channel` 是从它派生的静态事实。
增加一种渠道时，只修改 `CHANNELS` 就会同时更新迭代结果、守卫和联合类型。

`isChannel` 执行真实比较，所以它可以把外部字符串收窄为 `Channel`。
类型谓词 `value is Channel` 描述函数成功返回时已经证明的事实；如果实现与谓词不一致，检查器不会替你发现这个谎言。

这个模式适合由当前程序拥有的短列表。
如果允许值由服务器部署、插件或数据库动态决定，就不能把构建时元组假装成完整的运行时来源。

### 用字面量标签关联状态与数据

每个 `UploadState` 成员都有 `status`，但其余字段取决于这个标签。
`switch` 检查标签后，每个分支只能访问该状态真正拥有的载荷。

```typescript
// file: upload-state.ts
type UploadState =
  | { status: "queued"; file: string }
  | { status: "uploading"; file: string; percent: number }
  | { status: "complete"; file: string; url: string }
  | { status: "failed"; file: string; message: string };

function assertNever(value: never): never {
  throw new Error(`Unexpected state: ${JSON.stringify(value)}`);
}

function describe(state: UploadState): string {
  switch (state.status) {
    case "queued":
      return `${state.file}: waiting`;
    case "uploading":
      return `${state.file}: ${state.percent}%`;
    case "complete":
      return `${state.file}: ${state.url}`;
    case "failed":
      return `${state.file}: ${state.message}`;
    default:
      return assertNever(state);
  }
}

const states: UploadState[] = [
  { status: "queued", file: "avatar.png" },
  { status: "uploading", file: "report.pdf", percent: 60 },
  { status: "complete", file: "map.svg", url: "/files/map.svg" },
];

for (const state of states) console.log(describe(state));
```

```text
avatar.png: waiting
report.pdf: 60%
map.svg: /files/map.svg
```

`assertNever` 的主要作用发生在类型检查阶段。
如果给 `UploadState` 添加 `"paused"` 成员却不添加对应分支，`state` 在 `default` 中就不再是 `never`，编译会失败。

辅助函数中的 `throw` 仍有运行时价值。
未经验证的 JavaScript 或错误断言可能绕过静态类型，运行时遇到未知标签时不会静默产生看似正常的文本。

不要把几个可选字段塞进一个带宽泛 `status: string` 的接口来替代这个联合。
那种结构会允许 `status: "complete"` 却没有 `url` 的非法组合，也无法让控制流建立字段关系。

### 用 `satisfies` 检查配置形状

配置必须恰好包含两个环境，每个重试次数也必须属于规定联合。
`satisfies` 执行这些检查，同时让两个已写入的重试值分别保留为 `0` 和 `3`。

```typescript
// file: service-config.ts
type Environment = "development" | "production";
type ServiceConfig = {
  endpoint: string;
  retry: 0 | 1 | 2 | 3;
};

const services = {
  development: { endpoint: "http://localhost:3000", retry: 0 },
  production: { endpoint: "https://api.example.com", retry: 3 },
} satisfies Record<Environment, ServiceConfig>;

function retryLabel(count: 0 | 3): string {
  return count === 0 ? "no retries" : "three retries";
}

console.log(`${services.development.endpoint}: ${retryLabel(services.development.retry)}`);
console.log(`${services.production.endpoint}: ${retryLabel(services.production.retry)}`);
```

```text
http://localhost:3000: no retries
https://api.example.com: three retries
```

如果键名拼错或写入 `retry: 5`，错误会落在配置对象上。
如果直接把变量注解为 `Record<Environment, ServiceConfig>`，读取具体属性时看到的重试类型会是完整联合 `0 | 1 | 2 | 3`。

这里没有使用 `as const`，所以 `endpoint` 等可变属性并未整体变成只读。
该例需要的是形状校验与字面量精度，不是假定整个配置永远不能修改。

`satisfies` 右侧同样会在输出 JavaScript 时消失。
如果这份配置来自文件而不是源码对象，仍要解析并验证实际内容。

## 陷阱

> **陷阱:** 以为 `const` 会保留对象属性的字面量类型。`const request = { method: "GET" }` 只禁止重新绑定 `request`，属性仍可修改，所以 `method` 通常是 `string`。

**修复方法：** 如果对象要修改，就给属性写联合注解；如果它是常量数据，就使用 `as const`。
只需要检查结构但不想替换推断类型时，使用 `satisfies`。

> **陷阱:** 把 `as const` 当成运行时冻结或输入验证。它不会调用 `Object.freeze`，也不能证明从网络或 JSON 得到的字符串属于某个联合。

**修复方法：** 在信任边界执行成员检查，并根据真实所有权决定是否冻结对象。
类型精度、运行时有效性和可变性是三项不同的保证。

> **陷阱:** 用类型断言（type assertion）修补拓宽错误，例如把任意 `string` 写成 `value as Status`。断言只要求检查器相信作者，不会改变或检查运行时值。

**修复方法：** 如果值来自受控源码，修正声明位置的推断或注解；如果值来自外部，先验证再返回联合。
不要把断言当作从宽类型到窄类型的通用转换函数。

> **陷阱:** 在字面量联合中加入宽原始类型，例如 `"auto" | string`。因为 `string` 已经包含 `"auto"`，整个联合接受所有字符串，封闭集合的约束就消失了。

**修复方法：** 先决定 API 是封闭集合还是开放字符串。
确实允许任意扩展值时，运行时验证、编辑器补全和兼容策略应按开放协议设计，不能继续声称联合会拒绝未知值。

> **陷阱:** 在 `switch` 中写一个返回通用结果的 `default`，并认为所有联合成员都已安全处理。新增字面量成员后，这个分支会吞掉遗漏，编译器没有理由报错。

**修复方法：** 让已知分支结束控制流，再把剩余值交给 `assertNever`。
同时保留运行时抛错，以便发现从未验证的调用路径。

> **陷阱:** 用数字字面量联合表示任意范围，例如试图列出所有合法端口或金额。联合擅长枚举少数有名称的档位，却不能表达连续范围或运行时业务约束。

**修复方法：** 对开放数值使用 `number`，并在运行时检查有限性、整数性和范围。
只有当 `0 | 1 | 2 | 3` 本身就是领域选项时，才把这些数字写成字面量联合。

<!-- deep -->

## 四种控制精度的工具

注解、`as const`、`satisfies` 和普通类型断言表面上都能出现在值附近，但它们回答的问题不同。
选择时先问这个位置是在声明契约、保留表达式精度、检查结构，还是提供编译器无法推导的证据。

| 工具 | 它要求检查器做什么 | 典型风险 |
| --- | --- | --- |
| 类型注解 | 让变量或边界采用声明的目标类型 | 可能有意拓宽初始化表达式 |
| `as const` | 保留字面量并把字面量结构视为只读 | 被误当成运行时冻结 |
| `satisfies` | 检查表达式可赋给目标，同时保留有用推断 | 被误当成运行时验证 |
| `as SomeType` | 接受作者提供的类型证据 | 证据可能是错的 |

公开函数参数和返回值通常适合注解，因为调用方需要稳定契约。
源码中的选项表适合 `as const`，因为值本身是运行时来源，而且不应随意修改。
大型配置对象适合 `satisfies`，因为你常常既要检查键和值，又想保留各属性的具体信息。

普通断言应留给已经由其他机制证明、但检查器无法表达的窄小接缝。
如果同一种断言反复出现，应回到数据来源、函数签名或验证器修正类型流。
大量 `as Status` 通常不是字面量类型太严格，而是程序没有记录值如何成为 `Status`。

这些工具可以组合，但组合顺序表达具体意图。
例如，一个静态查找表可以先用 `as const` 保留只读字面量，再用 `satisfies` 检查它覆盖某个键集合。
组合后仍没有任何运行时验证，因为两种语法都会被擦除。

## `const` 类型参数与调用点推断

TypeScript 还允许在泛型类型参数前写 `const`，让调用点对直接写出的对象、数组和原始字面量采用更接近 `as const` 的推断偏好。
这对编写元组工厂、路由定义器和事件表辅助函数很有用，因为调用方不必在每个参数后重复 `as const`。
它改变的是推断偏好，不是函数参数的运行时值。

`const` 类型参数只影响调用表达式中的候选类型。
如果值先保存到已经拓宽的变量，再传给泛型函数，原来的字面量信息不会被恢复。
API 设计者仍需为类型参数提供与预期只读结果相容的约束。

不要为了保留每个局部字面量就把所有泛型参数都标成 `const`。
当调用方需要可变集合或宽泛返回契约时，过窄推断会增加赋值摩擦。
它适合“调用时写下的结构就是契约组成部分”这一类 API。

## 静态集合与运行时世界

字面量联合的封闭性只存在于经过 TypeScript 检查的代码路径中。
类型擦除后，JavaScript 函数仍能收到任意字符串；`any`、错误断言和未验证数据也能绕过集合。
因此，系统必须明确哪一步把运行时值升级为已验证的联合成员。

短小稳定的集合可以像 `CHANNELS` 示例那样，用一个只读元组同时驱动验证与类型。
较大的协议通常已有模式、接口描述或服务器契约，应从权威来源生成运行时解析器与 TypeScript 类型，并测试两者同步。
手写两份看似相同的列表会在新增或删除成员时产生漂移。

验证成员身份只是第一步。
可辨识联合的外部输入还要验证对象非空、判别字段是字符串，并检查该成员要求的全部载荷字段。
只检查 `status === "complete"` 却直接断言整个对象，会让缺少 `url` 的数据越过边界。

错误处理也属于协议设计。
内部不可能状态适合通过 `assertNever` 暴露编程错误，外部未知值则可能需要返回结构化解析错误、记录兼容性事件或启用前向兼容路径。
不要用一个静默 `default` 同时处理这两类情况。

字面量类型最可靠的用法是把静态契约与运行时证据连成一条可审查路径。
源码常量提供选项，验证器证明输入属于选项，联合保存证明，控制流再根据字面量选择合法载荷。
其中任何一步靠断言跳过，后面的精确类型都会显得比真实数据更可信。

<!-- /deep -->

[检查点: typescript/literal-types](https://codewiki.com/zh/typescript/literal-types/#checkpoint)

## 延伸阅读

- [TypeScript 手册：字面量类型](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#literal-types)
- [TypeScript 手册：可辨识联合与穷尽性检查](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions)
- [TypeScript 3.4 发布说明：`const` 断言](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-4.html#const-assertions)
- [TypeScript 4.9 发布说明：`satisfies` 操作符](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html#the-satisfies-operator)
- [TypeScript 5.0 发布说明：`const` 类型参数](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-0.html#const-type-parameters)
