04 · TypeScript 与类型工程(详解版)
一句话定位:TypeScript 是在 JavaScript 上面加了一层「可被编译器检查的文档」。它不改变运行时,但能让你在改代码时提前知道哪里会坏。
读完本篇你应该能做到
- 给任意一个后端接口写出「窄而准」的类型,并知道为什么不能只靠类型做校验;
- 看懂并写出常见的泛型工具类型(
Pick、Omit、ReturnType的底层实现);- 判断一段类型代码「值不值得写」——而不是把所有类型体操都堆上去。
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'
三个真实收益:
- 重构敢动手:改一个字段名,编译器会列出所有需要改的地方。
- 接口契约可见:打开文件就知道数据长什么样,不用猜。
- 编辑器变聪明:自动补全、跳转定义、查找引用,本质都依赖类型信息。
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 不做任何运行时检查,只是让编译器闭嘴。安全用法只有两种:
- 你确实比编译器知道得多(如
getElementById后已确认存在); - 用在类型守卫函数内部,把
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 | 不做运行时检查,别用来消报错 |
tsconfig | strict: true 是底线 |
| 编译 | 类型会被擦除,不能做运行时判断 |
自检题
any和unknown用起来有什么本质区别?——unknown必须先收窄。- 为什么说「类型不是安全边界」?——编译后擦除,运行时脏数据照样进来。
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 值得开 |
自检题
Exclude<'a'|'b'|'c', 'a'>为什么是'b'|'c'?——分布式条件类型逐个判断,命中变never,never在联合中被吸收。Pick和Omit的实现分别是什么?——见 2.5。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 越来越慢
三大元凶:
- 巨型联合类型展开:
keyof一个 200 字段的接口再做映射,实例化成本极高。 - 深层递归:递归超过约 50 层会触发「Type instantiation is excessively deep」。
- 分布式条件类型滥用:对联合类型反复分发,组合爆炸。
诊断命令:
# 查看类型检查耗时
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 小结与自检
- 一个泛型工具类型让 tsserver 卡死,怎么定位并改写?——
--extendedDiagnostics+ 简化联合/递归 + 缓存中间类型。 - 如何为「同一接口不同版本」设计类型?——版本化命名空间 + 弃用标记 + 类型测试。
- 业务代码要不要写类型体操?——看收益公式,多数场景不需要。
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拉高,导致别的包报错。
治理手段:
- 锁定版本:
overrides/resolutions统一@types/*版本。 - 集中管理类型补丁:所有
declare module放在types/patches/,有 CODEOWNERS。 - CI 加类型门禁:
tsc --noEmit失败即阻断;strict评分作为技术债指标。 - 升级前跑「类型影响面」:用
tsc --generateTrace或直接对比d.tsdiff。
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,下游全部报错。
管理流程:
- SemVer 严格遵守:类型不兼容 → major。
- API 报告:
@microsoft/api-extractor生成api.md,review 时看 diff。 - 废弃流程:
@deprecatedJSDoc + 新 API 并存 → codemod 迁移 → 下一个 major 删除。 - 迁移文档:每个 breaking change 给「之前 / 之后」代码对比。
4.5 组织级迁移:JS → TS
分阶段(不要一次性全改):
| 阶段 | 动作 | 度量 |
|---|---|---|
| 0 | allowJs: 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 自检
- 500 人代码库从
strict: false迁到全严格,分阶段方案与度量?——先开allowJs + checkJs并设错误数棘轮,再按目录依次开noImplicitAny、strictNullChecks、其余开关,每步配 codemod 与 owner;度量看错误总数、tsc耗时、any密度与类型相关线上事故数。 - 公共组件库如何保证类型变更不悄悄破坏下游?——语义化版本 +
api-extractor生成 API 报告在 CI 做 diff,对下游消费方跑类型测试,破坏性变更走 major + codemod + 弃用期,并收敛导出面禁止深路径导入。 - 什么时候该写自定义 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;这也是为什么函数参数应尽量声明为只读数组。
- 要点:TS 为实用性把数组设为协变,但
链 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与编辑器都会明显变慢。
- 要点:类型层递归没有运行时栈,但有实例化深度上限;TS 4.5 起对「尾递归条件类型」做了优化(可到 1000 层),非尾递归仍是几十层量级。写法上用一个累加器参数把结果带下去,让它成为尾递归;同时避免在大联合上做全量映射,否则
链 3 · 类型擦除 → 运行时缺口 → 边界校验
- 面试官:类型在运行时存在吗?
- 要点:不存在,编译后被擦除。
as不做任何检查,instanceof只看构造函数,enum会生成真实对象,const enum会被内联但在isolatedModules下不可用(单文件转译无法跨文件内联)。
- 要点:不存在,编译后被擦除。
- 追问:接口返回的数据怎么保证类型是真的?
- 要点:在边界做运行时校验。用 zod / valibot 定义 schema,
z.infer反推类型,让 schema 成为单一事实源;或用 OpenAPI 生成类型与校验器。只在边界校验一次,内部传递可信类型。
- 要点:在边界做运行时校验。用 zod / valibot 定义 schema,
- 再追问:
verbatimModuleSyntax解决了什么问题?- 要点:Babel / esbuild 逐文件转译,无法判断一个 import 是类型还是值,可能保留无用导入或删掉有副作用的导入。
import type显式标注后,工具可以安全删除;verbatimModuleSyntax强制这种写法,避免「编译能过、运行时缺模块」。
- 要点:Babel / esbuild 逐文件转译,无法判断一个 import 是类型还是值,可能保留无用导入或删掉有副作用的导入。
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 全开。你怎么推进?
评估点:是否分阶段、是否有量化基线、是否处理「类型与运行时不一致」。
参考方案:
- 建基线:
allowJs + checkJs先让 JS 参与检查,记录错误总数与 Top 20 文件;CI 加「错误数不得增加」的棘轮(ratchet)。 - 先收边界:接口层、状态层、工具层优先加类型,用 OpenAPI / zod 生成,避免手写漂移。
- 按目录开严格项:顺序建议
noImplicitAny→strictNullChecks→noUncheckedIndexedAccess→ 其余;每开一项单独 PR,配套 codemod。 - 禁新增
any:ESLintno-explicit-any设为 error,存量用// eslint-disable-next-line标注并登记偿还清单。 - 验收:
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 校验 → 请求函数 → 类型测试)。做完你会明显感觉到「类型在帮你干活」。