TypeScript 类型系统:把类型当作集合来思考

联合、交叉、窄化、never……这些概念单独记很零碎。但只要接受一个前提——类型就是值的集合——它们会瞬间连成一张网。

学 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 & numbernever?因为”既是字符串又是数字”的值根本不存在——两个集合没有交集,交集自然是空集。

为什么联合类型不能随便访问属性

这是初学者最常撞的墙:

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 是空集,可以用来做穷尽性检查;
  • 赋值兼容就是子集关系。

这些原本零散的规则,会自然地连成一张自洽的网。类型系统不再是需要背诵的语法清单,而是一套可以推理的逻辑。


More like this