TypeScript · 类型收窄与判别联合

TS 沿着你的 if/switch 逐块缩小类型: 判别字段一动, 联合只剩一个 — 漏分支交给 never 举报

六种收窄开关 typeof x === 'string' x instanceof Class 'prop' in obj 判别字段: x.kind === 'circle' 字面量比较 / 真值判断 自定义守卫: x is Foo (函数形态) 判别联合: kind 一动全锁定 type Shape = { kind: 'circle'; r: number } | { kind: 'rect'; w: number; h: number }; case 'circle' 分支内: circle r 可用, w/h 不存在 default/else 分支内: rect w/h 可用 穷尽检查: default 里 const _x: never = s 新增 kind: 'tri' 而没写 case → 编译期报错 漏分支从"运行时漏算"变成"构建失败" 收窄会在哪"失效" arr.forEach(x => { if (x !== 'a') return; // x 在这里是 'a' ✓ queue.push(() => send(x)); }); 回调延迟执行: 分析器不知道 x 到那时还是不是 'a' 解法: 进回调前存 const 快照 const v: 'a' = x; 然后 send(v) 同理: await 之后 / 对象属性可变引用 // 自定义守卫: 把"判断"封装成可复用的类型谓词 function isUser(x: unknown): x is User { return typeof x === 'object' && x !== null && 'id' in x; } const data: unknown = JSON.parse(raw); if (isUser(data)) { data.id; } // data 被收窄为 User // unknown 是类型世界的"安全 any": 能接收一切, 使用前必须收窄 // 外部输入(JSON/接口/环境变量)的统一入口类型, 收窄后再进业务

收窄是免费的

  • • typeof/instanceof/in 自动收窄
  • • 不需要 as, 跟着控制流走
  • • 取反分支同样收窄(排除式)
  • • 条件表达式/提前 return 都算

判别联合是骨架

  • • 共享判别字段(kind/type/status)
  • • switch 一个字段, 全部形状就位
  • • 新增变体 + never 检查 = 编译期提醒
  • • 状态机/API 载荷的标准建模

失效点要背

  • • 回调/延时里收窄被遗忘
  • • await 之后可变量回到宽类型
  • • 对象属性修改不被跟踪
  • • 解法: const 快照 / 断言函数封装

💡 一句话理解

收窄就是 TS 的读心术: 你写 if (typeof x === 'string'), 它就在这个分支里把 x 当 string 用, 在 else 里把你排除掉的类型踢出去。不需要 as、不需要注释 — 因为你的判断本身就是证据, TS 只是沿控制流忠实地推理。判别联合则把这件事制度化: 所有变体共享一个判别字段(kind/type/status), switch 它一下, 每个分支的形状自动就位, 多余字段、缺失分支全部现形。

读心术有盲区: 延迟执行的代码(回调、await 之后)里, TS 不敢保证"那时候"变量还是收窄后的样子 — 存个 const 快照即可。而 unknown 是这套体系的安全入口: 什么都进得来, 但用之前必须给出证据(收窄), 这正是外部数据的正确通道。

🧠 必知必会 必考 & 必会

联合类型
值可以是几种类型之一; 使用时必须收窄到具体分支(数组方法的参数会交叉)。
let id: string | number = 1;
id.toUpperCase();      // ✗ number 没有 toUpperCase
if (typeof id === 'string') id.toUpperCase();  // ✓
typeof 收窄
与 JS 运行时同一套 typeof; 记住 typeof null === 'object' 的坑, 先排除 null。
function f(x: string | number) {
  if (typeof x === 'number') x.toFixed();
  else x.toUpperCase();     // else 分支剩 string
}
instanceof 收窄
对 class 实例收窄到该类; 对 interface 无效(运行时不存在)。
class Err4xx extends Error { code = 400 }
if (e instanceof Err4xx) e.code;   // ✓ 收窄到子类
in 收窄
检查属性是否在对象上, 按属性存在性区分联合成员; 可选属性两侧都有时失效。
type A = { url: string }; type B = { text: string };
if ('url' in msg) msg.url;   // ✓ 分支内是 A
判别字段
联合成员共享的字面量类型字段; switch/if 比较它即整体收窄, 是最可读的建模方式。
type Res =
  | { status: 'ok'; data: unknown }
  | { status: 'err'; msg: string };
never 穷尽检查
把剩余联合赋给 never: 有漏分支时编译报错; 新增变体自动"催办"。
switch (s.kind) {
  case 'a': return; case 'b': return;
  default: const _x: never = s;  // 漏 'c' 就编译错
}
类型谓词 is
x is T 让自定义布尔函数携带收窄能力; 函数体内返回值必须与判断一致。
const isStr = (x: unknown): x is string =>
  typeof x === 'string';
if (isStr(v)) v.toUpperCase();  // ✓ 收窄生效
断言函数 asserts
asserts x is T: 不满足直接 throw, 调用点之后视为已收窄 — 适合守卫式封装。
function assertUser(x: unknown): asserts x is User {
  if (!isUser(x)) throw new Error('not user');
}
assertUser(data); data.id;    // 之后全是 User
非空断言 !
一秒拿掉 null|undefined 的"闭嘴徽章", 说错就运行时崩; 与可选链 ?. 是两个方向。
user!.name;            // user 为 null 时运行时炸
user?.name ?? '';       // 安全读取+默认值, 优先这个
控制流边界
回调、await 之后、可变对象属性 — 收窄会"过期"; 用 const 快照或重新判断。
if (user) {
  await save();
  user.name;    // ✗ 严格下收回宽类型
  // 对: const u = user 先存, 后续用 u
}
unknown 收口
外部输入统一 unknown, 收窄/校验后放行; 替代 any 的渐进迁移起点。
const body: unknown = await req.json();
// any 版: body.x 直接用, 炸在运行时
// unknown 版: 必须先 isUser(body) 才许摸属性
readonly 元组判别
as const 生成的字面量元组天然可判别, 常用于路由表/事件名收窄。
const routes = ['home', 'about'] as const;
type Route = typeof routes[number];   // 'home' | 'about'

🏭 生产实战 real world

场景 1 · API 响应 ok/err 判别联合: 错误处理不再靠约定

接口层返回 {code,data} 全靠口头约定, 忘判 code 直接读 data 就是 undefined。判别联合把"必须判断"写进类型:

type ApiRes<T> =
  | { ok: true; data: T }
  | { ok: false; status: number; msg: string };
async function get<T>(url: string): Promise<ApiRes<T>> { /* ... */ }
const r = await get<User>('/me');
if (!r.ok) return redirect(r.status);   // 不判这个, 下一行编译不过
r.data.name;                             // 这里 TS 保证 r.ok 为 true

场景 2 · 订单状态机: switch 穷尽 + never 兜底

新增"已归档"状态后, 老的 if-else 链静默漏算。判别联合 + never 让漏分支编译期报警:

type Status =
  | 'created' | 'paid' | 'shipped' | 'archived';
function canCancel(s: Status) {
  switch (s) {
    case 'created': case 'paid': return true;
    case 'shipped': case 'archived': return false;
    default: { const _x: never = s; return false; }
  }
}
// 以后加状态漏写 case → 编译失败, 不是线上事故

场景 3 · WebSocket 消息分发: type 字段一路收窄

ws 推送 10 种消息, onmessage 里一个 any 闭眼转发。判别联合分发到各自 handler:

type Msg =
  | { type: 'tick'; price: number }
  | { type: 'fill'; orderId: string; qty: number };
ws.onmessage = e => {
  const m = JSON.parse(e.data) as Msg;   // 边界 as (配 zod 更稳)
  switch (m.type) {
    case 'tick': updatePrice(m.price); break;
    case 'fill': settle(m.orderId, m.qty); break;
  }
};

场景 4 · 表单按字段类型分支渲染

动态表单的字段定义是联合, 判别字段 variant 决定渲染哪个控件, 控件 props 精确匹配:

type Field =
  | { variant: 'text'; maxLength: number }
  | { variant: 'select'; options: string[] };
function renderField(f: Field) {
  switch (f.variant) {
    case 'text':   return <Input maxLength={f.maxLength} />;
    case 'select': return <Select options={f.options} />;
  }
}
// text 分支拿不到 options, select 分支拿不到 maxLength

场景 5 · zod 前置校验: 外部数据过闸再收窄

as User 是自欺欺人。zod 在运行时验证, safeParse 结果天然是判别联合:

const UserSchema = z.object({ id: z.number(), name: z.string() });
const r = UserSchema.safeParse(json);
if (!r.success) {
  return badRequest(r.error.issues[0].message);  // err 分支
}
r.data;   // ✓ 成功分支, 类型是 { id: number; name: string }
type User = z.infer<typeof UserSchema>;   // schema 单源出类型

场景 6 · 断言函数消灭重复 throw

20 处"if (!user) throw"散落各处。asserts 谓词收口一处, 调用后自动收窄:

function assertDefined<T>(
  v: T | undefined | null, msg: string
): asserts v is T {
  if (v == null) throw new Error(msg);
}
assertDefined(ctx.user, '需要登录');
ctx.user.id;    // ✓ 已收窄, 无需 if

场景 7 · filter 保型: 类型谓词救回窄类型

[1, undefined, 2].filter(Boolean) 的返回仍是 (number|undefined)[]。谓词版本真正收窄:

const xs: (number | undefined)[] = [1, undefined, 2];
const bad = xs.filter(Boolean);      // 仍是 (number | undefined)[]
const ok  = xs.filter((x): x is number => x != null);
ok;   // → number[]  ✓

场景 8 · 缓存键联合: 按键类型分派存取

缓存值类型跟键走, 判别联合 + 映射让 get 返回值精确:

type CacheMap = { user: User; order: Order };
type CacheKey = keyof CacheMap;
function get<K extends CacheKey>(k: K): CacheMap[K] | undefined {
  return store.get(k) as CacheMap[K] | undefined;
}
const u = get('user');   // → User | undefined 精确

场景 9 · any 接口迁移: unknown + 收窄渐进改造

老接口函数全 any, 一次改不动。先把 any 换成 unknown(更诚实), 再逐调用点收窄:

// 第 1 步: (o: any) => (o: unknown) — 调用方开始报错, 列出所有触点
// 第 2 步: 每个触点补收窄
function total(o: unknown) {
  if (isOrder(o)) return o.items.reduce((s, i) => s + i.price, 0);
  throw new TypeError('not an order');
}
// any 是"关灯", unknown 是"装了门禁" — 迁移方向永远是后者

场景 10 · 收窄过期修复: const 快照进回调

事件回调里读了被收窄的可变量, 严格模式报错。进回调前先定格:

function onChange(s: State) {
  if (s.stage !== 'editing') return;
  const draft = s;                     // 快照: editing 态的引用
  return () => commit(draft.draftDoc);  // 回调里用快照
}

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 回调里收窄失效 — 延迟执行时 TS 不保证变量仍窄. 正解: 进回调前 const 快照。
// 错: if (x !== 'a') return; cb.push(() => f(x));  // x 回宽
// 对: const v = x; cb.push(() => f(v));
坑 2 · typeof null === 'object' — 收窄分支混入 null. 正解: 先排除 null 再 typeof。
// 错: if (typeof v === 'object') v.x;  // null 混进来了
// 对: if (v !== null && typeof v === 'object') v.x;
坑 3 · instanceof 判 interface — 接口运行时不存在, 永远 false. 正解: 用 in 检查特征属性或判别字段。
// 错: e instanceof UserIface   // 编译过但恒 false
// 对: 'userId' in e  或 isUser(e)
坑 4 · 判别字段可选 — kind?: 'a' | 'b' 缺值时收窄不出分支. 正解: 判别字段必填且用字面量类型。
// 错: { kind?: 'a' } | { kind?: 'b' }
// 对: { kind: 'a' } | { kind: 'b' }
坑 5 · filter(Boolean) 不收窄 — 返回类型保持原联合. 正解: 类型谓词 (x): x is T =>。
xs.filter(Boolean);                   // 宽类型
xs.filter((x): x is T => x != null);  // ✓ 窄
坑 6 · as 滥用 — 把"证据"换成"我说是就是". 正解: 能收窄绝不 as; as 只留给边界且注释理由。
// 错: const u = json as User;   // json 可能是任何东西
// 对: const u = UserSchema.parse(json);
坑 7 · 双重断言连环 — as unknown as T 绕过一切检查, 类型系统裸奔. 正解: 出现即代码坏味道, 重构数据流。
x as unknown as Payment;   // 检查全灭, 与 any 无异
坑 8 · 非空断言 ! 当日常 — 说"绝不为空"但运行时空了就崩. 正解: ?. + ?? 或先判空。
config!.db!.host!;    // 三连 ! = 三颗雷
config?.db?.host ?? '';
坑 9 · Object.keys 不收窄 — 返回 string[], 键联合丢了. 正解: 自写 keys 守卫或 Object.entries + as。
const ks = Object.keys(o) as (keyof typeof o)[];  // 常用模式
坑 10 · await 后收窄过期 — 异步点之后可变量回到宽类型. 正解: await 前快照或重新判断。
if (u) { await x(); u.id; }   // 严格下报错
const uu = u; if (uu) { await x(); uu.id; }  // ✓
坑 11 · 漏 default 收窄残留 — switch 后变量仍可能是"没处理过的成员". 正解: default 里 never 断言兜底。
// 对: default: { const _x: never = s; break; }
坑 12 · is 谓词写反 — 函数返回 true 但实际不是 T, 收窄后全是雷. 正解: 谓词体内判断必须充分, 测试锁定。
// 错: const isU = (x): x is User => !!x;  // 太宽
// 对: 校验关键结构后再返回 true
坑 13 · in 对可选属性失效 — 两侧都有该属性(哪怕可选)时收窄不动. 正解: 用必填判别字段建模。
// { url?: string } | { url?: number } — 'url' in 两边都过
// 对: 换 kind: 'img' | 'text' 判别
坑 14 · 联合数组 map 参数告警 — (A|B)[].map 回调参数是 A & B 交叉, 常见困惑. 正解: 先按元素收窄再操作。
list.map(m => { if (m.kind === 'a') use(m); });  // 分支内收窄
坑 15 · never 赋值没接住 return — 穷尽检查写完忘了处理返回路径, 函数返回类型掺 undefined. 正解: default 里 throw 或补 return。
default: { const _x: never = s; throw new Error('unreachable'); }
坑 16 · 字符串枚举与字面量混用 — enum 与 'a' | 'b' 混合导致比较恒 false. 正解: 单一来源, 用 as const 对象枚举。
const Kind = { a: 'a', b: 'b' } as const;
type Kind = typeof Kind[keyof typeof Kind];   // 'a' | 'b'
坑 17 · satisfies 后忘收窄 — satisfies 只校验不改类型, 判断逻辑仍要写. 正解: satisfies 保形状, 联合分支照常收窄。
const r = res satisfies ApiRes;
if (r.ok) use(r.data);   // 收窄逻辑不能省
坑 18 · any 混进联合 — A | any = any, 收窄体系整体瓦解. 正解: lint 禁 any, 入口统一 unknown。
let v: A | any = x;   // v 就是 any, 全白写
坑 19 · switch 漏 case 静默 — 没 never 检查时漏分支不报错. 正解: 状态联合 + default never 成对出现。
// 每个 switch over 联合 都该有 never 兜底
坑 20 · 谓词函数过度宽松 — 只查一个字段就 is T, 数据残缺也放行. 正解: 关键字段全查, 或接 zod refine。
// 错: x is User 只查 'id' in x
// 对: id/name/role 全查, 不确定的走 schema.parse