04

TypeScript 与类型工程

1,254 行约 59 分钟55 个代码块11 张表格更新于 2026-11-03

04 · TypeScript 与类型工程(详解版)

一句话定位:TypeScript 是在 JavaScript 上面加了一层「可被编译器检查的文档」。它不改变运行时,但能让你在改代码时提前知道哪里会坏。

读完本篇你应该能做到

  1. 给任意一个后端接口写出「窄而准」的类型,并知道为什么不能只靠类型做校验;
  2. 看懂并写出常见的泛型工具类型(Pick、Omit、ReturnType 的底层实现);
  3. 判断一段类型代码「值不值得写」——而不是把所有类型体操都堆上去。

0. 领域导论

0.1 这个领域解决什么问题

没有类型时,典型的日常:

// 三个月后你接手这段代码
function calc(a, b) {
  return a + b; // a、b 是什么?数字?字符串?对象?
}

calc('1', 2); // '12',不报错,但结果不是你要的
calc({}, []); // '[object Object]',同样不报错

问题不在「你不够小心」,而在运行时才知道错。TypeScript 把这些错误提前到编辑器里:

function calc(a: number, b: number): number {
  return a + b;
}

calc('1', 2); // ❌ 编译报错:Argument of type 'string' is not assignable to 'number'

三个真实收益:

  1. 重构敢动手:改一个字段名,编译器会列出所有需要改的地方。
  2. 接口契约可见:打开文件就知道数据长什么样,不用猜。
  3. 编辑器变聪明:自动补全、跳转定义、查找引用,本质都依赖类型信息。

0.2 一个类比

把 TS 想成建筑施工图,JS 是实际盖出来的房子:

  • 图纸(类型)能提前发现「这面墙承重不够」,但房子盖好后图纸就收起来了(编译后类型被擦除)。
  • 所以图纸不能代替验收(运行时校验)——材料以次充好(接口返回脏数据)时,图纸检查不出来。
  • 图纸画得太复杂(类型体操),施工队(同事和 tsc)都会累。

0.3 学习地图

04 TypeScript
  ├─ 前置:03 JavaScript(必须熟练)
  ├─ 紧接着:06 框架(组件 props 类型)、09 工程化(编译与产物)
  ├─ 交叉:13 测试(类型测试)、07 状态(数据契约)
  └─ 长期:17 架构(公共 API 设计)

建议顺序:1(基础类型与收窄)→ 2(泛型 + 工具类型)→ 用真实接口练手 → 3(类型性能与契约)→ 4(编译器与迁移)。

0.4 分级标尺

级别能力特征典型产出自检标准
L1 基础会标注基础类型,能读懂报错业务组件与函数的类型能解释常见报错原因
L2 进阶掌握泛型与工具类型,能为 API 设计类型可复用类型定义、d.ts能对同一个数据写出准确类型
L3 高级能做类型级编程与契约设计,关注类型性能公共库类型、schema 推导能平衡类型强度与可维护性
L4 专家治理跨库类型体系与编译链类型规范、编译器插件、迁移方案能对破坏性变更负责

1. L1 基础:先把「报错」读懂

1.1 基础类型与 any / unknown

let name: string = '张三';
let age: number = 20;
let isActive: boolean = true;
let tags: string[] = ['a', 'b'];
let tuple: [string, number] = ['张三', 20]; // 元组:位置和类型都固定
let nothing: null = null;
let missing: undefined = undefined;

any 与 unknown 的关键区别:

let a: any = getData();
a.foo.bar;          // ✅ 不报错,但运行时可能崩
a();                // ✅ 不报错,但可能不是函数

let u: unknown = getData();
u.foo;              // ❌ 报错:Object is of type 'unknown'
u();                // ❌ 报错
if (typeof u === 'string') {
  u.toUpperCase();  // ✅ 收窄后才能用
}
  • any 是「关掉类型检查」,而且会传染:any 参与运算的结果还是 any,bug 会沿调用链扩散。
  • unknown 是「我不知道,但你必须先检查」。外部数据一律用 unknown(接口响应、JSON.parse、postMessage)。
// 推荐写法:把 unknown 收窄成具体类型
function isUser(value: unknown): value is { id: string; name: string } {
  return (
    typeof value === 'object' &&
    value !== null &&
    'id' in value &&
    'name' in value
  );
}

value is X 是类型谓词,它让 if (isUser(data)) 之后的 data 自动变成 { id: string; name: string }。

1.2 interface 与 type:什么时候用哪个

// interface:描述对象形状,可声明合并、错误信息更友好
interface User {
  id: string;
  name: string;
  age?: number;        // 可选
  readonly createdAt: Date; // 只读
}

// type:描述联合、交叉、条件、映射等「类型运算」
type Status = 'idle' | 'loading' | 'success' | 'error';
type UserWithRole = User & { role: 'admin' | 'user' };
场景选择
对象/类的形状(尤其会被 implements)interface
联合类型、函数类型、工具类型组合type
需要声明合并(扩展第三方类型)interface
需要映射/条件/模板字面量type

团队里常见约定:对外暴露的对象用 interface,内部运算类型用 type。不必教条,但一个项目内要统一。

1.3 联合、交叉与字面量类型

type A = { a: string };
type B = { b: number };

type AB = A & B;        // 交叉:同时具备
const ab: AB = { a: 'x', b: 1 };

type Size = 'sm' | 'md' | 'lg';  // 字面量联合:把「有限取值」表达成类型
function button(size: Size) {}

button('xl'); // ❌ 报错,拼写错误当场发现

字面量联合是最划算的类型投入:它把「魔法字符串」变成编译期检查,成本极低,收益极高。

1.4 类型收窄:让编译器跟着你的 if 走

function format(value: string | number | Date): string {
  if (typeof value === 'string') {
    return value.trim();          // 这里 value: string
  }
  if (typeof value === 'number') {
    return value.toFixed(2);      // 这里 value: number
  }
  return value.toISOString();     // 剩下只能是 Date
}

常用收窄手段:

手段适用
typeof原始类型
instanceof类实例(Date、Error、自定义类)
in判断对象是否有某属性
字面量相等可辨识联合(kind === 'a')
自定义谓词复杂校验(value is User)
Array.isArray数组与对象区分

可辨识联合(前端最常用模式):

type Result =
  | { status: 'success'; data: User }
  | { status: 'error'; message: string }
  | { status: 'loading' };

function render(result: Result) {
  switch (result.status) {
    case 'success':
      return result.data.name;   // ✅ 编译器知道有 data
    case 'error':
      return result.message;     // ✅ 编译器知道有 message
    case 'loading':
      return '加载中';
  }
}

1.5 断言与守卫:as 是把双刃剑

const el = document.getElementById('app') as HTMLDivElement;
el.innerHTML = 'ok'; // 如果元素实际是 null 或别的标签,运行时报错

as 不做任何运行时检查,只是让编译器闭嘴。安全用法只有两种:

  1. 你确实比编译器知道得多(如 getElementById 后已确认存在);
  2. 用在类型守卫函数内部,把 unknown 收窄。

不要用它来「消除报错」——那是把问题推迟到线上。

as const 是另一回事,它把值收窄成字面量并加 readonly:

const config = { mode: 'dark', retries: 3 } as const;
// 类型:{ readonly mode: "dark"; readonly retries: 3 }

配合 typeof 提取:

type Config = typeof config;              // 从值反推类型
type Mode = Config['mode'];               // 'dark'

1.6 tsconfig 关键项(初学者最该懂的 8 个)

{
  "compilerOptions": {
    "target": "ES2022",          // 编译输出的语法版本
    "module": "ESNext",          // 模块格式
    "moduleResolution": "bundler", // 模块解析策略(Vite/webpack 项目常用)
    "strict": true,              // 打开全部严格检查(强烈建议)
    "noUncheckedIndexedAccess": true, // 数组下标访问结果可能是 undefined
    "exactOptionalPropertyTypes": true,
    "skipLibCheck": true,        // 跳过 node_modules 的 d.ts 检查(提速)
    "paths": { "@/*": ["./src/*"] } // 路径别名(需构建工具同步配置)
  }
}

strict 包含:strictNullChecks、noImplicitAny、strictFunctionTypes、strictPropertyInitialization、strictBindCallApply、alwaysStrict、useUnknownInCatchVariables。

最重要的一条是 strictNullChecks:它让 null 和 undefined 成为独立类型,「Cannot read property of undefined」这类错误会在编译期暴露。

1.7 编译流程:类型只是「擦掉」

.ts → [tsc / esbuild / swc] → .js(类型被擦除,无运行时开销)

关键认知:

  • 类型不参与运行,所以不能 if (typeof x === MyType)。
  • tsc 只做类型检查与降级;打包通常交给 esbuild/swc(快 10-100 倍)。工程上常见组合是:构建用 esbuild,类型检查用 tsc --noEmit。
  • 界面里的报错来自 tsserver(语言服务),与构建是两套进程。

自检:interface 编译后还在吗?——不在,完全消失。

1.8 L1 小结与自检

知识点最该记住的一句
any / unknown外部数据用 unknown,any 会传染
interface / type对象用 interface,运算用 type
联合/字面量有限取值用字面量联合,性价比最高
收窄typeof/in/判别字段,让编译器跟着走
as不做运行时检查,别用来消报错
tsconfigstrict: true 是底线
编译类型会被擦除,不能做运行时判断

自检题

  1. any 和 unknown 用起来有什么本质区别?——unknown 必须先收窄。
  2. 为什么说「类型不是安全边界」?——编译后擦除,运行时脏数据照样进来。
  3. as const 与 as 的区别?——前者收窄为字面量并只读,后者只是断言。

2. L2 进阶:泛型与工具类型

2.1 泛型:把「类型」变成参数

不用泛型时,你要为每种类型写一遍:

function firstString(arr: string[]): string | undefined { return arr[0]; }
function firstNumber(arr: number[]): number | undefined { return arr[0]; }

用泛型,一次搞定:

/**
 * 取数组第一个元素。
 * @param arr 输入数组
 * @returns 第一个元素,空数组时为 undefined
 */
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

first([1, 2]);          // number | undefined
first(['a']);           // string | undefined

T 是类型参数,调用时由实参推断出来(不需要手写 first<number>([1,2]))。

约束:T 默认可以是任何类型,需要「至少具备某能力」时用 extends:

function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}

longest('abc', 'de');   // ✅ string 有 length
longest(1, 2);          // ❌ number 没有 length

默认类型参数:

interface ApiResponse<T = unknown> {
  code: number;
  data: T;
  message: string;
}

const res: ApiResponse = { code: 0, data: null, message: 'ok' };

2.2 条件类型:T extends U ? X : Y

type IsString<T> = T extends string ? true : false;

type A = IsString<'hello'>;  // true
type B = IsString<42>;       // false

分布式条件类型(最容易踩的点):当 T 是裸类型参数且传入联合类型时,会对每个成员分别判断再合并:

type ToArray<T> = T extends unknown ? T[] : never;

type R1 = ToArray<string | number>;
// string[] | number[]  ← 分发后合并

// 阻止分发:用元组包裹
type ToArrayNoDistribute<T> = [T] extends [unknown] ? T[] : never;
type R2 = ToArrayNoDistribute<string | number>;
// (string | number)[]

Exclude 就依赖分发:

type Exclude<T, U> = T extends U ? never : T;

type T1 = Exclude<'a' | 'b' | 'c', 'a'>; // 'b' | 'c'
// 'a' → never,'b' → 'b','c' → 'c',合并后 'b' | 'c'

2.3 infer:在类型里「解构」

// 提取函数返回值
type MyReturnType<T> = T extends (...args: never[]) => infer R ? R : never;

type R = MyReturnType<() => string>; // string

// 提取数组元素类型
type ElementOf<T> = T extends (infer E)[] ? E : never;
type E = ElementOf<number[]>; // number

// 提取 Promise 结果(内置 Awaited 的简化版)
type Unwrap<T> = T extends Promise<infer U> ? Unwrap<U> : T;
type U = Unwrap<Promise<Promise<string>>>; // string

// 提取函数第一个参数
type FirstArg<T> = T extends (first: infer F, ...rest: never[]) => unknown ? F : never;

infer 的出现位置决定「推断什么」:infer R 在返回值位置就推断返回值,在参数位置就推断参数。

2.4 映射类型:批量改造对象类型

type MyPartial<T> = { [K in keyof T]?: T[K] };
type MyReadonly<T> = { readonly [K in keyof T]: T[K] };

// 修饰符操作:+? / -? / +readonly / -readonly
type Required<T> = { [K in keyof T]-?: T[K] };
type Mutable<T> = { -readonly [K in keyof T]: T[K] };

键名重映射(as):

// 把所有字段变成 getXxx 方法类型
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type User = { name: string; age: number };
type UserGetters = Getters<User>;
// { getName: () => string; getAge: () => number }

string & K 是为了让 K 满足 Capitalize 要求的 string 约束(keyof T 可能是 string | number | symbol)。

2.5 工具类型:不仅要会用,要知道实现

type MyPick<T, K extends keyof T> = { [P in K]: T[P] };
type MyOmit<T, K extends keyof any> = MyPick<T, Exclude<keyof T, K>>;
type MyRecord<K extends keyof any, V> = { [P in K]: V };
type MyNonNullable<T> = T extends null | undefined ? never : T;
type MyParameters<T> = T extends (...args: infer P) => unknown ? P : never;
type MyConstructorParams<T> = T extends abstract new (...args: infer P) => unknown ? P : never;

实际用法:

interface User {
  id: string;
  name: string;
  password: string;
  createdAt: Date;
}

// 对外返回时去掉敏感字段
type PublicUser = Omit<User, 'password'>;

// 创建时不需要 id 与时间
type CreateUserInput = Omit<User, 'id' | 'createdAt'>;

// 更新时全部可选
type UpdateUserInput = Partial<Pick<User, 'name' | 'password'>>;

// 表单草稿:字段可为空
type Draft<T> = { [K in keyof T]?: T[K] | null };

2.6 模板字面量类型:把字符串约束住

type EventName = 'click' | 'focus' | 'blur';
type Handler = `on${Capitalize<EventName>}`;
// 'onClick' | 'onFocus' | 'onBlur'

const handlers: Record<Handler, () => void> = {
  onClick: () => {},
  onFocus: () => {},
  onBlur: () => {},
};

更实用的场景:从字段名推导事件名、约束 CSS 变量名、约束 i18n key。

type Path<T, Prefix extends string = ''> = {
  [K in keyof T & string]: T[K] extends object
    ? Path<T[K], `${Prefix}${K}.`>
    : `${Prefix}${K}`;
}[keyof T & string];

interface Messages {
  common: { ok: string; cancel: string };
  user: { name: string };
}

type MessageKey = Path<Messages>;
// 'common.ok' | 'common.cancel' | 'user.name'

2.7 satisfies:校验但不丢失推断

const routes = {
  home: '/',
  user: '/user/:id',
} satisfies Record<string, `/${string}`>;

routes.home; // 类型是 '/',不是 string ← 保留了字面量

对比三种写法:

const a: Record<string, string> = { home: '/' };  // 类型被放宽为 string
const b = { home: '/' } as const;                 // 收窄但没有结构校验
const c = { home: '/' } satisfies Record<string, string>; // 既校验又保留字面量

2.8 模块与声明文件

.d.ts 的三种用途:

// 1. 给第三方库补类型(types/lodash.d.ts)
declare module 'legacy-lib' {
  export function run(cmd: string): void;
}

// 2. 声明全局变量(如 CDN 注入的 SDK)
declare global {
  interface Window {
    __APP_CONFIG__: { env: string; apiBase: string };
  }
}

// 3. 声明非代码资源
declare module '*.svg' {
  const content: string;
  export default content;
}

import type 只导入类型,编译后完全消失(避免打包进无谓的运行时依赖):

import type { User } from './types';

2.9 严格模式实践:那些「更烦但更安全」的开关

// noUncheckedIndexedAccess
const arr: string[] = [];
arr[0].toUpperCase(); // ❌ 报错:可能为 undefined
const first = arr[0];
if (first) first.toUpperCase(); // ✅ 必须检查

// exactOptionalPropertyTypes
interface Config {
  timeout?: number;
}
const c: Config = { timeout: undefined }; // ❌ 报错(要区分「没有」与「显式 undefined」)

这两个开关会带来大量改造工作,但能消灭「下标越界」与「可选属性被显式赋 undefined」两类线上问题。新项目建议直接打开。

2.10 L2 小结与自检

主题关键结论
泛型类型参数 + 推断 + extends 约束
条件类型裸类型参数会分发,用 [T] 阻止
infer在类型里解构,位置决定推断对象
映射类型[K in keyof T] + 修饰符 + as 重映射
工具类型至少能手写 Pick/Omit/ReturnType
模板字面量用字符串模式约束命名
satisfies校验结构同时保留字面量
严格开关noUncheckedIndexedAccess 值得开

自检题

  1. Exclude<'a'|'b'|'c', 'a'> 为什么是 'b'|'c'?——分布式条件类型逐个判断,命中变 never,never 在联合中被吸收。
  2. Pick 和 Omit 的实现分别是什么?——见 2.5。
  3. satisfies 解决了什么问题?——有类型校验但不拓宽类型。

3. L3 高级:类型工程的边界

3.1 类型体操的边界:什么值得写

类型体操能做出很酷的东西,但生产代码里要有判断标准:

值得写不值得写
消除调用方的样板(如自动推导表单类型)为了炫技的递归迷宫
让错误在编译期暴露(如路由参数校验)一个类型文件超过 200 行
公共库的 API 契约只在一个组件里用一次的炫技类型
能配 @ts-expect-error 测试的没人能 review 的

判断公式:收益 = 避免的运行时错误 × 复用次数 − 维护成本 − 编译耗时。收益为负就别写。

3.2 协变、逆变与变型标注

interface Animal { name: string }
interface Dog extends Animal { bark(): void }

let animal: Animal = { name: 'a' };
let dog: Dog = { name: 'd', bark() {} };

animal = dog;  // ✅ 协变:Dog 可以赋给 Animal(更具体→更宽泛)
dog = animal;  // ❌ 报错:Animal 缺少 bark

函数参数是逆变(在 strictFunctionTypes 下):

type Handler<T> = (value: T) => void;

let animalHandler: Handler<Animal> = () => {};
let dogHandler: Handler<Dog> = () => {};

animalHandler = dogHandler; // ❌ 不安全:(v: Dog) 无法处理任意 Animal
dogHandler = animalHandler; // ✅ 安全:(v: Animal) 能处理 Dog

TS 4.7+ 的变型标注(对性能和正确性都有帮助):

interface Producer<out T> { get(): T }        // 只产出 → 协变
interface Consumer<in T> { set(v: T): void }  // 只消费 → 逆变
interface Both<in out T> { get(): T; set(v: T): void }

3.3 类型性能:为什么 tsc 越来越慢

三大元凶:

  1. 巨型联合类型展开:keyof 一个 200 字段的接口再做映射,实例化成本极高。
  2. 深层递归:递归超过约 50 层会触发「Type instantiation is excessively deep」。
  3. 分布式条件类型滥用:对联合类型反复分发,组合爆炸。

诊断命令:

# 查看类型检查耗时
npx tsc --noEmit --extendedDiagnostics

# 生成性能跟踪
npx tsc --noEmit --generateTrace ./trace
# 用 chrome://tracing 打开 trace/trace.json

优化手段:

// 1. 用接口代替大型交叉类型(交叉类型每次都要重新计算)
type Bad = A & B & C & D;
interface Good extends A, B, C, D {}

// 2. 缓存中间结果(用类型别名固定)
type Normalized = { ... };
type Step1 = DoSomething<Normalized>;  // 复用 Step1,避免重复实例化

// 3. 避免在热路径类型里做递归
// 4. 用 as const 与 satisfies 替代复杂推断

3.4 声明合并与模块增强

给第三方库「打补丁」:

// 给 axios 的 config 增加自定义字段
declare module 'axios' {
  export interface AxiosRequestConfig {
    /** 是否静默上报错误 */
    silent?: boolean;
  }
}

风险:全局增强会影响整个项目,且与库的版本升级耦合。都要写在统一的 types/ 目录并加注释说明原因,不要散落在业务文件里。

3.5 契约驱动:从 schema 到类型

类型只描述「形状」,运行时校验负责「真的对不对」。两者应该来自同一个源头:

import { z } from 'zod';

const UserSchema = z.object({
  id: z.string(),
  name: z.string().min(1),
  age: z.number().int().nonnegative().optional(),
});

type User = z.infer<typeof UserSchema>;  // 类型自动推导,不用手写

// 运行时校验(接口响应、表单、localStorage 读取)
const result = UserSchema.safeParse(raw);
if (!result.success) {
  console.error(result.error.issues);
  return;
}
const user: User = result.data;

分层原则:

位置做法
系统边界(接口、URL、storage、postMessage、用户输入)必须运行时校验
内部模块之间类型足够
第三方 SDK用 unknown + 校验,不要直接信它的 d.ts

3.6 品牌类型(Branded Types)

防止「都是 string,但含义不同」的 ID 混用:

type Brand<T, B extends string> = T & { readonly __brand: B };

type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;

function toUserId(id: string): UserId { return id as UserId; }

declare function getUser(id: UserId): void;
declare function getOrder(id: OrderId): void;

const uid = toUserId('u1');
getUser(uid);   // ✅
getOrder(uid);  // ❌ 报错:UserId 不能赋给 OrderId

代价:需要显式的构造函数(toUserId),且 as 断言绕不过去。适合金融、订单、多租户等 ID 密集场景。

3.7 类型测试

类型也需要测试,尤其是公共库:

// expect-type
import { expectTypeOf } from 'expect-type';

expectTypeOf<ReturnType<typeof first>>().toEqualTypeOf<string | undefined>();

// tsd
import { expectType } from 'tsd';
expectType<string>(padLeft('x', 3));

// 负向测试:断言这里必须报错
// @ts-expect-error —— 传 number 应报错
first(123);

@ts-expect-error 与 @ts-ignore 的区别:前者在「没有报错」时自己会报错,能防止测试失效。

3.8 公共 API 设计准则

// 1. 输入宽容,输出精确
function request<T>(url: string, options?: RequestOptions): Promise<T>;

// 2. 重载从具体到宽泛
function create(value: string): StringBox;
function create(value: number): NumberBox;
function create(value: unknown): Box;

// 3. 不要暴露内部泛型
// ❌ 调用方被迫写三个泛型
function query<T, K extends keyof T, R>(t: T, k: K, f: (v: T[K]) => R): R;
// ✅ 通过推断隐藏
function query<T, R>(t: T, f: (v: T[keyof T]) => R): R;

// 4. 错误信息要人类可读
type RequireName<T> = T extends { name: string }
  ? T
  : { error: '必须提供 name 字段'; received: T };

3.9 声明文件发布

// package.json
{
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",   // types 条件必须放最前
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}
  • .d.mts / .d.cts 对应 ESM / CJS 类型。
  • isolatedDeclarations(TS 5.5+):要求显式返回类型,让 d.ts 生成可以并行化,大幅提升大型库的构建速度。
  • skipLibCheck 能提速,但会掩盖依赖之间的真实类型冲突。

3.10 L3 小结与自检

  1. 一个泛型工具类型让 tsserver 卡死,怎么定位并改写?——--extendedDiagnostics + 简化联合/递归 + 缓存中间类型。
  2. 如何为「同一接口不同版本」设计类型?——版本化命名空间 + 弃用标记 + 类型测试。
  3. 业务代码要不要写类型体操?——看收益公式,多数场景不需要。

4. L4 专家:编译器与治理

4.1 编译器 API

三层结构:

Program(整个项目的文件图)
  └─ TypeChecker(类型查询、推断、关系判断)
  └─ SourceFile(AST)→ Transformer(转换)

自动化重构示例(把所有 console.log 改成统一 logger):

import ts from 'typescript';

/**
 * 创建一个转换器:把 console.log(...) 替换为 logger.info(...)。
 * @param context 转换上下文
 * @returns 转换函数
 */
function createLoggerTransformer(context: ts.TransformationContext) {
  return (sourceFile: ts.SourceFile) => {
    const visit = (node: ts.Node): ts.Node => {
      if (
        ts.isCallExpression(node) &&
        ts.isPropertyAccessExpression(node.expression) &&
        ts.isIdentifier(node.expression.expression) &&
        node.expression.expression.text === 'console' &&
        node.expression.name.text === 'log'
      ) {
        return ts.factory.updateCallExpression(
          node,
          ts.factory.createPropertyAccessExpression(
            ts.factory.createIdentifier('logger'),
            'info'
          ),
          node.typeArguments,
          node.arguments
        );
      }
      return ts.visitEachChild(node, visit, context);
    };
    return ts.visitNode(sourceFile, visit) as ts.SourceFile;
  };
}

实际项目里更推荐 ts-morph(对编译器 API 的友好封装),几十行就能写出安全的批量重构。

4.2 跨库类型治理

真实痛点:

  • @types/react 版本与 react 不匹配 → 全局报错;
  • 两个库都声明了 Window 上的同一个字段,类型冲突;
  • 一个依赖偷偷把 tsconfig 的 lib 拉高,导致别的包报错。

治理手段:

  1. 锁定版本:overrides / resolutions 统一 @types/* 版本。
  2. 集中管理类型补丁:所有 declare module 放在 types/patches/,有 CODEOWNERS。
  3. CI 加类型门禁:tsc --noEmit 失败即阻断;strict 评分作为技术债指标。
  4. 升级前跑「类型影响面」:用 tsc --generateTrace 或直接对比 d.ts diff。

4.3 构建与类型协同

大型单仓的标准做法:

// 根 tsconfig
{
  "references": [{ "path": "./packages/ui" }, { "path": "./packages/utils" }]
}
  • project references:包之间只依赖声明文件,增量编译只重编改动的包。
  • tsbuildinfo:缓存上次编译结果,二次构建从分钟级降到秒级。
  • isolatedModules:禁止依赖「跨文件类型推断」的写法(如 export { SomeType } 而不加 type),让单文件转译成为可能。
  • verbatimModuleSyntax:强制显式 import type,避免打包器把纯类型当运行时依赖。

分工建议:

开发:tsserver(编辑器内即时反馈)
构建:esbuild/swc(快,只擦除类型)
CI:tsc --noEmit(唯一的类型真相来源)

4.4 破坏性变更管理

类型收紧也是 breaking change。例如把 onChange?: (v: string) => void 改成 onChange: (v: string | number) => void,下游全部报错。

管理流程:

  1. SemVer 严格遵守:类型不兼容 → major。
  2. API 报告:@microsoft/api-extractor 生成 api.md,review 时看 diff。
  3. 废弃流程:@deprecated JSDoc + 新 API 并存 → codemod 迁移 → 下一个 major 删除。
  4. 迁移文档:每个 breaking change 给「之前 / 之后」代码对比。

4.5 组织级迁移:JS → TS

分阶段(不要一次性全改):

阶段动作度量
0allowJs: true + checkJs: false,先让 TS 能编译构建通过
1新代码必须 TS;开 noImplicitAny 但用 // @ts-expect-error 兜底新增文件 TS 覆盖率
2存量按模块开启 checkJs,逐个修类型错误数周下降曲线
3开 strict,把 any 替换为 unknown + 校验any 数量、@ts-expect-error 数量
4治理公共类型、加类型测试与 API 报告类型相关线上事故数

关键经验:

  • 不要追求 100%,先覆盖核心链路(支付、登录、订单);
  • any 要有预算(如每千行不超过 N 个),否则会反弹;
  • 开发者体验是留存关键:编辑器卡顿、报错难懂会让人放弃 TS。

4.6 前沿判断

  • TS 原生编译器(tsgo / Port 7):用 Go 重写,类型检查快 10 倍量级,会改变大型项目的 CI 策略。
  • 类型化 API 的行业趋势:OpenAPI/GraphQL → 类型 → 客户端代码,全链路自动化。
  • effect-ts 类重型类型框架:类型极强但学习曲线陡、编译慢,适合核心领域,不适合全站。
  • 类型与运行时融合:Zod/Valibot 这类「schema 即类型」的方案会继续成为主流。

4.7 L4 自检

  1. 500 人代码库从 strict: false 迁到全严格,分阶段方案与度量?——先开 allowJs + checkJs 并设错误数棘轮,再按目录依次开 noImplicitAny、strictNullChecks、其余开关,每步配 codemod 与 owner;度量看错误总数、tsc 耗时、any 密度与类型相关线上事故数。
  2. 公共组件库如何保证类型变更不悄悄破坏下游?——语义化版本 + api-extractor 生成 API 报告在 CI 做 diff,对下游消费方跑类型测试,破坏性变更走 major + codemod + 弃用期,并收敛导出面禁止深路径导入。
  3. 什么时候该写自定义 lint 插件而不是写规范文档?——规则可机械判定、重复违反、且能在编辑期自动修复时写插件;涉及取舍、边界判断与设计意图的写文档加评审。先用 no-restricted-* 等现有规则,成本更低。

5. 综合实战:为一个接口设计完整类型

需求:订单列表接口 GET /api/orders?status=&page=,返回分页数据,含商品与金额。

步骤 1 · 先写「数据形状」

interface OrderItem {
  skuId: string;
  name: string;
  price: number;      // 单位:分,避免浮点误差
  quantity: number;
}

interface Order {
  id: string;
  status: 'pending' | 'paid' | 'shipped' | 'closed';
  items: OrderItem[];
  totalAmount: number;
  createdAt: string;  // ISO 8601 字符串(跨端传输标准)
}

interface Page<T> {
  list: T[];
  page: number;
  pageSize: number;
  total: number;
}

步骤 2 · 区分「传输类型」与「领域类型」

// DTO:与后端字段完全一致(snake_case 也照抄,不要美化)
interface OrderDTO {
  id: string;
  status: string;
  order_items: Array<{ sku_id: string; price: number; qty: number }>;
  total_amount: number;
  created_at: string;
}

// 转换层:DTO → 领域模型(做校验、默认值、字段映射)
function toOrder(dto: OrderDTO): Order {
  return {
    id: dto.id,
    status: parseStatus(dto.status),
    items: dto.order_items.map((it) => ({
      skuId: it.sku_id,
      name: '',            // 名称可能由另一个接口补齐
      price: it.price,
      quantity: it.qty,
    })),
    totalAmount: dto.total_amount,
    createdAt: dto.created_at,
  };
}

为什么不能直接用 DTO:后端改字段名会波及全站;status 是任意字符串,不能当枚举用;缺少默认值处理。边界处转换一次,内部就干净了。

步骤 3 · 运行时校验(关键)

const OrderDTOSchema = z.object({
  id: z.string(),
  status: z.enum(['pending', 'paid', 'shipped', 'closed']),
  order_items: z.array(
    z.object({
      sku_id: z.string(),
      price: z.number().int().nonnegative(),
      qty: z.number().int().positive(),
    })
  ),
  total_amount: z.number().int().nonnegative(),
  created_at: z.string().datetime(),
});

type OrderDTO = z.infer<typeof OrderDTOSchema>;

步骤 4 · 请求函数与错误模型

type ApiResult<T> =
  | { ok: true; data: T }
  | { ok: false; code: string; message: string };

/**
 * 拉取订单列表,带运行时校验。
 * @param params 查询参数
 * @returns 统一结果对象,永不抛异常
 */
async function fetchOrders(params: {
  status?: Order['status'];
  page: number;
  pageSize?: number;
}): Promise<ApiResult<Page<Order>>> {
  try {
    const res = await fetch(`/api/orders?${new URLSearchParams(params as Record<string, string>)}`);
    if (!res.ok) return { ok: false, code: `HTTP_${res.status}`, message: '请求失败' };

    const json: unknown = await res.json();
    const parsed = PageSchema(OrderDTOSchema).safeParse(json);
    if (!parsed.success) {
      return { ok: false, code: 'SCHEMA_MISMATCH', message: '数据格式异常' };
    }

    return {
      ok: true,
      data: {
        ...parsed.data,
        list: parsed.data.list.map(toOrder),
      },
    };
  } catch (err) {
    return { ok: false, code: 'NETWORK_ERROR', message: (err as Error).message };
  }
}

步骤 5 · 自检清单

  • 外部数据用 unknown + schema 校验
  • DTO 与领域模型分离
  • 金额用整数分,避免浮点
  • 时间统一 ISO 字符串,展示层再格式化
  • 错误用可辨识联合,调用方必须处理两种分支
  • 公共类型放 types/,有注释与 owner

6. 常见误区对照表

误区事实
「类型写全了就不用运行时校验」类型编译后擦除,接口返回脏数据依然会炸
「as 一下就好了」断言绕过检查,把错误推到运行时
「类型越复杂越显得水平高」类型是给人读的,过度体操拖慢编译、增加维护成本
「any 只是临时方案」不加约束的 any 会沿调用链扩散,最终让类型系统失效
「skipLibCheck 能解决类型冲突」它只是隐藏冲突,真实版本冲突仍会在别处爆发
「interface 和 type 可以随便换」合并行为、错误提示、性能都不同
「开 strict 太麻烦」麻烦一次,省下的是线上排查十次

7. 面试高频题(附答题思路)

Q1:interface 与 type 的区别? 对象形状 vs 类型运算;声明合并;implements;错误信息友好度;映射类型只能用 type。给一个具体选用例子。

Q2:any 和 unknown 的区别? unknown 必须收窄才能使用,any 直接放行;any 会传染;外部数据用 unknown。

Q3:说说泛型与泛型约束。 类型参数化 → 调用时推断 → extends 约束 → 默认类型参数 → 一个真实组件 props 的例子。

Q4:Pick / Omit 怎么实现的? 映射类型 + keyof + Exclude,现场手写。

Q5:TS 会被完全编译掉吗?运行时还有类型吗? 类型擦除;enum 会生成运行时代码(所以有争议);装饰器会生成;const enum 会被内联。


8. 面试深挖

8.1 面试官在考察什么

维度看什么加分回答减分回答
类型系统观是否理解「结构类型 + 擦除」两个前提能用这两点解释大部分「诡异报错」「TS 就是给 JS 加类型」
型变协变、逆变、双变的边界能解释 strictFunctionTypes 只约束函数属性「泛型就是占位符」
类型运算能否写出可维护的高级类型知道递归深度与编译性能代价堆砌 infer 炫技
工程落地类型与运行时的一致性用 schema 做单一事实源到处 as any 换安静
迁移能力存量项目怎么渐进收紧分阶段开 strict,先 checkJs「全部重写成 TS」

8.2 追问链

链 1 · 结构化类型 → 型变 → 数组协变的坑

  • 面试官:TypeScript 是名义类型还是结构类型?
    • 要点:结构类型,只比较成员形状,「长得一样就兼容」。class 里出现 private / protected 成员会引入名义性:不同声明处的 private 成员互不兼容,这是模拟「品牌类型」的常用手段。
  • 追问:为什么 (dog: Dog) => void 可以赋给 (animal: Animal) => void?
    • 要点:参数逆变、返回值协变。函数被赋值后可能收到任意 Animal,所以它必须能接受更宽的入参;返回值更具体是安全的。strictFunctionTypes 只对「函数类型属性」生效,方法语法(m(x: Dog): void)仍然是双变,因为数组方法与事件回调的既有实践依赖它。
  • 再追问:Dog[] 赋给 Animal[] 为什么危险?
    • 要点:TS 为实用性把数组设为协变,但 push(cat) 会在运行时污染原数组,读出来的元素并不是 Dog。安全写法是 readonly Animal[](只读位置协变),或把可变位置建模为 in T;这也是为什么函数参数应尽量声明为只读数组。

链 2 · 条件类型 → 分布式 → 递归深度

  • 面试官:手写 ReturnType<T>。
    • 要点:T extends (...args: any[]) => infer R ? R : never。infer 只能出现在 extends 子句里;同一位置多个 infer 会按协变/逆变合并成联合或交叉。
  • 追问:T extends any ? 1 : 2 和 [T] extends [any] ? 1 : 2 有什么区别?
    • 要点:裸类型参数遇联合类型会「分发」,A | B 会分别计算再合并;用元组包裹可以关闭分发。never 是空联合,分布式条件下直接得到 never,这常被用来做「排除」工具类型。
  • 再追问:为什么复杂类型会报 Type instantiation is excessively deep?
    • 要点:类型层递归没有运行时栈,但有实例化深度上限;TS 4.5 起对「尾递归条件类型」做了优化(可到 1000 层),非尾递归仍是几十层量级。写法上用一个累加器参数把结果带下去,让它成为尾递归;同时避免在大联合上做全量映射,否则 tsc 与编辑器都会明显变慢。

链 3 · 类型擦除 → 运行时缺口 → 边界校验

  • 面试官:类型在运行时存在吗?
    • 要点:不存在,编译后被擦除。as 不做任何检查,instanceof 只看构造函数,enum 会生成真实对象,const enum 会被内联但在 isolatedModules 下不可用(单文件转译无法跨文件内联)。
  • 追问:接口返回的数据怎么保证类型是真的?
    • 要点:在边界做运行时校验。用 zod / valibot 定义 schema,z.infer 反推类型,让 schema 成为单一事实源;或用 OpenAPI 生成类型与校验器。只在边界校验一次,内部传递可信类型。
  • 再追问:verbatimModuleSyntax 解决了什么问题?
    • 要点:Babel / esbuild 逐文件转译,无法判断一个 import 是类型还是值,可能保留无用导入或删掉有副作用的导入。import type 显式标注后,工具可以安全删除;verbatimModuleSyntax 强制这种写法,避免「编译能过、运行时缺模块」。

8.3 手写题

题 1 · 实现 DeepReadonly<T>

考察点:递归、数组与函数特判、避免把方法变成只读属性。

/**
 * 递归只读:数组转为只读数组,函数原样保留,其余对象逐层只读。
 * @example
 * type R = DeepReadonly<{ a: { b: number }[] }>;
 * // { readonly a: readonly { readonly b: number }[] }
 */
type DeepReadonly<T> = T extends (...args: any[]) => any
  ? T
  : T extends readonly (infer U)[]
    ? ReadonlyArray<DeepReadonly<U>>
    : T extends Map<infer K, infer V>
      ? ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>>
      : T extends Set<infer U>
        ? ReadonlySet<DeepReadonly<U>>
        : T extends object
          ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
          : T;

追问:

  • 为什么函数要原样返回?→ 函数也是对象,逐层只读会把调用签名破坏成属性映射,导致不可调用。
  • Date 会被怎么处理?→ 会进入 object 分支变成只读属性映射,方法丢失。生产实现应显式排除 Date、RegExp 等内建类型。
  • 循环引用类型会怎样?→ 类型层无法表达环,会无限展开直到深度上限报错,需要人为截断。

题 2 · 类型安全的事件总线

/** 事件表:事件名 → 参数元组 */
interface Events {
  'order:created': [id: string, amount: number];
  'order:closed': [id: string];
  'cart:changed': [];
}

/**
 * 事件总线:on / emit 的参数由事件表约束。
 */
class Emitter<E extends Record<string, unknown[]>> {
  private handlers: { [K in keyof E]?: Array<(...args: E[K]) => void> } = {};

  /** 订阅事件,返回取消函数 */
  on<K extends keyof E>(type: K, fn: (...args: E[K]) => void): () => void {
    (this.handlers[type] ??= []).push(fn);
    return () => {
      const list = this.handlers[type];
      if (list) this.handlers[type] = list.filter((h) => h !== fn) as typeof list;
    };
  }

  /** 触发事件,参数个数与类型由事件表校验 */
  emit<K extends keyof E>(type: K, ...args: E[K]): void {
    this.handlers[type]?.forEach((fn) => fn(...args));
  }
}

追问:

  • E extends Record<string, unknown[]> 换成 any[] 会怎样?→ any[] 让 emit 丢失参数校验,等于退回 EventEmitter 的弱类型。
  • 怎么支持「只触发一次」?→ 在 on 外包一层,执行前先调用取消函数;注意在回调内部取消时要避免遍历中修改数组。
  • 回调抛错怎么处理?→ 逐个 try/catch 并汇总,或用 reportError 上报,不要因为一个监听器失败中断其余监听器。

8.4 场景设计题

题:一个 8 万行的存量 JavaScript 项目要接入 TypeScript,业务不能停,半年内要让 strict 全开。你怎么推进?

评估点:是否分阶段、是否有量化基线、是否处理「类型与运行时不一致」。

参考方案:

  1. 建基线:allowJs + checkJs 先让 JS 参与检查,记录错误总数与 Top 20 文件;CI 加「错误数不得增加」的棘轮(ratchet)。
  2. 先收边界:接口层、状态层、工具层优先加类型,用 OpenAPI / zod 生成,避免手写漂移。
  3. 按目录开严格项:顺序建议 noImplicitAny → strictNullChecks → noUncheckedIndexedAccess → 其余;每开一项单独 PR,配套 codemod。
  4. 禁新增 any:ESLint no-explicit-any 设为 error,存量用 // eslint-disable-next-line 标注并登记偿还清单。
  5. 验收:strict 全开、tsc --noEmit 耗时在预算内、构建产物体积无回退、线上错误率不升。

加分:指出 strictNullChecks 会暴露大量「接口字段可能为空」的真实问题,应同步修后端契约而不是加 !;指出类型检查应进 CI 但不能阻塞开发(本地增量 + 远端全量)。

减分:一次性全量改后缀;用 // @ts-nocheck 换安静;把 any 换成 unknown 后到处 as。

8.5 最容易答错的点

常见说法纠正
「TS 是强类型语言」它是静态类型检查,运行时仍是 JS,类型会被擦除
「interface 和 type 完全等价」interface 可声明合并、更适合扩展;type 能表达联合与条件类型
「as 是类型转换」只是断言,不做运行时转换,断言错误会在运行时爆炸
「any 和 unknown 差不多」unknown 强制收窄后才能使用,any 直接放弃检查
「泛型默认值等于可选」默认值只在无法推断时生效,不影响约束检查
「enum 只是语法糖」会生成运行时代码;const enum 在 isolatedModules 下不可用
「strict 只是一个开关」它是多个开关的集合,应逐项开启并评估影响
「类型定义写全了运行时就是安全的」运行时数据可能不符合类型,边界必须校验

9. 延伸资源

  • TypeScript Handbook:Everyday Types、Type Manipulation、Declaration Files 三章必读。
  • type-challenges:按 easy/medium/hard/extreme 分级,恰好对应本图谱 L2~L4。
  • TS 官方 Release Notes:每个版本的类型行为变更,升级前必看。
  • total-typescript:实战型类型技巧文章。
  • zod / valibot 文档:schema 即类型的现代做法。
  • ts-morph 文档:AST 自动化重构。
  • 工具:tsd、expect-type、@microsoft/api-extractor、tsc --extendedDiagnostics。

下一步练习:挑你项目里最复杂的一个接口响应,按第 5 节的五步流程完整做一遍(DTO → 领域模型 → schema 校验 → 请求函数 → 类型测试)。做完你会明显感觉到「类型在帮你干活」。