《TypeScript编程入门》7.1 联合类型与字面量类型

本节先讲联合类型,说明如何用「A | B」表达取值可能是若干形态之一,再引入字面量类型,把具体的字符串、数字、布尔值当作类型使用,从而写出既安全又贴近业务语义的约束。你会看到 let 与 const 在字面量推断上的差异、as const 的实际作用、联合类型成员访问的边界,以及字面量联合与枚举之间的取舍。读完后你能用字面量联合取代散落各处的魔法字符串,为下一节判别联合与类型收窄打好基础。

本节目标:理解联合类型「取值是若干候选之一」的表达方式,掌握字面量类型把具体值变成类型的能力,学会用二者的组合替代魔法字符串,并弄清联合类型在成员访问、字面量推断、与枚举对比上的边界。读完后你能独立为一个业务字段设计出精确的类型约束。

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: stringstring可以
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) 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes