本节目标:理解联合类型「取值是若干候选之一」的表达方式,掌握字面量类型把具体值变成类型的能力,学会用二者的组合替代魔法字符串,并弄清联合类型在成员访问、字面量推断、与枚举对比上的边界。读完后你能独立为一个业务字段设计出精确的类型约束。
7.1 联合类型与字面量类型
到第 6 章为止,我们描述的类型基本都是「一个确定的结构」:一个对象有哪几个字段,一个类有哪些方法。但真实业务里更常见的情况是——某个值不是唯一确定的,它可能是这几种形态中的任意一种。订单状态是「待支付 / 已支付 / 已取消」,日志级别是「debug / info / warn / error」,配置项可能是字符串也可能是数字。用单一类型去描述它们,要么太松(string 什么都放得进来),要么太死(写死一个值就没法变)。
TypeScript 给出的答案就是联合类型(union type)与字面量类型(literal type)。这两者常常一起出现,构成了本书后续所有类型收窄技术的地基。
一个从字符串说起的问题
先看一段没有类型约束的代码,它来自很多真实项目的早期形态:
function setLogLevel(level: string) {
console.log(`日志级别已设为 ${level}`);
}
setLogLevel("info"); // 正常
setLogLevel("warn"); // 正常
setLogLevel("warning"); // 拼写错误,但编译器完全不管
setLogLevel("hello"); // 完全无关的字符串,照样通过
string 的粒度太粗了。它允许任意字符串,包括拼错的 "warning" 和毫不相干的 "hello"。这类错误要到运行期才发现,甚至可能一直不报错,只是日志级别静默地失效。
我们真正想表达的是:「这个参数只能是 "debug"、"info"、"warn"、"error" 这四者之一」。这个「之一」就是联合,而这四个具体字符串就是字面量类型。
type LogLevel = "debug" | "info" | "warn" | "error";
function setLogLevel(level: LogLevel) {
console.log(`日志级别已设为 ${level}`);
}
setLogLevel("info"); // OK
setLogLevel("warning"); // 编译错误
最后一行会得到这样的报错:
Argument of type '"warning"' is not assignable to parameter of type 'LogLevel'.
Type '"warning"' is not assignable to type '"debug" | "info" | "warn" | "error"'.
注意报错的措辞——编译器把联合的四个成员全部列了出来。它不是在说「你传错了类型」,而是在说「你传的这个值不在候选清单里」。这正是字面量联合的价值:约束的不是「是不是字符串」,而是「是不是清单里的某一个」。
联合类型的语法与语义
联合类型的语法很直观,用竖线 | 把若干类型连起来即可:
type ID = string | number;
type MaybeName = string | undefined;
type Status = "pending" | "paid" | "cancelled";
type Value = string | number | boolean | null;
它的语义是并集:一个值只要属于其中任意一个成员类型,就属于这个联合类型。因此下面这些赋值都合法:
let id: ID;
id = 1001; // number 成员
id = "u_1001"; // string 成员
// id = true; // 错误:boolean 不在 ID 中
反过来,如果一个值不属于任何成员,就会被拒绝。这条规则听起来平淡,但它带来的第一个实际影响是:联合类型的变量只能访问所有成员共有的成员。
只能访问「公共成员」
这是初学者最容易困惑的一点。看下面这个例子:
type Input = string | string[];
function normalize(input: Input) {
return input.toUpperCase(); // 编译错误
}
报错是:
Property 'toUpperCase' does not exist on type 'string | string[]'.
Property 'toUpperCase' does not exist on type 'string[]'.
原因很直白:Input 的取值可能是数组,而数组没有 toUpperCase 方法。编译器无法确定运行期到底是哪一种,所以它只允许你访问两种成员都具备的属性与方法——在这里,只有 length、toString、valueOf 这类来自 Object 的公共成员。
function describe(input: Input) {
return input.length; // OK:string 和 string[] 都有 length
}
那要真正处理字符串或数组各自的行为怎么办?答案是先收窄、再使用——这正是本章 7.2、7.3 两节的主题。这里只需要先建立一个直觉:
| 表达式 | 结果类型 | 能否调用 toUpperCase |
|---|---|---|
let x: string | string | 可以 |
let x: string | string[] | string | string[] | 不可以 |
收窄到 string 之后 | string | 可以 |
换句话说,联合类型是「宽」的,而使用具体能力时需要「窄」的视角。类型收窄就是把宽变窄的过程。
字面量类型:把具体的值当作类型
字面量类型是 TypeScript 一个非常独特的设计:一个具体的值,本身就可以是一个类型。比如 "info" 既是一个字符串值,也是一个类型,且这个类型只接受 "info" 这一个值。
let a: "info" = "info"; // OK
// a = "warn"; // 错误:不能把 "warn" 赋给类型 "info"
支持字面量类型的有三种原始值:
// 字符串字面量
type Direction = "north" | "south" | "east" | "west";
// 数字字面量
type Dice = 1 | 2 | 3 | 4 | 5 | 6;
type HttpSuccess = 200 | 201 | 204;
// 布尔字面量(真值类型)
type AlwaysTrue = true;
布尔字面量类型看起来有点奇怪——boolean 本身不就是 true | false 吗?确实如此,boolean 就是 true | false 的语法糖。单独用 true 类型主要用于「互斥标志」这类场景:
interface SuccessResponse {
ok: true;
data: unknown;
}
interface ErrorResponse {
ok: false;
message: string;
}
这两个接口稍加组合,就是 7.2 节要讲的判别联合。
let 与 const 的字面量推断差异
字面量类型最需要留心的地方是类型推断。同一个字符串,用 let 和 const 声明,推断出的类型并不一样:
const c = "info"; // 类型是 "info"(字面量类型)
let l = "info"; // 类型是 string(被拓宽了)
为什么?因为 const 声明后值不会再变,编译器有把握把它收窄到字面量;而 let 声明的变量随时可能被改写成别的字符串,所以它推断成更宽的 string。这个行为叫类型拓宽(widening)。
这个差异会导致一个非常常见的坑:
type LogLevel = "debug" | "info" | "warn" | "error";
let level = "info"; // 推断为 string
// setLogLevel(level); // 错误:string 不能赋给 LogLevel
const level2 = "info"; // 推断为 "info"
setLogLevel(level2); // OK
对象属性也会遇到同样的问题:
type LogLevel = "debug" | "info" | "warn" | "error";
// 错误:level 被拓宽为 string
// const config: { level: LogLevel } = { level: "info" };
等等——上面这行其实是合法的。当对象字面量被赋给一个有明确类型注解的目标时,编译器会做上下文类型推断,把 "info" 当作 LogLevel 来检查,而不会先拓宽成 string。真正会出问题的是「先声明变量、再传递」的写法:
type LogLevel = "debug" | "info" | "warn" | "error";
const config = { level: "info" }; // 推断为 { level: string }
// setLogLevel(config.level); // 错误:string 不能赋给 LogLevel
区别就在于:有没有一个「目标类型」在推断时约束字面量。有约束,就保留字面量;没约束,就拓宽成 string。
as const:主动保留字面量
当你确实需要让一个对象或数组保留字面量类型时,可以用 as const 断言。它会做两件事:把每个属性收窄为字面量类型,并把属性变成 readonly。
const levels = ["debug", "info", "warn", "error"];
// 推断为 string[]
const levelsConst = ["debug", "info", "warn", "error"] as const;
// 推断为 readonly ["debug", "info", "warn", "error"]
type LogLevel = typeof levelsConst[number];
// LogLevel = "debug" | "info" | "warn" | "error"
最后两行值得单独记住。typeof levelsConst 取出这个数组的类型,[number] 表示「取下标为 number 时得到的元素类型」,合起来就是把数组的元素类型摊平成联合。这是从「运行期常量数组」反推「编译期联合类型」的标准写法,好处是清单只有一份,值改了类型自动跟着改,不会出现两处不同步。
const ROLES = ["admin", "editor", "viewer"] as const;
type Role = typeof ROLES[number]; // "admin" | "editor" | "viewer"
function hasPermission(role: Role, action: string) {
// ...
}
as const 的只读性也需要注意,下面这行会报错:
const levelsConst = ["debug", "info"] as const;
// levelsConst.push("warn"); // 错误:readonly 数组没有 push
如果你想要可变数组又要字面量元素类型,需要显式注解:
const levels: ("debug" | "info" | "warn")[] = ["debug", "info"];
levels.push("warn"); // OK
字面量联合 vs 枚举
第 3 章介绍过 enum,这里做一次正面比较。同样表达日志级别,两种写法是:
// 写法一:enum
enum LogLevel {
Debug = "debug",
Info = "info",
Warn = "warn",
Error = "error",
}
// 写法二:字面量联合
type LogLevel2 = "debug" | "info" | "warn" | "error";
| 对比项 | enum | 字面量联合 |
|---|---|---|
| 编译产物 | 生成运行期对象 | 完全擦除,零产物 |
| 与 JSON / API 兼容 | 需手动转换 | 原生字符串,直接互通 |
| 提示体验 | LogLevel.Info 有自动补全 | 字符串有字面量补全 |
| 可扩展性 | 只能通过 enum 本身添加 | 可以随意联合其他字面量 |
| 使用门槛 | 需要 import | 字符串即可,无需引入 |
社区的主流倾向是:如果只是为了「一组固定取值」,优先用字面量联合;只有当确实需要在运行期遍历枚举成员、或需要反向映射(由值找名)时,enum 才更合适。本书后续示例默认使用字面量联合,因为它与类型擦除的整体设计更契合,也更利于与外部数据打交道。
常见坑与错误信息
| 现象 | 原因 | 处理方式 |
|---|---|---|
Type 'string' is not assignable to type '"a" | "b"' | let 声明的变量被拓宽成 string | 改用 const、加 as const,或显式标注目标类型 |
Property 'xxx' does not exist on type 'A | B' | 该成员不是所有候选共有 | 先用类型守卫收窄(见 7.3) |
as const 后无法 push | 数组被推断为 readonly | 显式写出可变的元素联合类型 |
| 拼写错误的值没报错 | 用了 string 而不是字面量联合 | 把类型改成字面量联合 |
| 两处清单不同步 | 类型与运行期数组各写一份 | 用 typeof arr[number] 从数组反推类型 |
与后续内容的关系
字面量联合提供的是「一组离散取值」的表达能力,但它还只是一维的:一个字段只能取几个固定值。真实业务里更常见的是「多个字段组合成若干种整体形态」,比如一个请求要么是加载中、要么是成功且带数据、要么是失败且带错误信息。这类建模需要给每种形态一个共同的判别字段,那就是下一节要讲的判别联合。
关于 type 别名与交叉类型的更多细节,可以回看 5.2 type 别名、联合与交叉
;如果你已经等不及想看联合类型能派生出哪些工具类型,可以先读 10.1 内置工具类型全解
中关于 Exclude 与 Extract 的部分。
如果你想先看字面量联合在更大规模下的用法,可以阅读本站的 TypeScript 高级类型 ,那篇文章从工具类型的角度继续往下讲;而 Zod 运行时校验 则展示了如何在运行期把外部数据校验成这些字面量联合,两者与本节形成互补。
小结
本节我们完成了三件事。第一,认识了联合类型 A | B,它表达「取值是若干候选之一」,代价是只能访问所有成员的公共成员。第二,认识了字面量类型,把 "info"、200、true 这样的具体值提升为类型,并用 | 组合成精确的取值清单,用来消灭魔法字符串。第三,弄清了类型拓宽的规则:let 会拓宽、const 不拓宽、有上下文类型约束时不拓宽,以及用 as const 主动保留字面量、用 typeof arr[number] 从常量数组反推联合类型。
需要记住的核心判断是:联合类型是宽的,使用具体能力前必须先收窄。 收窄的手段有两类——依赖共同判别字段的结构化收窄(判别联合),以及依赖运行期检查的守卫收窄(typeof、in、自定义守卫)。下一节 7.2 判别联合
我们先讲前者,看看如何用一个小小的 kind 字段把「若干形态之一」建模得既安全又易读。
阅读导航:上一节:6.3 接口实现与 mixin · 下一节:7.2 判别联合(Discriminated Unions) 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。