前两天调一个前端配置模块屏幕上整整一排类型报错看得人头疼。海鲜市场第一眼看起来是接口字段对不上实际上却是因为一个常量对象被推断得太宽——宽到把我要的精确字面量类型全给吞了。这个场景我太熟了团队里每隔几周就会有人被同样的事情卡住。TypeScript 值类型说白了就是研究具体的一个值如何在 TS 里变成一种类型。这是 TS 跟 Java、C# 这类传统静态语言最大的分野也是绝大多数人写了两三年 TS 还总觉得类型系统很玄的根源。这篇是这个系列里我认为最值得单独拆开讲的一篇适合那些已经会用 TS 写代码、但遇到类型报错习惯用 any 逃逸的开发者也适合把 TS 当 Java 写、动辄 interface 堆一摞的朋友。1. 从值到类型TS类型系统为什么这么设计1.1 类型不是拍脑袋声明的而是从值里推出来的很多人学 TS 时有一个先入为主的印象类型系统嘛不就是定义 interface、class、type然后给变量标注上去吗这确实是绝大多数静态语言的做法但它忽略了一个重要事实——TS 不是一门从设计时刻起就自带类型信息的语言。它面对的是海量历史悠久、类型全无的 JavaScript 代码。它必须做的一件核心事情是看着你写的值猜出它的类型。这就是为什么我反复强调值是 TS 类型系统的起点。你写const a 42TS 编译器不靠任何人的嘴自己推断出a的类型是数字。你写const obj { name: 张三 }它推断出对象类型里有一个字符串属性。类型不是先行的而是从数据本身长出来的。这个设计带来了两个巨大的连锁反应第一你可以不写一行类型注解照样获得类型检查第二值本身可以参与到类型运算里——因为类型是从值里抽出来的所以反过来把值当作类型也就成了顺理成章的事。这套思路在工程上改变了很多习惯。过去我写配置表第一反应是先写一个 interface然后小心翼翼保证实现和接口完全一致。现在我的第一反应是先写真实的值再让类型从值里推导。如果两者对不上TS 会第一时间告诉我而不是等运行时报错。1.2 let 和 const类型的宽与窄最直观的例子就是 let 和 const 推断出来的类型宽度差异。下面是 TS 新手很容易忽略的行为let a hello; // a 的类型是 string const b hello; // b 的类型是 hello同样是hello这个值为什么推断结果差这么多原因其实很朴素let声明了一个可以被重新赋值的变量。既然它之后可能变成任意字符串TS 只能给它一个宽泛的类型string。而const声明的变量在声明之后就不可能再被重新赋值了那它这一生的值就锁死是helloTS 自然可以大胆地把它推断成字面量类型hello。真正的坑在对象上。我见过太多人以为对象的属性也这么智能const config { mode: dev, // 你以为 mode 是 dev实际是 string retries: 3, // 你以为 retries 是 3实际是 number };就算整个对象是用const声明的对象的属性依然是可写的。JS 里你可以随手写config.mode test运行时没人拦得住。TS 在推断的时候必须把这种可能性算进去于是属性类型只能保守地放宽到string和number。这个属性拓宽行为是很多类型报错的源头。比如下面这段代码function setMode(mode: dev | prod) { // 只接受两个精确值 } setMode(config.mode); // 报错类型 string 不能赋值给类型 dev | prod初看非常反直觉config 明明是 constmode 为什么不是 dev因为 const 只冻结了变量绑定没有冻结对象属性。TS 在做类型推断时看到的不是你的美好愿望而是运行时可能发生的任何操作。这就是为什么在 TS 的世界里理解值如何被拓宽、如何防止拓宽比背 interface 语法重要得多。2. 字面量类型把具体的值任命为类型2.1 字符串、数字、布尔三种字面量类型怎么用字面量类型literal type是值类型最直接的表现一个具体的值直接可以担任一个类型。TS 里最常用的是三种type Name Jack; // 该类型只有一个值Jack type Count 3; // 该类型只有一个值3 type Enabled true; // 该类型只有一个值true单独用单个字面量类型意义不大它们一般都会组合成联合类型用来表示一个字段只允许这几个具体值。业务里最常见的写法是这样type Status pending | success | error; type Priority 1 | 2 | 3; type CanMerge true | false; // 其实等价于 boolean联合类型把多个字面量值组合成新的类型编译器在检查时会把变量实际的值跟这一串允许值逐一对齐。这种能力让许多原本需要运行时报错才能发现的问题提前到了编译期如果某个函数只接受 pending、success、error那你传一个 failed 进去编译器立刻报错连编译这一关都过不了。2.2 拓宽机制拆解什么时候一个值会变宽拓宽literal widening说的是一个具体的字面量值在类型推断过程中被放大成更宽泛的类型。一个 hello 会变成string一个 42 会变成number。理解拓宽基本就理解了值类型的脾气。除了上面提到的对象属性还有几个常见场景我也踩过坑let level 3; // 拓宽成 number因为 let 允许重赋值 const level2 3; // 保持 3因为 const 不可重赋值 const levels [1, 2, 3]; // 推断成 number[]而不是 [1, 2, 3] const arr [1, 2, 3] as const; // 才是 readonly [1, 2, 3]数组的推断比较微妙。const levels [1, 2, 3]里的 const 只保证数组变量本身不被重新赋值数组元素随时可以通过levels.push(4)修改。所以 TS 会推断成number[]而不是固定长度的元组[1, 2, 3]。如果你希望数组被当作固定结构的元组就必须告诉 TS这个数组从今往后不可变这就要用到 as const。拓宽机制背后始终贯穿着同一个逻辑TS 会假设你在运行时可能做任何合法操作。只有当它确信一个值不可能再改变时才会把它锁死在字面量类型上。这也是为什么同一个值在不同的声明方式下会得到完全不同的类型。2.3 as const给类型拓宽安装一个开关as const 是防止拓宽的最常用武器它像是一个断言告诉 TS这个值请完全按字面量理解不要给我放大也不要允许修改。用法非常简单写在值表达式的末尾const config { mode: dev, retries: 3, } as const; // config.mode 类型是 dev // config.retries 类型是 3 // config 对象的所有属性都是 readonly加上 as const 之后对象属性不仅收窄到了字面量还全部变成了只读属性。数组用 as const 会变成只读元组const stages [build, test, deploy] as const; // 类型是 readonly [build, test, deploy]只读这个副作用我一开始没在意后来发现它特别好用——它防止团队里有人偷偷往配置数组里塞东西从类型层面就把可变性关掉了。我把最常用的推断结果整理成一张表方便比对写法推断类型底层原因const s hihiconst 变量不能重赋值值可锁死let s histringlet 允许重赋值需宽类型const o { a: hi }{ a: string }对象属性运行时仍可修改const o { a: hi } as const{ readonly a: hi }关闭拓宽并冻结所有属性const arr [1, 2]number[]数组元素可以动态增删const arr [1, 2] as constreadonly [1, 2]变成只读元组长度固定另外TS 4.9 以后引入的satisfies操作符也值得顺带提一句。它可以在保留精确字面量推断的同时校验对象是否符合某个更大的接口约束适合做配置校验。不过日常简化代码as const已经能解决八成的拓宽问题先把这张表记牢比学一堆花哨操作有意义得多。3. typeof把值提取成类型的桥3.1 TS世界里存在两张表值空间与类型空间聊值类型绕不开一个底层概念TS 里存在两套并行的世界。一套叫值空间装的是运行时会真实存在的东西变量、常量、函数、类实例这些在编译后的 JS 里都还活着。另一套叫类型空间装的是编译期用来做检查的东西interface、type 别名、泛型参数它们在编译完成之后会被完全擦除掉。为什么必须有这么一层区分因为 TS 在语法上要同时兼容 JS而 JS 本身就分表达式和声明两个体系。一个标识符到底在哪个空间有意义取决于它是由什么语法声明出来的。我用一张表帮你快速记忆声明形态能否当值用能否当类型用const/let/function能不能interface/type不能能class能构造器能实例类型enum能成员对象能枚举成员类型最典型的是class它在值空间是一个构造函数可以用new调用在类型空间则是这个类的实例类型可以对变量做类型标注。interface则只能老老实实待在类型空间你想在值空间里使用它TS 会直接报错。3.2 typeof 的典型用法对象、函数、类的类型提取有些声明只有值空间的身份比如一个普通的对象常量。你想把它在类型空间里对应的类型抽出来就需要typeof运算符。注意这里的typeof是 TS 在类型上下文中使用的类型查询操作符跟 JS 里的typeof返回字符串的运行时行为完全不同const user { name: Jack, age: 18, }; type User typeof user; // User 等于 { name: string; age: number }这种写法最大的好处是单一数据源。你不需要为了让一个对象有类型再去手动写一个 interface 和它长得一模一样。对象变了类型自动跟着变。对函数也很常用配合ReturnType可以拿到函数的返回值类型function createUser() { return { name: Jack, age: 18 }; } type User ReturnTypetypeof createUser; // User 等于 { name: string; age: number }对类使用typeof时有个容易栽的坑typeof 类名拿到的是构造函数类型而不是实例类型。想要实例类型得包一层InstanceTypeclass Animal { name: string cat; } type Cls typeof Animal; // 构造函数类型 type Instance InstanceTypetypeof Animal; // 实例类型3.3 keyof typeof让联合类型自动和常量表联动typeof最常见的高光时刻是配合keyof使用。我们在业务里经常有一张路由表、事件名表或者权限表用普通对象存着。如果手工维护一份对应的联合类型改值的时候很容易忘了改类型两边就会悄悄脱节。用keyof typeof可以从常量表里自动长出类型const handlers { home: () welcome, dashboard: () overview, settings: () config, }; type HandlerKey keyof typeof handlers; // 自动等于 home | dashboard | settings你往 handlers 里加一个键HandlerKey 会自动扩大删一个键它也会自动缩小。类型逻辑和实际运行数据永远是同一份改了值忘记改类型这类问题直接不存在了。3.4 两个空间错用现场与解法两个空间的概念虽然简单但实际报错出现的时候经常让人一头雾水。我之前看到过类似这样的代码interface EventMap { click: () void; keydown: () void; } type Keys keyof typeof EventMap; // 报错报错的大意是EventMap 只存在于类型层面不能当作值使用。原因很简单interface 根本不在值空间里typeof后面必须跟一个值表达式而 interface 不是值。解决办法是把接口声明改成真实的常量对象const EventMap { click: () void 0, keydown: () void 0, } as const; type EventKey keyof typeof EventMap;我第一次碰到这个报错时确实愣了半天因为明明EventMap 这个名字就在那怎么就不能用了。理解了值空间和类型空间的划分之后这类报错基本扫一眼就能定位。记住一句话typeof是值通往类型的单向桥不是所有名字都能从桥那头举过来。4. enum、const对象与可辨识联合用值约束业务边界4.1 enum 是值类型双栖的异类enum在 TS 里是个特殊的存在它的成员既是真实存在的值又能当类型用。这也是它和普通对象最大的区别。enum Color { Red, // 0 Green, // 1 Blue, // 2 }数字枚举默认会为成员分配从 0 递增的序号同时生成反向映射不但Color.Red 0还能通过Color[0]拿回Red。字符串枚举在 TS 2.4 以后也支持换成字符串值enum HttpStatus { OK ok, NotFound not_found, }字符串枚举不会生成反向映射运行时就是一个普通的对象。enum的类型用法也很直观const c: Color Color.Red字符串枚举只能接收枚举成员直接写ok是不行的类型上要求必须是HttpStatus.OK。4.2 工程上我更常选 const 对象 as const而不是 enum聊到工程实践我得说实话在我参与过的大部分 Vue/React 项目里我更倾向于用const 对象 as const来替代enum除非有特别强的运行时需求。理由有三条。第一条数字枚举的类型安全有缺口。enum E { A 1 }声明之后const x: E 99是可以通过编译的数字枚举允许任意数字赋值这等于类型保护形同虚设。字符串枚举没有这个问题但团队里总有人图省事给枚举写数字值。第二条const enum在某些编译模式下有坑。如果项目开了isolatedModules或者用了单文件编译工具跨文件的const enum可能会产生奇怪的问题因为它需要在编译期被完全展开。第三条从 Tree-shaking 的角度看enum在运行时是对象部分构建工具对它的处理不如普通常量干净。而 const 对象本身就是普通对象按需引入毫无负担。替代方案非常好用const Status { Pending: pending, Success: success, Error: error, } as const; type Status typeof Status[keyof typeof Status]; // 自动得到 pending | success | error写函数入参时直接使用这个类型function updateStatus(status: Status) { // status 只能取三种字符串值 }这个模式把值的实际内容和类型的允许集合绑定成一份数据源。后端返回的字符串天然匹配不用做任何枚举转换代码里也不会出现Status[message]这种基于数字索引的脆弱写法。4.3 可辨识联合一个值决定整个对象形状如果说字面量类型和as const是基础功那可辨识联合discriminated union就是把值类型用在刀刃上的实战技巧。它的核心思路是在一个联合类型里用一个固定字段的值来区分不同的对象形状。type Result | { ok: true; data: string } | { ok: false; error: string }; function handle(result: Result) { if (result.ok) { console.log(result.data); // 此时 result.data 存在且是 string } else { console.log(result.error); // 此时 result.error 存在且是 string } }这里的ok就是判别字段。它的值必须是字面量类型或枚举成员。TS 会根据result.ok的值自动把联合类型收窄到对应的分支。在ok为 true 的分支里你访问result.data完全合法在 false 分支里result.error才存在。如果调换字段编译器马上报错。可辨识联合比一个大类里塞满可选字段要安全得多。很多新手写业务对象喜欢把所有可能出现的字段全铺平成可选属性结果每个字段要用都得先判空代码里全是if (x y z)。可辨识联合直接把不同状态建模成不同形状每段代码只看到自己关心的字段类型系统还能掐着脖子保证你穷尽所有可能分支。4.4 用 never 做穷尽检查让编译器逼你补分支可辨识联合配合switch和never检查是我最喜欢的组合。假设上面那个 Result 后来又增加了一个{ ok: retry; delay: number }分支忘了更新处理函数编译器会立刻投诉你的代码没有覆盖所有情况。实现穷尽检查的经典方法是准备一个接收never的辅助函数function assertNever(x: never): never { throw new Error(未能处理的分支${x}); } function handle(result: Result) { switch (result.ok) { case true: return result.data; case false: return result.error; default: return assertNever(result.ok); } }当 Result 增加新分支后result.ok的类型会变成retryassertNever(result.ok)就接收不了一个非never类型的值编译直接红牌。这种靠编译器逼你改代码的体验是值类型最爽的应用场景——以后再也不用担心改了数据结构忘了改调用方了。5. 值类型在实战中的几个隐蔽行为5.1 string 不是 String包装对象与原始值类型聊了这么多类型的值千万别漏掉一个最基本的坑原始值类型和它们的包装对象类型是两码事。JS 里字符串有原始值abc也有包装对象new String(abc)。TS 类型系统对此同样严格区分。虽然let s: string new String(abc)这种代码在结构化类型下可能蒙混过关但运行时它的typeof结果是object用比较两个内容相同的 String 对象结果也是 false——因为它们是不同的引用。工程上我的建议非常直接不要在生产代码里使用new String()、new Number()、new Boolean()。如果遇到老代码残留看到typeof x object x instanceof String这类判断先把它替换成正常的原始值处理。TS 的类型标注永远写小写的string、number、boolean别因为一时手滑写了大写后面会惹出一堆莫名其妙的类型问题。5.2 null 和 undefined两个特殊的值null 和 undefined 是值类型里最特殊的存在。在开启strictNullChecks的项目里它们不再自动兼容所有类型——null只能赋值给null或包含null的联合类型undefined同理。这个特性让类型检查真正变得有意义了。实际写代码时我习惯把它们的语义区分清楚undefined表示还没定义通常来自未初始化的变量或可选属性null表示有意置空通常来自后端返回的 JSON 或显式清空的操作。一个对象属性如果用可选标记a?: string它的类型是string | undefined不是string | null。如果需要和接口数据对齐就要明确写出a: string | null别让 null 和 undefined 混成两锅粥。处理这两个值的时候??和?.是日常最顺手的工具function format(input: string | null | undefined) { return input ?? empty; }??只在左侧是 null 或 undefined 时取右侧默认值这个语义正好覆盖了大多数防御性需求。新手常犯的错误是用||代替??结果空字符串和 0 也被当成空值吞掉了。5.3 窄化时值类型的判断顺序类型窄化type narrowing是值类型在实际流程里发挥作用的关键。TS 需要根据运行时的值判断把一个宽类型收窄到更具体的类型。窄化手段的选择有讲究。typeof适合区分原始类型但它有一个老掉牙的坑typeof null object。所以判断 null 必须用严格相等不能依赖typeof。instanceof适合区分自定义类和包装对象但在跨 iframe 或者跨 JS 上下文时因为每个上下文有自己的一套构造函数instanceof可能失效。在对象形状的区分上我更推荐用可辨识联合或者在属性存在性判断上使用in操作符type A { type: a; value: string }; type B { type: b; list: number[] }; function handle(x: A | B) { if (x.type a) { return x.value; } return x.list; }这种写法比value in x更直观也比instanceof更稳因为纯对象没有跨上下文问题。把判别值设计成字符串、数字或布尔字面量TS 在编译期就能精准追踪哪个分支里应该出现哪个字段整个处理函数写下来基本一路绿灯。写代码这几年我越来越觉得 TS 里值才是第一公民。你不需要费力去创造一个抽象的类型殿堂只要你把真实的数据结构写清楚在关键位置加上as const、typeof、keyof这些桥接工具类型系统会自己从值的世界里生长出来反过来约束你的每一处调用。我最后再分享一个自己坚持了很久的小习惯业务里的状态、事件名、配置键一律用const 对象 as const定义需要用联合类型时用typeof提取遇到分支判断就上可辨识联合。这样做之后interface 的数量反而变少了因为很多类型根本不需要手写——它们从值里自然诞生也永远不会和真实数据脱节。