模块三 · 样式与类型
TypeScript:给 JavaScript 加一层可执行的契约
类型不是注释,而是能在编译期拦下错误的设计工具。这一节讲清类型收窄、泛型、工具类型与 interface/type 的取舍,并给出迁移老代码的顺序。
读完这一节,你能做到
- 用类型把函数的输入输出变成契约,而不是堆
any。 - 用判别联合与类型守卫写出"编译器能检查"的分支。
- 用泛型与工具类型复用类型,而不是复制粘贴。
- 给一个老 JS 文件制定渐进迁移顺序。
一、类型解决的是什么问题
function total(items: { price: number; qty: number }[]) {
return items.reduce((sum, i) => sum + i.price * i.qty, 0);
}
这段类型声明做了三件事:
- 调用方传错字段,编辑器立刻报错,不用等运行时。
- 函数体内能安全补全,不需要猜字段名。
- 改名时 IDE 能全量重命名,漏改会报错。
类型检查发生在编译期,运行时不存在。它不会校验接口返回的数据,也不会阻止用户输入非法值——那些是运行时校验(zod、后端校验)的职责。
二、从推断开始,少写标注
const count = 0; // 0(字面量类型)
let total = 0; // number
const user = { id: "1", admin: false }; // { id: string; admin: boolean }
function add(a: number, b: number) {
return a + b;
} // 返回 number 自动推断
三条经验:
- 能推断就不写:局部变量、函数返回值通常不需要标注。
- 函数参数必须写:TS 无法从调用处推断参数类型。
- 公共 API 显式标注返回类型:避免实现改动导致类型意外变化。
三、interface 与 type
| 维度 | interface | type |
|---|---|---|
| 扩展 | extends、声明合并 | 交叉类型 & |
| 联合类型 | 不能 | 能 |
| 映射/条件类型 | 不能 | 能 |
| 性能 | 略优(可缓存) | 复杂类型可能更慢 |
| 适用 | 对象结构、对外 API | 联合、工具类型、函数类型 |
判断顺序:
- 描述"一个对象的形状",且可能被扩展或声明合并 →
interface。 - 需要联合、条件、映射类型 →
type。 - 团队里统一一种也可以,但要知道另一边的能力边界。
interface User {
id: string;
name: string;
}
interface Admin extends User {
permissions: string[];
}
type Status = "idle" | "loading" | "success" | "error";
type Nullable<T> = T | null;
四、any、unknown、never
| 类型 | 含义 | 能否赋值给别的类型 | 适用 |
|---|---|---|---|
any | 关闭检查 | 任意 | 迁移期临时用,尽快消除 |
unknown | 未知但安全 | 需要收窄后才能用 | 外部输入(JSON、catch) |
never | 不可能的值 | 可以赋给任意类型 | 穷尽检查、抛错函数 |
try {
// ...
} catch (e) {
const msg = e instanceof Error ? e.message : String(e); // catch 变量是 unknown
}
function assertNever(x: never): never {
throw new Error(`未处理的分支: ${JSON.stringify(x)}`);
}
any 的危害是传染:一个 any 传进函数,返回值、属性访问全部变成 any,类型检查在这条链路上彻底失效。宁可写 unknown 再多收窄一步。
五、类型收窄:让编译器跟着你的 if 走
type Shape =
| { kind: "circle"; r: number }
| { kind: "rect"; w: number; h: number };
function area(s: Shape) {
switch (s.kind) {
case "circle":
return Math.PI * s.r ** 2;
case "rect":
return s.w * s.h;
default:
return assertNever(s);
}
}
四种收窄方式:
| 方式 | 例子 | 注意 |
|---|---|---|
typeof | typeof x === "string" | 只对基础类型有效 |
in | "r" in s | 适合区分对象字段 |
instanceof | e instanceof Error | 跨 iframe 时失效 |
| 判别字段 | s.kind === "circle" | 最推荐,稳定且可穷尽 |
自定义类型守卫:
function isUser(v: unknown): v is User {
return typeof v === "object" && v !== null && "id" in v && "name" in v;
}
v is User 是"类型谓词",它让调用方在 if (isUser(x)) 之后获得 User 类型。守卫函数内部必须自己保证判断充分,TS 不会校验你的逻辑。
六、泛型:把类型当参数
function first<T>(list: T[]): T | undefined {
return list[0];
}
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
return keys.reduce((acc, k) => ({ ...acc, [k]: obj[k] }), {} as Pick<T, K>);
}
三个约束:
K extends keyof T保证传入的键真实存在。- 泛型默认值
T = string适合有常见场景的参数。 - 不要在不需要关联的地方加泛型,
<T>用不上就是噪音。
七、工具类型与 infer
| 工具类型 | 作用 |
|---|---|
Partial<T> | 全部变可选 |
Required<T> | 全部变必填 |
Readonly<T> | 全部只读 |
Pick<T, K> / Omit<T, K> | 取子集 / 去掉子集 |
Record<K, V> | 构造键值映射 |
ReturnType<F> | 取函数返回值类型 |
Parameters<F> | 取函数参数元组 |
type ApiResponse<T> = { code: number; data: T; message: string };
type UserResp = ApiResponse<User>;
// 条件类型 + infer:提取 Promise 内部类型
type Unwrap<T> = T extends Promise<infer U> ? U : T;
type R = Unwrap<Promise<string>>; // string
// 手写一个 Pick
type MyPick<T, K extends keyof T> = { [P in K]: T[P] };
infer 只能出现在条件类型的 extends 子句里,用来"占位并推断"一个类型。它是 ReturnType 这类工具类型的实现基础。
八、函数重载与声明文件
function parse(v: string): number;
function parse(v: number): number;
function parse(v: string | number): number {
return typeof v === "string" ? Number(v) : v;
}
重载适合"同一函数、不同输入对应不同输出"的场景。实现签名对外不可见,调用方只看到重载签名。
声明文件 .d.ts 用来描述没有类型的 JS 模块:
declare module "legacy-chart" {
export function render(el: HTMLElement, data: number[]): void;
}
这也是给第三方库补类型的标准方式。
九、老项目迁移顺序
allowJs: true,先让 TS 与 JS 共存。- 从工具函数与 API 层开始加类型(收益最高、依赖最少)。
- 打开
strictNullChecks,处理null/undefined。 - 打开
noImplicitAny,逐个消灭隐式any。 - 最后开
strict,并加 CI 检查。
顺序反了会得到几千条报错,没人愿意修。
十、常见坑
- 用
any消错,结果any沿调用链扩散。 - 把接口返回断言成
User,运行时字段缺失直接崩。 interface与type混用却没有判断标准,团队风格分裂。- 泛型加在不需要关联的地方,调用方被迫写
<string>。 - 类型守卫只判断一个字段,就断言成完整类型。
as const与普通对象混用,字面量类型意外丢失。- 以为 TS 会校验运行时数据,跳过接口校验。
十一、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 给一个 JS 工具函数加类型,要求不写
any,并处理null输入。 - 用判别联合重写一个
status分支,补上assertNever穷尽检查。 - 手写
MyOmit与Unwrap,并解释infer的作用。 - 挑一个 200 行的 JS 文件,按迁移顺序改成 TS,记录每一步的报错数量。
② 让 AI 写同一题
把第 2 题(类型建模)交给 AI,补一句 不许用 any,未知类型用 unknown 并显式收窄。
③ 对比要盯的点
- 它用
any还是unknown?any等于关掉检查。 - 泛型有没有约束?没约束的
T拿不到任何属性,只能any兜底。 type与interface的说法它是不是还停在“前者不能继承”?现在已经过时。
④ 一句话取舍
把它标 any 的地方换成 unknown,哪一处会立刻报错?
十二、自测
interface与type各适合什么场景?unknown与any的关键区别是什么?- 判别联合为什么比
typeof更稳? infer用在哪里?- TS 能替代运行时校验吗?
参考答案要点:对象形状 vs 联合与工具类型;unknown 必须收窄;靠业务字段,可穷尽检查;条件类型的 extends 子句;不能,接口数据仍需校验。
附:类型写法速查
下面这些写法覆盖了日常 80% 的类型需求,可以直接当查询表用:
| 需求 | 写法 | 说明 |
|---|---|---|
| 对象所有字段可选 | Partial<T> | 常见于更新接口的入参 |
| 必填且不可空 | Required<T> | 去掉 ? 与 undefined |
| 只读 | Readonly<T> | 只限制属性不可重赋值 |
| 从联合中排除 | Exclude<T, U> | 如排除 "error" |
| 只保留联合中的部分 | Extract<T, U> | 与 Exclude 相反 |
| 去掉 null | NonNullable<T> | 等价于 T & {} 思路 |
| 函数返回类型 | ReturnType<typeof fn> | 不需要手写 |
| 类实例类型 | InstanceType<typeof C> | 组件 ref 常用 |
| 取数组元素类型 | T[number] | 如 string[] → string |
| 取对象值的联合 | T[keyof T] | 如枚举值集合 |
| 字符串转联合 | `${T}` | 模板字面量类型 |
| 断言不为空 | x! 或 assertNotNull(x) | 优先用函数断言 |
其中“优先用函数断言”值得单独说:! 只是消掉编译器的警告,运行时如果真为 null 依然会崩。写一个 assertNotNull 在入口处做真实校验,才能把错误拦在边界:
function assertNotNull<T>(
v: T | null | undefined,
msg: string,
): asserts v is T {
if (v == null) throw new Error(msg);
}
另一个常见需求是“从常量数组推导联合类型”,避免两处维护:
const STATUS = ["idle", "loading", "success", "error"] as const;
type Status = (typeof STATUS)[number]; // "idle" | "loading" | ...
as const 是关键:没有它,数组会被推断为 string[],联合类型就退化成 string。这一招在定义状态机、接口枚举、下拉选项时都用得上。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
提示:读完可以顺手做几道对应题目,检验是否真的理解