要点
类型把”数据长什么样”从注释和人脑搬进编译器。联合写”或”,交叉写”且”,泛型把类型做成参数,收窄让编译器替你跟踪”现在到底是哪一种”。这篇讲每个语法为什么存在——语法本身是最短的部分,选型理由才是要背下来的东西。
一、类型标注与类型推断
类型标注(type annotation)是契约,类型推断(type inference)让契约不必重复抄写。编译器能从右侧推出的类型,左边再写一遍就是噪音;真正必须手写的,是推断没有信息来源的位置——函数参数,以及需要对外固化的导出 API。
let count = 0; // 推断为 number,标注是废话
const names = ["a", "b"]; // 推断为 string[]
// 参数没有推断来源,必须标注——这是标注的主战场
function greet(name: string): string {
return `你好,${name}`;
}
导出函数的返回值建议显式标注:它把你对调用方的承诺写死,实现内部改动一旦影响返回形状,第一时间在定义处报错,而不是散落在几十个调用点。
坑:初始化推断会把变量类型焊死在初始值上。let config = getDefault() 之后想赋一个更宽的值,得显式标注。标注密度不是越高越好——边界手写,内部交给推断。
二、interface 与 type:描述形状的两种写法
90% 的场景两者等价,差异在能力边缘。选哪个看团队约定,但边缘必须认识:
| 能力 | interface | type 别名 |
|---|---|---|
| 对象 / 函数形状 | 可以 | 可以 |
| 继承扩展 | extends,多继承 | 交叉类型 & 模拟 |
| 同名重复声明 | 自动合并(declaration merging) | 报错 |
| 联合、元组、映射、条件类型 | 不行 | 可以 |
declaration merging 双刃剑:库作者靠它扩展全局类型;业务代码里两个同名 interface 静默合并,排查半天才发现字段来自八竿子打不着的文件。描述对象形状且要被别人扩展,用 interface;要表达联合、要组合类型运算,只有 type 能干。
三、联合与交叉
| 是”值属于其中之一”,& 是”必须同时满足”。联合的规矩:收窄之前只能访问公共成员——这不是限制,是逼你在正确的位置处理分支。
function format(id: string | number): string {
// id.toUpperCase() // 报错:number 上没有这个方法
return typeof id === "string" ? id.toUpperCase() : id.toFixed(0);
}
交叉类型的坑:同名属性类型冲突会交叉成 never,造出一个无人能构造的类型:
type A = { kind: "a"; value: string };
type B = { kind: "b"; value: number };
type Both = A & B; // kind: "a" & "b" = never,这就是个死类型
四、泛型
泛型(generics)解决的问题:让输入的类型决定输出的类型,把这条关联保住。参数写 any,关联丢失;每个类型复制一份函数,是体力活。泛型就是把”类型”本身做成参数:
function firstOr<T>(list: T[], fallback: T): T {
// 没有泛型:list 只能是 any[],返回 any,关联消失
return list.length > 0 ? list[0] : fallback;
}
const n = firstOr([1, 2], 0); // n 是 number,关联保住了
约束(constraint)用 extends,给类型参数立法——无约束的 T 上连 .length 都不能访问:
function logSize<T extends { length: number }>(x: T): T {
console.log(x.length); // 有约束才敢访问
return x;
}
反模式:为泛型而泛型。函数体根本用不到类型关联时,泛型只是装饰品——单形态就直写具体类型,等第二形态出现再抽象。
五、类型收窄与判别联合(本篇重点)
联合类型只有配上收窄(narrowing)才有用。编译器沿控制流分析把宽类型削窄,触发器包括 typeof、真值检查、in、instanceof。而最值得练成肌肉记忆的,是判别联合(discriminated union)。
为什么它重要:多个布尔并存的状态(isLoading、hasError、hasData)挡不住”loading 且 error”这种矛盾组合;判别联合给每个成员一个字面量判别字段,switch 它,每个分支内类型自动收窄——非法状态不可表示。
type LoadState =
| { status: "loading" }
| { status: "success"; items: string[] }
| { status: "error"; message: string };
function render(state: LoadState): string {
switch (state.status) {
case "loading": return "加载中";
case "success": return `${state.items.length} 条`; // 此分支 items 必在
case "error": return `失败:${state.message}`;
}
}
穷尽检查(exhaustiveness check):新增成员时漏写分支应该报编译错误,而不是运行时悄悄掉进 undefined:
function assertNever(x: never): never {
throw new Error(`漏了分支: ${JSON.stringify(x)}`);
}
// switch 末尾加 default: return assertNever(state)
// 一旦 LoadState 加了第四个成员而 render 忘了处理,tsc 直接拦下
六、unknown 与 any
any 的本质是双向关闭检查,并且传染:任何经过 any 之手的值都失去保护。unknown 是”我不知道,但你得先证明”——可以随便赋值给它,但不能直接使用,必须先收窄或校验。
所以边界数据——JSON.parse 的返回、process.argv、接口响应——一律 unknown 起步:
const raw: unknown = JSON.parse(text);
// raw.items // 编译报错,好事:没证明过形状就不许用
if (isTodoList(raw)) {
render(raw); // 类型谓词收窄后才放行
}
七、工具类型:类型层面的函数
工具类型(utility types)存在的理由:类型也需要复用。Partial 这类内置泛型就是”接受类型、返回类型”的函数,不必每次手写形状变体:
| 工具类型 | 语义 | 典型用途 |
|---|---|---|
| Partial | 所有属性变可选 | 更新接口的入参:只传要改的 |
| Pick | 挑出指定字段 | 列表视图只要 id 和标题 |
| Record | 键值映射 | id → 实体的索引表 |
| ReturnType | 取函数返回类型 | 复用工厂函数产出的类型 |
interface Todo { id: string; text: string; done: boolean; }
type TodoDraft = Partial<Todo>; // 更新入参
type TodoBrief = Pick<Todo, "id" | "text">; // 列表视图
type TodoMap = Record<Todo["id"], Todo>; // 索引表
八、satisfies:验形状,不丢推断
TS 4.9 引入 satisfies,一次满足三个诉求:想检查形状、不想丢字面量推断、不想像 as 那样骗编译器。
const themes = {
dark: { bg: "#111", fg: "#eee" },
light: { bg: "#eee", fg: "#111" },
} satisfies Record<string, { bg: string; fg: string }>;
themes.dark.bg; // 类型仍是 "#111"(字面量保住了),且拼错键会报错
// 对比:去掉 satisfies——没有任何检查
// 对比:写成 as Record<...>——有检查,但推断全部放宽成 string
最佳实践
- 边界手写标注(函数参数、导出 API),内部交给推断;标注密度过高和过低都是坏味道。
- 互斥状态用判别联合建模,配 assertNever 穷尽检查;两个以上布尔并存时先想想是不是状态机写歪了。
- any 只许出现在迁移期和类型定义文件;边界数据一律 unknown 起步,校验后才使用。
- 状态建模先于函数编写:类型写对了,函数体常常只剩一种写法。
常见问题
什么时候允许 as? 基本两个场景:测试里构造部分对象、双重断言桥接动态加载的模块。能用收窄或校验解决的都不该用 as——as 是”我比编译器懂”,每次写都要能说出为什么。
开了 strict 之后报错暴涨,要不要先关掉? 不要。报错暴涨说明代码里本来就有这么多隐式约定没被写下来,strict 只是让它们现形。正确动作是逐条处理:该收窄的收窄、该校验的校验、确实无害的用显式分支表达意图。关掉 strict 等于把体检报告烧了——病灶还在。
练习与实践
- 写 parsePriority(raw: unknown): “high” | “low”:非法输入返回 “low” 而不是抛异常,想一想 CLI 场景为什么要容错。
- 把 isLoading / hasError / hasData 三布尔的渲染逻辑重构成判别联合,用 assertNever 保证穷尽。
- 给 Todo 写 Partial<Pick> 并解释每一层分别约束什么。
延伸阅读
- 上一篇:第一个 TypeScript 应用——语法落到一个能跑的小程序里。
- 下一篇:TypeScript 基础知识参考字典——本篇每个概念按条目速查,含 tsconfig 关键项与报错速查表。
- 进框架:Next.js 与 TypeScript——这套语法在 React 组件与数据层里的真实用法。
版本说明:本文语法适用于 TypeScript 5.x 及之后版本(satisfies 需 4.9+)。截至写作(2025-02-15),npm latest 为 TypeScript 5.7.x(2024-11 发布);语言语法跨版本稳定,具体行为以官方文档为准。