知

模块三 · 样式与类型

第 10 / 14 节19 分钟阅读型

TypeScript:给 JavaScript 加一层可执行的契约

类型不是注释,而是能在编译期拦下错误的设计工具。这一节讲清类型收窄、泛型、工具类型与 interface/type 的取舍,并给出迁移老代码的顺序。

读完这一节,你能做到

  1. 用类型把函数的输入输出变成契约,而不是堆 any。
  2. 用判别联合与类型守卫写出"编译器能检查"的分支。
  3. 用泛型与工具类型复用类型,而不是复制粘贴。
  4. 给一个老 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 自动推断

三条经验:

  1. 能推断就不写:局部变量、函数返回值通常不需要标注。
  2. 函数参数必须写:TS 无法从调用处推断参数类型。
  3. 公共 API 显式标注返回类型:避免实现改动导致类型意外变化。

三、interface 与 type

维度interfacetype
扩展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);
  }
}

四种收窄方式:

方式例子注意
typeoftypeof x === "string"只对基础类型有效
in"r" in s适合区分对象字段
instanceofe 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;
}

这也是给第三方库补类型的标准方式。

九、老项目迁移顺序

  1. allowJs: true,先让 TS 与 JS 共存。
  2. 从工具函数与 API 层开始加类型(收益最高、依赖最少)。
  3. 打开 strictNullChecks,处理 null/undefined。
  4. 打开 noImplicitAny,逐个消灭隐式 any。
  5. 最后开 strict,并加 CI 检查。

顺序反了会得到几千条报错,没人愿意修。

十、常见坑

  1. 用 any 消错,结果 any 沿调用链扩散。
  2. 把接口返回断言成 User,运行时字段缺失直接崩。
  3. interface 与 type 混用却没有判断标准,团队风格分裂。
  4. 泛型加在不需要关联的地方,调用方被迫写 <string>。
  5. 类型守卫只判断一个字段,就断言成完整类型。
  6. as const 与普通对象混用,字面量类型意外丢失。
  7. 以为 TS 会校验运行时数据,跳过接口校验。

十一、动手任务:自写 → AI 写 → 对比 → 取舍

四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。

① 自己写

  1. 给一个 JS 工具函数加类型,要求不写 any,并处理 null 输入。
  2. 用判别联合重写一个 status 分支,补上 assertNever 穷尽检查。
  3. 手写 MyOmit 与 Unwrap,并解释 infer 的作用。
  4. 挑一个 200 行的 JS 文件,按迁移顺序改成 TS,记录每一步的报错数量。

② 让 AI 写同一题

把第 2 题(类型建模)交给 AI,补一句 不许用 any,未知类型用 unknown 并显式收窄。

③ 对比要盯的点

  • 它用 any 还是 unknown?any 等于关掉检查。
  • 泛型有没有约束?没约束的 T 拿不到任何属性,只能 any 兜底。
  • type 与 interface 的说法它是不是还停在“前者不能继承”?现在已经过时。

④ 一句话取舍

把它标 any 的地方换成 unknown,哪一处会立刻报错?

十二、自测

  1. interface 与 type 各适合什么场景?
  2. unknown 与 any 的关键区别是什么?
  3. 判别联合为什么比 typeof 更稳?
  4. infer 用在哪里?
  5. TS 能替代运行时校验吗?

参考答案要点:对象形状 vs 联合与工具类型;unknown 必须收窄;靠业务字段,可穷尽检查;条件类型的 extends 子句;不能,接口数据仍需校验。

附:类型写法速查

下面这些写法覆盖了日常 80% 的类型需求,可以直接当查询表用:

需求写法说明
对象所有字段可选Partial<T>常见于更新接口的入参
必填且不可空Required<T>去掉 ? 与 undefined
只读Readonly<T>只限制属性不可重赋值
从联合中排除Exclude<T, U>如排除 "error"
只保留联合中的部分Extract<T, U>与 Exclude 相反
去掉 nullNonNullable<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。这一招在定义状态机、接口枚举、下拉选项时都用得上。

配套题目

★ 越多越该先背;点题目可看完整答案与深入版。

提示:读完可以顺手做几道对应题目,检验是否真的理解