学 TypeScript 的人常有个共同的卡点:单个语法都会用,但组合起来就懵——为什么联合类型不能随便访问属性?never 到底是什么鬼东西?其实这些都能用一个统一的视角串起来:类型就是值的集合。 接受了这个前提,类型系统里大半的规则都会变得可以推导,而不用死记。
类型即集合↗
先把这个前提说透。当你写 let x: number,你其实是在说”x 的取值,属于所有数字组成的那个集合”。类型标注做的事,就是约束一个值可以落在哪个集合里。
顺着这个视角,几个看似抽象的类型立刻有了直观含义:
| 类型 | 作为集合是什么 |
|---|---|
never | 空集 ∅——不包含任何值 |
string | 所有字符串 |
'hello'(字面量类型) | 只含一个元素的集合 {'hello'} |
unknown | 全集——包含所有值 |
boolean | 就是 true | false 两个元素的集合 |
never 是空集这一点尤其有用:一个函数如果返回 never,意味着它永远不会正常返回(要么抛错,要么死循环)——因为没有任何值属于空集。这不是特例,而是集合视角的自然结论。
联合与交叉:并集与交集↗
有了集合视角,两个最基础的组合操作就一目了然:
type A = 'red' | 'green'; // 并集:{red, green}
type B = 'green' | 'blue'; // 并集:{green, blue}
type U = A | B; // 并集的并集:{red, green, blue}
type I = A & B; // 交集:{green}
- 联合类型
|是并集:值可以是其中任意一个类型。 - 交叉类型
&是交集:值必须同时满足所有类型。
对象类型上,交叉最常见的用法是”合并字段”,这也符合集合直觉——同时满足两组约束,字段自然是两者相加:
type WithId = { id: string };
type WithName = { name: string };
// 同时具备 id 和 name,才属于这个交集
type User = WithId & WithName; // { id: string; name: string }
这里也藏着一个经典困惑:为什么 string & number 是 never?因为”既是字符串又是数字”的值根本不存在——两个集合没有交集,交集自然是空集。
为什么联合类型不能随便访问属性↗
这是初学者最常撞的墙:
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; side: number };
function area(s: Shape) {
return s.radius; // ❌ 报错:square 上没有 radius
}
用集合视角一想就通了:s 属于”圆或方”的并集,而 TypeScript 只允许你访问并集里每个成员都保证有的属性。radius 只有圆有,方没有,所以不安全。并集里所有成员共有的属性,才是能安全访问的——这里只有 kind。
那怎么安全地拿到 radius?答案是先把集合缩小。
类型窄化:把大集合缩成小集合↗
窄化(Narrowing)是类型系统里最实用的操作:通过运行时的检查,把一个大集合缩小到它的某个子集,从而解锁子集独有的属性。
function area(s: Shape) {
if (s.kind === 'circle') {
// 这个分支里,s 被窄化成只剩 { kind: 'circle'; radius: number }
return Math.PI * s.radius ** 2; // ✅ 现在能访问 radius 了
}
return s.side ** 2; // ✅ 走到这里,s 只可能是 square
}
TypeScript 能识别多种窄化手段,本质都是”用一个条件把集合切开”:
typeof x === 'string'—— 按基础类型切。'radius' in s—— 按属性是否存在切。s.kind === 'circle'—— 按可辨识联合的公共字段切(最推荐)。x instanceof Error—— 按类切。
可辨识联合(Discriminated Union)之所以是建模利器,就是因为它给每个成员一个公共的”标签字段”(这里的 kind),让窄化变得又简单又可靠。
never 的实战:穷尽性检查↗
理解了”never 是空集”,就能用它写出一个非常漂亮的模式——穷尽性检查(Exhaustiveness Check)。它能让编译器帮你保证 switch 覆盖了所有情况:
function area(s: Shape): number {
switch (s.kind) {
case 'circle': return Math.PI * s.radius ** 2;
case 'square': return s.side ** 2;
default:
// 如果上面覆盖全了,走到这里时 s 的类型被窄化为 never(空集)
const _exhaustive: never = s;
return _exhaustive;
}
}
它的妙处在于未来的可维护性:哪天你给 Shape 加了一个 { kind: 'triangle'; ... },却忘了在 switch 里处理,那么 default 分支里 s 就不再是 never(还剩三角形没被排除),const _exhaustive: never = s 立刻编译报错,提醒你漏了分支。类型系统在这里替你做了一次”你是不是忘了什么”的检查。
一点延伸:子集与赋值兼容↗
集合视角还能解释”什么能赋值给什么”。规则是:A 能赋给 B,当且仅当 A 是 B 的子集。
let s: 'hello' = 'hello';
let str: string = s; // ✅ {'hello'} 是 string 的子集,小的能塞进大的
let str2: string = 'world';
let s2: 'hello' = str2; // ❌ string 不是 {'hello'} 的子集,大的塞不进小的
这也解释了为什么 never 能赋值给任何类型(空集是任何集合的子集),而 unknown 只能被赋值、几乎不能直接使用(全集太大,做任何操作都不安全,必须先窄化)。
小结↗
把这篇收束成一句话:别把 TypeScript 的类型当成”给变量贴的标签”,把它当成”值所属的集合”。 一旦切换到这个视角:
- 联合是并集、交叉是交集;
- 只能访问并集的公共属性,想访问独有属性就得窄化;
never是空集,可以用来做穷尽性检查;- 赋值兼容就是子集关系。
这些原本零散的规则,会自然地连成一张自洽的网。类型系统不再是需要背诵的语法清单,而是一套可以推理的逻辑。