Appearance
Day8 TypeScript 高级:类型体操、infer、条件类型
📖 基础知识讲解【基础原理 + 高级深挖知识点】
基础原理
1. TS 类型系统的两条主线
TypeScript 类型系统
│
┌───────────────────┴───────────────────┐
▼ ▼
【值空间 Value】 【类型空间 Type】
const / let / function type / interface
class(同时存在于两个空间) 泛型参数 <T>
│ │
│ typeof 变量 ───────────────────────► │ 把值提升为类型
│ ◄─────────────────── 无法把类型降为值 │
▼ ▼
运行时真实存在 编译期擦除,产物里一行不剩第一性原理: TS 的类型只存在于编译期,
tsc做完检查就把类型全部擦掉。所以类型层面做不了任何运行时判断,这是理解declare、类型守卫、as断言为什么只是「骗编译器」的根本原因。
| 关键字 | 所在空间 | 能否作为值使用 |
|---|---|---|
interface / type | 仅类型空间 | ❌ |
const / let | 仅值空间 | ✅ |
class | 两个空间 | ✅(值)+ ✅(实例类型) |
enum | 两个空间 | ✅(会生成运行时对象) |
namespace | 两个空间 | ✅ |
2. type 与 interface 的全部差异
| 维度 | interface | type |
|---|---|---|
| 声明合并 | ✅ 同名自动合并 | ❌ 重复声明直接报错 |
| 可描述类型 | 只能是对象 / 函数 / 类(object shape) | 任意类型:联合、交叉、元组、原始值、字面量 |
| 继承方式 | extends(会做兼容性检查) | & 交叉(不检查冲突,冲突属性可能变成 never) |
| 映射类型 | ❌ 不能用 [K in keyof T] | ✅ |
| 条件类型 | ❌ | ✅ |
| 递归引用 | ✅ 天然支持 | ✅ 支持(部分早期版本需绕过) |
| 实现(implements) | ✅ | ✅(只要是对象结构) |
| 报错信息 | 保留名字,更易读 | 可能被展开成一长串结构 |
ts
// ① 声明合并:interface 独有,第三方库扩展全靠它
interface Window {
__APP_VERSION__: string;
}
interface Window {
__TRACKER__: { report(e: unknown): void };
} // ✅ 合并成一个,含两个属性
// ② type 能表达 interface 做不到的
type Status = "pending" | "fulfilled" | "rejected"; // 联合
type Point = [number, number]; // 元组
type Fn = (a: number) => void; // 函数(interface 也行但更啰嗦)
type Nullable<T> = T | null; // 泛型包装
// ③ extends vs & 的差异:冲突时行为完全不同
interface A {
x: number;
}
// interface B extends A { x: string } // ❌ 立即报错:类型不兼容
type C = A & { x: string }; // ✅ 不报错,但 C['x'] 是 never,赋值时才炸选型结论: 对外暴露的对象结构用
interface(可被扩展、报错友好);工具类型、联合类型、类型运算用type。团队里统一一条规则比争论哪个更好重要得多。
3. 泛型与泛型约束
ts
// 泛型 = 类型的「参数」,把类型的决定权延迟到调用处
function identity<T>(v: T): T {
return v;
}
// 泛型约束:T 必须满足某种结构
function getLen<T extends { length: number }>(v: T) {
return v.length;
}
getLen("abc"); // ✅ 3
getLen([1, 2]); // ✅ 2
// getLen(123); // ❌ number 没有 length
// 多泛型 + keyof 约束:类型安全的属性取值
function pick<T extends object, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: "jack" };
pick(user, "name"); // string(精确到字段类型)
// pick(user, 'age'); // ❌ 编译期就拦住拼写错误
// 默认泛型参数
type ApiRes<T = unknown> = { code: number; data: T; msg: string };| 场景 | 写法 |
|---|---|
| 约束为对象 | T extends object |
| 约束为对象的 key | K extends keyof T |
| 约束为构造函数 | T extends new (...args: any[]) => any |
| 约束为函数 | T extends (...args: any[]) => any |
| 约束为数组 | T extends readonly unknown[] |
| 约束为字符串字面量 | S extends string |
4. 条件类型:类型层面的三元表达式
ts
type IsString<T> = T extends string ? true : false;
type R1 = IsString<"abc">; // true
type R2 = IsString<123>; // false T extends U ? X : Y
│
▼
「T 可以赋值给 U 吗?」 ← 注意:是赋值兼容,不是继承
│
┌─────────┴─────────┐
▼ ▼
是 → 取 X 否 → 取 Y
⚠️ 如果 T 是「裸类型参数」且传入联合类型 → 触发【分布式条件类型】
IsString<'a' | 1> ==> IsString<'a'> | IsString<1> ==> true | false ==> booleants
// 关掉分布式:用方括号包起来,让 T 不再是裸类型参数
type IsStringStrict<T> = [T] extends [string] ? true : false;
type R3 = IsStringStrict<"a" | 1>; // false(整体判断:'a'|1 不能整体赋给 string)
// 整体判断是「联合能否赋给联合」,所以目标类型放宽就能为 true
type IsStrOrNum<T> = [T] extends [string | number] ? true : false;
type R4 = IsStrOrNum<"a" | 1>; // true
// never 的特殊性:never 是空联合,分布式时直接短路返回 never
type Test = IsString<never>; // never(不是 false!)
type Test2 = IsStringStrict<never>; // true(关闭分发后,never 可赋给任何类型)5. infer:在条件类型里「挖」出类型
一句话定义: infer 写在条件类型 extends 的右侧,对目标结构做模式匹配,把其中某个位置的类型「抓」出来存进一个临时类型变量,? 后面的分支就能直接拿来用。
⚠️ 硬性规则:
infer只能写在extends的右边,写左边是语法错误;并且只能出现在条件类型里,脱离extends ... ? ... : ...单独写也不行。
ts
// infer 只能出现在 extends 的【右侧】,作为一个占位变量
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type MyParameters<T> = T extends (...args: infer P) => any ? P : never;
type ElementOf<T> = T extends (infer E)[] ? E : never;
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T;① MyReturnType —— 提取函数返回值类型
ts
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
// └─ ① 判断 T 是不是函数类型
// └─ ② 是的话,看返回值位置,推断给 R
// └─ ③ 命中就返回 R
// └─ ④ 不是函数返回 never
type Fn = () => number;
type Ret = MyReturnType<Fn>; // number② MyParameters —— 提取参数元组类型
ts
type MyParameters<T> = T extends (...args: infer P) => any ? P : never;
// (...args: infer P) 匹配「参数列表」位置,整个参数列表被推断成一个元组
type Fn2 = (name: string, age: number) => void;
type Params = MyParameters<Fn2>; // [name: string, age: number]③ ElementOf —— 提取数组元素类型
ts
type ElementOf<T> = T extends (infer E)[] ? E : never;
// (infer E)[] 匹配「数组」结构,推断出每一项的元素类型
type Arr = string[];
type Item = ElementOf<Arr>; // string
type Item2 = ElementOf<(string | number)[]>; // string | number④ UnwrapPromise —— 解开 Promise 包裹
ts
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T;
// Promise<infer V> 匹配「Promise 泛型位」,推断里面包裹的值类型
type P = Promise<{ id: number }>;
type Res = UnwrapPromise<P>; // { id: number }
type Res2 = UnwrapPromise<string>; // string(false 分支返回 T 本身,而不是 never)
// ⚠️ 只解一层!嵌套要递归 —— 这正是内置 Awaited 的做法
type Bad = UnwrapPromise<Promise<Promise<number>>>; // Promise<number>自己动手写一个:提取元组首项 / 末项
ts
type FirstItem<T> = T extends [infer First, ...any[]] ? First : never;
type LastItem<T> = T extends [...any[], infer Last] ? Last : never;
type T1 = FirstItem<[string, number, boolean]>; // string
type T2 = LastItem<[string, number, boolean]>; // booleants
// ❌ 三种常见错误写法
type Bad1<T> = infer R extends T ? R : never; // infer 放左边,语法报错
type Bad2<T> = infer R; // 脱离条件类型,报错
type Bad3<T> = T extends infer R ? R : never; // 语法合法但没意义:R 恒等于 TT = (a: number, b: string) => boolean
T extends (...args: infer P) => infer R ? ... : ...
└──┬──┘ └─┬─┘
│ └── R 被推断为 boolean
└── P 被推断为 [a: number, b: string]| 位置 | 示例 | 提取出什么 |
|---|---|---|
| 函数返回值 | (...a: any[]) => infer R | 返回值类型 |
| 函数参数 | (...a: infer P) => any | 参数元组 |
| 数组元素 | (infer E)[] | 元素类型 |
| 元组首项 | [infer F, ...any[]] | 第一项 |
| Promise 内部 | Promise<infer V> | resolve 的类型 |
| 构造函数实例 | new (...a: any[]) => infer I | 实例类型 |
| 字符串模板 | `${infer H}${infer Rest}` | 首字符 + 剩余 |
| 对象属性 | { data: infer D } | 某个属性的类型 |
一次写多个 infer: 同一个 extends 右侧可以放任意多个占位变量,互不干扰。
ts
type GetFnInfo<T> = T extends (...args: infer P) => infer R ? [P, R] : never;
type Info = GetFnInfo<(a: number, b: string) => boolean>;
// [[a: number, b: string], boolean]
// 同时解构对象的多个字段
type SplitRes<T> = T extends { code: infer C; data: infer D }
? { status: C; payload: D }
: never;
type SR = SplitRes<{ code: 200; data: string[] }>;
// { status: 200; payload: string[] }同名 infer 出现多次: 位置决定合并方式 —— 协变位取联合,逆变位取交叉。
ts
// 协变位置(返回值 / 对象属性)→ 联合
type Co<T> = T extends { a: infer U; b: infer U } ? U : never;
type C1 = Co<{ a: string; b: number }>; // string | number
// 逆变位置(函数参数)→ 交叉
type Contra<T> = T extends (a: infer U, b: infer U) => void ? U : never;
type C2 = Contra<(a: { x: 1 }, b: { y: 2 }) => void>; // { x: 1 } & { y: 2 }带约束的 infer(TS 4.7+): infer R extends X,不满足约束直接走 false 分支。
ts
// 把数字字符串推断成数字字面量类型
type ToNum<S extends string> = S extends `${infer N extends number}`
? N
: never;
type N1 = ToNum<"42">; // 42(number 字面量,不是 string)
type N2 = ToNum<"abc">; // neverinfer 和泛型参数的区别:
| 维度 | 泛型参数 <T> | infer R |
|---|---|---|
| 谁来决定 | 外部调用方显式传 | TS 自动推断,从已有结构里抓出来 |
| 书写位置 | 类型名后的尖括号里 | 只能在 extends 右侧的模式里 |
| 作用域 | 整个类型声明 | 仅限当前条件类型的 ? 和 : 分支 |
| 用途 | 把类型的决定权外移 | 把结构内部「隐藏」的类型挖出来复用 |
| 推断失败 | 用默认值或报错 | 走 false 分支(常见写法是 never) |
三条必须记住的规则: ①
infer只能在extends右侧;② 它做的是模式匹配,匹配不上就走 false 分支;③ 必须依附在条件类型里,不能单独声明。几乎所有内置工具类型 ——ReturnType、Parameters、InstanceType、Awaited、ThisParameterType—— 都是这三条规则的直接应用。
6. 映射类型与 keyof / 索引访问
ts
type Keys = keyof { a: 1; b: 2 }; // 'a' | 'b'
type Values = { a: 1; b: 2 }["a" | "b"]; // 1 | 2(索引访问也支持联合)
// 映射类型:遍历 key 生成新类型
type Clone<T> = { [K in keyof T]: T[K] };
// 修饰符:? 可选、readonly 只读,前面加 - 表示【移除】
type Mutable<T> = { -readonly [K in keyof T]-?: T[K] };
// as 重映射(TS 4.1+):可以改 key 的名字,也可以过滤 key
type Getters<T> = {
[K in keyof T & string as `get${Capitalize<K>}`]: () => T[K];
};
type G = Getters<{ name: string; age: number }>;
// { getName: () => string; getAge: () => number }
// 过滤 key:返回 never 的 key 会被丢弃
type OmitByKey<T, K extends keyof any> = {
[P in keyof T as P extends K ? never : P]: T[P];
};7. 内置工具类型总表(必须能手写)
| 工具类型 | 作用 | 一行实现 |
|---|---|---|
Partial<T> | 全部可选 | { [K in keyof T]?: T[K] } |
Required<T> | 全部必填 | { [K in keyof T]-?: T[K] } |
Readonly<T> | 全部只读 | { readonly [K in keyof T]: T[K] } |
Pick<T, K> | 挑选 key | { [P in K]: T[P] } |
Omit<T, K> | 剔除 key | Pick<T, Exclude<keyof T, K>> |
Record<K, V> | 构造字典 | { [P in K]: V } |
Exclude<T, U> | 联合里排除 | T extends U ? never : T |
Extract<T, U> | 联合里提取 | T extends U ? T : never |
NonNullable<T> | 去掉 null/undefined | T & {}(旧版 T extends null | undefined ? never : T) |
ReturnType<T> | 函数返回值 | T extends (...a: any) => infer R ? R : never |
Parameters<T> | 函数参数元组 | T extends (...a: infer P) => any ? P : never |
ConstructorParameters<T> | 构造参数元组 | T extends new (...a: infer P) => any ? P : never |
InstanceType<T> | 实例类型 | T extends new (...a: any) => infer I ? I : never |
Awaited<T> | 递归解包 Promise | T extends Promise<infer U> ? Awaited<U> : T |
ThisParameterType<T> | 提取 this 参数 | T extends (this: infer U, ...a: any[]) => any ? U : unknown |
Uppercase / Lowercase / Capitalize / Uncapitalize | 字符串变形 | 编译器内置(intrinsic),无法用 TS 语法实现 |
✨ 高级深挖知识点
1. 分布式条件类型的完整机制
触发的三个必要条件:
| 条件 | 说明 |
|---|---|
| 必须是条件类型 | 只有 T extends U ? X : Y 形式才会分发 |
T 必须是裸类型参数 | 不能被 []、{}、函数包裹,否则退化为整体判断 |
| 传入的实参是联合类型 | 非联合类型没有可分发的成员 |
Exclude<'a' | 'b' | 'c', 'a'>
│
▼ 自动拆分(distribute)
┌────────┼────────┐
▼ ▼ ▼
'a' ext 'a' 'b' ext 'a' 'c' ext 'a'
?never:'a' ?never:'b' ?never:'c'
▼ ▼ ▼
never 'b' 'c'
└────────┼────────┘
▼ 合并回联合类型(never 被自动丢弃)
'b' | 'c'ts
// never 在联合类型里会被「吸收」,这是 Exclude 能生效的关键
type X = never | "b" | "c"; // 'b' | 'c'
// ⚠️ 但 never 作为【入参】会让整个条件类型短路
type ToArr<T> = T extends any ? T[] : never;
type A1 = ToArr<never>; // never(而不是 never[])
type A2 = ToArr<string | number>; // string[] | number[]
// boolean 本质是 true | false,也会被分发(高频陷阱)
type Neg<T extends boolean> = T extends true ? false : true;
type N = Neg<boolean>; // boolean(false | true),不是单一值ts
// 实战:联合转交叉(UnionToIntersection)—— 逆变位置的经典用法
type UnionToIntersection<U> = (U extends any ? (x: U) => void : never) extends (
x: infer I
) => void
? I
: never;
type UI = UnionToIntersection<{ a: 1 } | { b: 2 }>; // { a: 1 } & { b: 2 }为什么能转交叉: 先用分布式把联合拆成
((x:A)=>void) | ((x:B)=>void),再用infer从**函数参数位置(逆变位置)**反推。多个函数类型要同时兼容,参数必须取交叉,这正是 TS 协变/逆变规则的直接体现。
2. 协变、逆变与双变
父类型 Animal Animal[] ──┐
▲ │ 协变 Covariant
子类型 Dog Dog[] ◄──┘ 跟随原方向
函数参数位置:
(a: Animal) => void ──┐
│ 逆变 Contravariant
(a: Dog) => void ◄──┘ 方向反转| 位置 | 变型方式 | 记忆要点 |
|---|---|---|
| 对象属性 / 数组元素 | 协变 | 子类型可以赋给父类型 |
| 函数返回值 | 协变 | 返回更具体的类型是安全的 |
| 函数参数 | 逆变 | 接受更宽泛的参数才安全(strictFunctionTypes) |
| 方法简写语法 | 双变(不安全) | { fn(a: Dog): void } 历史兼容特例 |
ts
class Animal {}
class Dog extends Animal {
bark() {}
}
let fnAnimal: (a: Animal) => void = (a) => {};
let fnDog: (a: Dog) => void = (d) => d.bark();
fnDog = fnAnimal; // ✅ 逆变:要求 Dog,给了能处理 Animal 的函数,安全
// fnAnimal = fnDog; // ❌ 不安全:传 Cat 进来会调用 bark
// ⚠️ 方法简写是双变(不受 strictFunctionTypes 约束)
interface Unsafe {
handle(a: Dog): void;
} // 双变,可互相赋值
interface Safe {
handle: (a: Dog) => void;
} // 严格逆变检查TS 4.7+ 可以显式标注变型:
interface Box<in out T>、type Getter<out T>、type Setter<in T>,在复杂泛型里能显著加速类型检查并避免推断歧义。
3. 模板字面量类型(TS 4.1+)
ts
type Prefix = `/api/${string}`;
type Method = "get" | "post";
type Route = `${Method}:/user`; // 'get:/user' | 'post:/user'(联合会做笛卡尔积)
// 从路径里解析出动态参数(Vue Router / Express 类型推断的核心做法)
type ParseParams<S extends string> =
S extends `${string}:${infer P}/${infer Rest}`
? { [K in P]: string } & ParseParams<`/${Rest}`>
: S extends `${string}:${infer P}`
? { [K in P]: string }
: {};
type P1 = ParseParams<"/user/:id/post/:postId">;
// { id: string } & { postId: string }
// 字符串分割
type Split<
S extends string,
D extends string
> = S extends `${infer H}${D}${infer T}` ? [H, ...Split<T, D>] : [S];
type S1 = Split<"a,b,c", ",">; // ['a', 'b', 'c']
// 蛇形转驼峰(后端返回下划线字段时的高频需求)
type CamelCase<S extends string> = S extends `${infer H}_${infer T}`
? `${H}${Capitalize<CamelCase<T>>}`
: S;
type C1 = CamelCase<"user_name_list">; // 'userNameList'
// 整个对象的 key 转驼峰
type CamelKeys<T> = {
[K in keyof T & string as CamelCase<K>]: T[K];
};
// 深层 key 路径(lodash get 的类型安全版)
type Paths<T> = T extends object
? {
[K in keyof T & string]: T[K] extends object
? K | `${K}.${Paths<T[K]>}`
: K;
}[keyof T & string]
: never;
type PathKeys = Paths<{ a: { b: { c: 1 } } }>; // 'a' | 'a.b' | 'a.b.c'| 内置字符串工具 | 效果 |
|---|---|
Uppercase<S> | 'abc' → 'ABC' |
Lowercase<S> | 'ABC' → 'abc' |
Capitalize<S> | 'abc' → 'Abc' |
Uncapitalize<S> | 'Abc' → 'abc' |
注意性能: 模板字面量 + 联合类型会做笛卡尔积,两个各 100 项的联合拼起来就是 10000 个成员,很容易让编辑器卡死。真实项目里要给联合规模设上限,拿不准就退化成
string。
4. 递归类型与递归深度限制
ts
// 深度 Partial / Readonly(业务里最常用的两个递归工具)
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
type DeepReadonly<T> = T extends (...args: any[]) => any
? T // 函数不递归
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
// 元组反转(尾递归 + 累加器写法,TS 4.5 起支持尾递归消除)
type Reverse<T extends unknown[], Acc extends unknown[] = []> = T extends [
infer H,
...infer R
]
? Reverse<R, [H, ...Acc]>
: Acc;
type R1 = Reverse<[1, 2, 3]>; // [3, 2, 1]
// 类型层面做加法:靠「构造指定长度的元组再取 length」
type BuildTuple<
N extends number,
Acc extends unknown[] = []
> = Acc["length"] extends N ? Acc : BuildTuple<N, [...Acc, unknown]>;
type Add<A extends number, B extends number> = [
...BuildTuple<A>,
...BuildTuple<B>
]["length"];
type Sum = Add<3, 4>; // 7递归深度上限: TS 对类型实例化深度有硬限制(普通递归约 50 层,尾递归约 1000 层),超了报
Type instantiation is excessively deep and possibly infinite。解法:① 改写成尾递归形式(递归调用放在返回位置);② 用累加器Acc代替嵌套;③ 加一个深度计数泛型主动截断,比如Depth extends unknown[] = []。
5. any / unknown / never / void 四兄弟
一句话总览:
any:放弃类型检查,想怎么写怎么写,等于直接逃出类型系统unknown:安全版 any,什么都能接,但用之前必须先收窄或断言never:不可能存在的值,代表代码永远走不到(抛错、死循环、不可达分支)void:没有有效返回值,函数能正常跑完,只是不返回东西
unknown ← 顶层类型(所有类型的父类型)
▲
┌────────┼────────┬──────────┐
string number object void
▲ ▲ ▲ ▲
└────────┴────┬───┴──────────┘
never ← 底层类型(所有类型的子类型)
any 不在这个格子里:它同时扮演顶层和底层,双向可赋值 → 所以危险| 类型 | 本质 | 可赋值给它 | 它可赋值给 | 典型用途 |
|---|---|---|---|---|
any | 关闭类型检查 | 任何类型 | 任何类型 | 应急逃生舱,尽量别用 |
unknown | 安全的顶层 | 任何类型 | 只能给 unknown/any | 外部输入、JSON.parse |
never | 底层类型 | 只有 never | 任何类型 | 穷尽检查、不可达分支 |
void | 无返回值 | undefined | 只能给 void/any | 函数返回值、回调忽略返回 |
① any —— 关闭校验,坑最多
ts
let a: any;
a = 1;
a = "hello";
a = true; // ✅ 任意值都能赋给 any
a.foo(); // ✅ 调用不存在的方法,TS 不报错 → 运行时才炸
a[999].x; // ✅ 访问不存在的属性,TS 不报错
const b: number = a; // ✅ any 能反向赋给任意类型 → 类型保护在这里断链传染性是 any 最大的危害: 一个
any赋给下游变量后,整条调用链的类型检查全部失效。工程上要用 ESLint 的no-explicit-any+ tsconfig 的noImplicitAny一起锁住。
② unknown —— 安全版 any
ts
let u: unknown;
u = 1;
u = "abc"; // ✅ 什么都能接
// u.toFixed() ❌ 'u' is of type 'unknown'
// const num: number = u ❌ 不能直接赋给别的类型
if (typeof u === "number") {
const n: number = u; // ✅ 类型守卫收窄后才能用
}
const n2 = u as number; // ✅ 或者显式断言(但断言没有运行时校验)一句话区分:
any是「不管对错随便用」,unknown是「什么都能接,但想用必须先证明它是什么」。
③ never —— 不可能存在的值
ts
// 永远不会正常返回的函数,返回类型是 never
function throwErr(): never {
throw new Error("err");
}
function loop(): never {
while (true) {}
}
let n: never;
// n = 1 ❌
// n = undefined ❌ 没有任何值能赋给 never
n = throwErr(); // ✅ 只有 never 能赋给 never
// never 在联合里会被「吸收」—— Exclude 能生效的根基
type X = never | string; // string
// 对应分布式条件类型的考点:裸 T 传 never 会短路
type ToArr<T> = T extends any ? T[] : never;
type A1 = ToArr<never>; // never(不是 never[])④ void —— 没有有效返回值
ts
function fn1(): void {
console.log(111);
} // ✅ 不写 return
function fn2(): void {
return undefined;
} // ✅ 显式返回 undefined
// function fn3(): void { return 1 } ❌ 不能返回有效值
let v: void;
v = undefined; // ✅
// v = 1; ❌
// v = null; ❌ strictNullChecks 下报错void vs never(面试官最爱问):
| 维度 | void | never |
|---|---|---|
| 函数能否跑完 | ✅ 正常执行结束 | ❌ 根本到不了结束(抛错/死循环) |
| 有没有返回值 | 有返回动作,值是 undefined | 连返回这件事都不会发生 |
| 变量可赋值 | undefined | 什么都赋不进去 |
| 联合里的表现 | void | string 保持联合 | never | string 被吸收成 string |
ts
function vFn(): void {} // 跑完了,只是没返回东西
function nFn(): never {
throw new Error();
} // 跑到 throw 直接终止,不会正常结束三个工程里真正会用到的场景:
ts
// unknown 必须先收窄才能用 —— 这正是它比 any 安全的地方
function handle(res: unknown) {
// res.data ❌ Object is of type 'unknown'
if (typeof res === "object" && res !== null && "data" in res) {
console.log((res as { data: string }).data); // ✅
}
}
// never 做联合穷尽检查:新增 status 时编译期就报错
type Status = "pending" | "success" | "fail";
function text(s: Status) {
switch (s) {
case "pending":
return "进行中";
case "success":
return "成功";
case "fail":
return "失败";
default:
const _exhaustive: never = s; // 漏分支时这里编译失败
return _exhaustive;
}
}
// void 的宽容规则:声明为返回 void 的回调,实现可以返回任意值(但返回值会被忽略)
const arr: number[] = [];
[1, 2].forEach((n) => arr.push(n)); // push 返回 number,forEach 要 void —— 合法面试加分点:
never是所有类型的子类型,所以never参与联合会被直接吸收(never | string === string);而unknown是所有类型的父类型,参与联合会吸收掉别人(unknown | string === unknown)。这两条规则是很多工具类型(如Exclude为什么能"删掉"成员)能跑通的根基。
四类型判断题自测(先自己判,再看答案):
ts
// 1. let a: any = 1; let b: number = a; // ?
// 2. let u: unknown = 1; let n: number = u; // ?
// 3. let v: void = undefined; let v2: void = null; // ?(开启 strictNullChecks)
// 4. let n2: never = 1; // ?
// 5. function f(): void { return 1; } // ?
// 6. type R = never | string; // R 是 ?
// 7. type S = unknown | string; // S 是 ?
// 8. type T = void | string; // T 是 ?| 题号 | 结果 | 原因 |
|---|---|---|
| 1 | ✅ 通过 | any 可以赋给任何类型,但也因此彻底断掉类型检查 |
| 2 | ❌ 报错 | unknown 只能赋给 unknown 和 any,用之前必须收窄或断言 |
| 3 | undefined 通过,null 报错 | strictNullChecks 下 null 不属于 void |
| 4 | ❌ 报错 | never 是空集,没有任何值能赋给它 |
| 5 | ❌ 报错 | void 函数可以 return;,但不能 return 值 |
| 6 | string | never 被联合吸收(恒等元) |
| 7 | unknown | unknown 会吸收掉所有兄弟类型 |
| 8 | void | string | void 不被吸收,是独立成员,不会被"吞掉" |
第 6、7、8 三题连起来看最有用:
never是联合运算的"零",unknown是"无穷大",void则是普通成员——它在联合里既不吃别人也不被别人吃,所以void | string永远保留两个分支,这也是void写错时经常"看起来没报错、实际收窄失效"的原因。
6. 类型守卫与类型收窄
ts
// ① typeof —— 原始类型
function f1(x: string | number) {
if (typeof x === "string") x.toUpperCase();
}
// ② instanceof —— 类实例
function f2(e: Error | string) {
if (e instanceof Error) e.stack;
}
// ③ in —— 判断属性存在
type Cat = { meow(): void };
type Dog = { bark(): void };
function f3(a: Cat | Dog) {
if ("meow" in a) a.meow();
else a.bark();
}
// ④ 判别联合(Discriminated Union)—— 工程里最推荐的建模方式
type Res<T> =
| { status: "success"; data: T }
| { status: "fail"; message: string };
function render<T>(r: Res<T>) {
if (r.status === "success") return r.data; // ✅ 自动收窄,无 message
return r.message;
}
// ⑤ 自定义类型谓词 v is T
function isString(v: unknown): v is string {
return typeof v === "string";
}
// ⑥ 断言签名 asserts(收窄效果会延续到调用之后)
function assertNonNull<T>(v: T): asserts v is NonNullable<T> {
if (v == null) throw new Error("empty");
}
function f4(name?: string) {
assertNonNull(name);
name.length; // ✅ 这里已是 string
}| 收窄方式 | 能力 | 注意 |
|---|---|---|
typeof | 原始类型 | typeof null === 'object' 要单独排除 |
instanceof | 类 / 构造函数 | 跨 iframe、跨 realm 会失效 |
in | 属性存在性 | 可选属性存在但为 undefined 时会误判 |
| 判别联合 | 最可靠,推荐优先 | 需要提前设计 kind / status 字段 |
v is T | 封装复杂判断 | 返回值逻辑写错编译器不会提醒 |
asserts v is T | 收窄延续到后续语句 | 必须是具名函数,箭头函数变量需显式标注类型 |
7. 声明文件与模块扩展
ts
// global.d.ts —— 挂全局变量 / 扩展 window
declare global {
interface Window {
__APP_CONFIG__: { baseURL: string };
}
const __VERSION__: string;
}
// 给第三方库补类型(没有 @types 的情况)
declare module "untyped-lib" {
export function init(opts: { id: string }): void;
const _default: { version: string };
export default _default;
}
// 扩展已有库的类型(Vue 实例上加全局属性)
declare module "vue" {
interface ComponentCustomProperties {
$http: typeof import("axios").default;
}
}
// 约束 process.env(避免到处 as string)
declare namespace NodeJS {
interface ProcessEnv {
NODE_ENV: "development" | "production";
VITE_API_BASE: string;
}
}
// 非代码资源
declare module "*.svg" {
const src: string;
export default src;
}| tsconfig 关键项 | 作用 |
|---|---|
strict | 一键开启下面全部严格检查,新项目必开 |
strictNullChecks | null / undefined 不再能赋给任意类型,收益最大 |
noImplicitAny | 禁止隐式 any |
strictFunctionTypes | 函数参数按逆变严格检查(方法语法仍是双变) |
noUncheckedIndexedAccess | arr[i] 自动带上 undefined,防越界 |
exactOptionalPropertyTypes | 区分"属性不存在"与"属性值为 undefined" |
skipLibCheck | 跳过 .d.ts 检查,提速明显但可能漏掉库类型冲突 |
isolatedModules | 要求每个文件可独立转译,类型导入必须用 import type |
verbatimModuleSyntax | 保留 import/export 书写形式,替代旧的 importsNotUsedAsValues |
paths / baseUrl | 路径别名,需与打包器(vite/webpack)配置保持一致 |
8. 类型层面的性能问题
| 症状 | 原因 | 解法 |
|---|---|---|
| 编辑器飘红延迟数秒 | 大量交叉类型 A & B & C... 展开 | 改用 interface extends,或用 Prettify 展开 |
| 联合类型爆炸 | 模板字面量笛卡尔积 | 限制联合规模,必要时退化成 string |
| 递归报"实例化过深" | 普通递归超 50 层 | 改尾递归 + 累加器,或加深度计数截断 |
tsc --noEmit 耗时过长 | 全量 d.ts 检查、循环依赖 | 开 skipLibCheck、项目引用拆分、--incremental |
ts
// Prettify:把交叉类型"压平",鼠标悬浮时能看到完整结构而不是 A & B
type Prettify<T> = { [K in keyof T]: T[K] } & {};
type Messy = { a: string } & { b: number } & { c: boolean };
type Clean = Prettify<Messy>; // { a: string; b: number; c: boolean }排查手段:
tsc --generateTrace trace生成性能追踪文件,再用@typescript/analyze-trace分析,能精确定位是哪个类型把检查拖慢了。
9. 结构类型系统与多余属性检查
第一性原理: TS 是结构类型系统(Structural Typing),判断两个类型兼容只看「成员形状」,完全不看声明它的名字或继承关系。这就是「鸭子类型」——长得像就是。
ts
interface Point {
x: number;
y: number;
}
class Vec {
constructor(public x: number, public y: number) {}
}
const p: Point = new Vec(1, 2); // ✅ Vec 没 implements Point,照样兼容
const q: Point = { x: 1, y: 2, z: 3 } as Vec; // ✅ 只要成员够就行| 兼容判断对象 | 规则 |
|---|---|
| 对象属性 | 目标类型要求的属性,源类型必须都有且类型兼容(可以多) |
| 函数参数个数 | 源函数参数可以少,不能多 |
| 函数参数类型 | 逆变(strictFunctionTypes 下生效) |
| 函数返回值 | 协变;返回 void 的位置可以塞任意返回值 |
| 可选属性 | 目标可选、源没有 → 兼容 |
private 成员 | 例外:含 private/protected 成员的类只和「同一处声明」兼容 |
ts
// private 成员打破结构兼容 —— 这是 TS 里唯一接近「名义类型」的内建行为
class A {
private secret = 1;
}
class B {
private secret = 1;
}
// let a: A = new B(); ❌ 'secret' 是两个不同声明里的私有成员多余属性检查(Excess Property Check / Freshness): 结构类型本该「多给属性也兼容」,但直接写对象字面量时 TS 会额外做一次严格检查。
ts
interface Opt {
url: string;
timeout?: number;
}
// ❌ 直接传字面量 → 触发多余属性检查
// request({ url: '/a', timeOut: 3000 }); // 报错:timeOut 不存在(拼写错误被抓到)
// ✅ 先赋给变量,"新鲜度"丢失,退回普通结构兼容,拼写错误就漏过去了
const opt = { url: "/a", timeOut: 3000 };
declare function request(o: Opt): void;
request(opt); // 不报错!这是最常见的「类型没拦住拼错」原因| 绕过/关闭多余属性检查的四种方式 | 说明 |
|---|---|
| 先赋值给中间变量 | 最常见的「意外绕过」,也是漏掉拼写错误的根源 |
as Opt 断言 | 显式绕过 |
| 目标类型含索引签名 | [k: string]: unknown 后任意多余 key 都合法 |
| 目标类型是联合且某分支含该 key | 只要有一个分支认识它就放行 |
工程结论: 想让「拼写错误一定报错」,就让对象字面量直接出现在调用处或变量类型标注处;想保留字面量检查又不丢推断,用
satisfies(见下一节)。
10. satisfies 操作符(TS 4.9)
要解决的问题: 类型标注和 as 各有缺陷 —— 标注会丢掉字面量的精确推断,as 会完全放弃校验。satisfies 的语义是「校验它满足这个类型,但保留它自己推断出的精确类型」。
ts
type Config = Record<string, string | number>;
// ① 类型标注:校验通过,但精确类型丢了
const a: Config = { host: "localhost", port: 8080 };
// a.port.toFixed() ❌ port 被宽化成 string | number
// a.hots ❌ 不存在的 key 也不报错(索引签名吞掉了)
// ② as 断言:啥也不校验
const b = { host: "localhost", port: 8080 } as Config;
// ③ satisfies:既校验又保留精确类型
const c = { host: "localhost", port: 8080 } satisfies Config;
c.port.toFixed(); // ✅ port 精确推断为 number
// c.hots ❌ 不存在的 key 立刻报错| 维度 | const x: T = {...} | {...} as T | {...} satisfies T |
|---|---|---|---|
是否校验是否满足 T | ✅ | ❌(只要勉强相关) | ✅ |
| 变量最终类型 | T(丢精度) | T(丢精度) | 字面量推断结果 |
| 多余属性检查 | ✅ | ❌ | ✅ |
keyof 能拿到具体 key | ❌ | ❌ | ✅ |
ts
// 高频实战一:路由表 —— 既要校验结构,又要 keyof 拿到精确路由名
const routes = {
home: { path: "/", title: "首页" },
detail: { path: "/detail/:id", title: "详情" },
} satisfies Record<string, { path: string; title: string }>;
type RouteName = keyof typeof routes; // 'home' | 'detail'(标注写法只会得到 string)
// 高频实战二:主题色 —— 校验是合法颜色键,同时保留字面量
const theme = {
primary: "#1677ff",
danger: "#ff4d4f",
} satisfies Record<string, `#${string}`>;
type Color = (typeof theme)[keyof typeof theme]; // '#1677ff' | '#ff4d4f'
// 高频实战三:替代 as const + 二次校验
const methods = ["GET", "POST"] satisfies readonly string[];
// 想同时冻结字面量就串起来写:
const methods2 = ["GET", "POST"] as const satisfies readonly string[];一句话选型: 需要「校验 + 保精度」用
satisfies;需要「把变量真的变成某个宽类型」(比如要往里塞别的 key)用标注;as只在「我确实比编译器知道得多」时用。注意satisfies是纯编译期语法,和类型一起被擦除。
11. 标称类型(Branded Types)与 unique symbol
问题来源: 结构类型系统下,type UserId = string 和 type OrderId = string 完全可以互相赋值,编译器帮不了你。真实事故里「把订单号传进了用户详情接口」这种 bug 类型系统一个都抓不住。
ts
type UserId = string;
type OrderId = string;
declare function getUser(id: UserId): void;
const orderId: OrderId = "O-123";
getUser(orderId); // ✅ 编译通过 —— 但这就是线上事故解法:给类型打一个「品牌」标记,让结构不再相同。
ts
// ① 最常用:交叉一个带 __brand 的虚拟字段
declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
// 构造只能走一个「校验 + 断言」的工厂函数(唯一合法入口)
function toUserId(s: string): UserId {
if (!/^U-\d+$/.test(s)) throw new Error("invalid userId");
return s as UserId; // 断言收口在这一处
}
declare function getUser(id: UserId): void;
// getUser('U-1'); ❌ 裸 string 不行,必须过工厂
// getUser(toOrderId('O-1')); ❌ OrderId 和 UserId 不再兼容
getUser(toUserId("U-1")); // ✅
// UserId 依然能当 string 用(交叉类型保留了原始能力)
const upper = toUserId("U-1").toUpperCase(); // ✅| 实现方式 | 写法 | 特点 |
|---|---|---|
unique symbol 品牌字段 | T & { [brand]: 'X' } | 最推荐:字段不可能被意外写出来,产物零开销 |
| 字符串字面量品牌字段 | T & { __brand: 'X' } | 简单,但手写 { __brand: 'X' } 就能伪造 |
enum 做品牌 | T & { __brand: MyEnum.X } | 会产生运行时代码 |
private 成员的 class | class UserId { private _: void } | 真正的名义类型,但必须 new,有运行时成本 |
ts
// ② 实用场景:单位、已校验的数据、未转义的字符串
type Meters = Brand<number, "Meters">;
type Seconds = Brand<number, "Seconds">;
declare function speed(d: Meters, t: Seconds): number;
// speed(100 as Seconds, 10 as Meters); ❌ 单位传反了,编译期就拦住
type SafeHTML = Brand<string, "SafeHTML">; // 只有过了 sanitize 才能拿到
type Email = Brand<string, "Email">; // 只有过了正则校验才能拿到
// ③ unique symbol 本身的规则:只能用在 const / readonly static 声明上
declare const S1: unique symbol;
const S2: unique symbol = Symbol("k"); // ✅ 必须是 const
// let S3: unique symbol = Symbol(); ❌ let 不行
// symbol 当 key 时,类型层面也是精确的
const KEY: unique symbol = Symbol("key");
interface Store {
[KEY]: number;
}三条落地经验: ① 品牌类型的断言必须收口在工厂函数里,否则到处
as UserId等于没做;② 品牌字段是「幽灵类型」,运行时不存在,JSON.stringify不会多出字段,零成本;③ 不要给所有 id 都打品牌,只给容易混且混了会出事的(用户/订单/租户 id、金额单位、已转义内容)打,否则到处要转换反而降低可读性。
12. 泛型推断机制:候选类型、字面量宽化、const 泛型与 NoInfer
调用 fn(arg)
│
▼
① 从每个参数位置收集「推断候选」 ← 同一个 T 出现多次会收集到多个候选
│
▼
② 合并候选:协变位取【最佳公共超类型】,逆变位取【交叉】
│
▼
③ 字面量宽化(Widening):'abc' → string、1 → number
除非:有 extends 约束指向字面量 / as const / const 泛型参数
│
▼
④ 校验是否满足 extends 约束 → 不满足则报错(不是回退默认值)
│
▼
⑤ 用推断结果做上下文类型(Contextual Typing),回填回调参数类型ts
// ① 多候选合并:协变位取公共超类型
declare function pair<T>(a: T, b: T): T[];
const r1 = pair(1, "a"); // T 推断为 string | number?
// 实际:TS 会报错或推成 string | number,取决于版本与是否有联合候选
const r2 = pair<string | number>(1, "a"); // ✅ 显式传最稳
// ② 字面量宽化:这是「配置对象类型丢精度」的根因
const s1 = "abc"; // 'abc'(const 不宽化)
let s2 = "abc"; // string(let 会宽化)
const obj = { k: "abc" }; // { k: string } —— 属性位置仍然宽化!
const obj2 = { k: "abc" } as const; // { readonly k: 'abc' }
// ③ 用 extends 约束「锚住」字面量
declare function tag1<T extends string>(v: T): T;
const t1 = tag1("abc"); // 'abc' ✅ 约束指向 string 时不宽化
declare function tag2(v: string): string;
const t2 = tag2("abc"); // string(没有泛型就没法保精度)
// ④ const 类型参数(TS 5.0):让传入的对象/数组整体按 as const 推断
declare function routes1<T extends readonly string[]>(v: T): T;
declare function routes2<const T extends readonly string[]>(v: T): T;
const a1 = routes1(["/a", "/b"]); // string[]
const a2 = routes2(["/a", "/b"]); // readonly ['/a', '/b'] ✅ 调用方不用写 as const
// ⑤ 上下文类型:回调参数不用标注,就是靠它
[1, 2, 3].map((n) => n.toFixed()); // n 自动是 number
declare function on<K extends keyof WindowEventMap>(
k: K,
cb: (e: WindowEventMap[K]) => void
): void;
on("click", (e) => e.clientX); // e 自动是 MouseEventts
// ⑥ NoInfer(TS 5.4):阻止某个位置参与推断,解决「被调用方带歪」
declare function createState1<T>(values: T[], initial: T): T;
createState1(["a", "b"], "c"); // ✅ 通过了!T 被第二个参数带成 'a'|'b'|'c'
declare function createState2<T>(values: T[], initial: NoInfer<T>): T;
// createState2(['a', 'b'], 'c'); ❌ T 只由第一个参数决定,'c' 不在其中
// 低版本可以手写等价物
type NoInferHack<T> = [T][T extends any ? 0 : never];| 推断「不如预期」的典型症状 | 原因 | 解法 |
|---|---|---|
字面量变成 string/number | 字面量宽化 | as const / T extends string / const T |
数组变成 string[] 而非元组 | 数组字面量默认推成数组 | as const / const T extends readonly... |
T 被某个参数「带宽」了 | 多候选合并取了公共超类型 | NoInfer<T> 或显式传入泛型参数 |
泛型函数当值传递后变 unknown | 泛型未实例化,ReturnType 拿不到 | 显式实例化,或改写成高阶泛型 |
回调参数变成 any | 上下文类型链断了(中间过了变量) | 让回调字面量直接写在调用处 |
一条实用判断: 推断出问题时先问「这个类型是从哪个参数位置推出来的?」。把那个位置用
as const锚住、用约束收紧、或用NoInfer排除掉,99% 的推断问题都能解决,不需要到处as。
13. 函数重载、实现签名与它的替代方案
ts
// 重载:N 个【声明签名】 + 1 个【实现签名】
function parse(v: string): object; // 声明 1
function parse(v: object): string; // 声明 2
function parse(v: string | object): object | string {
// 实现签名:对外不可见!
return typeof v === "string" ? JSON.parse(v) : JSON.stringify(v);
}
const o = parse("{}"); // object ✅ 命中声明 1
const s = parse({}); // string ✅ 命中声明 2
// parse(1); ❌ 没有匹配的重载| 规则 | 说明 |
|---|---|
| 匹配顺序 | 自上而下,第一个匹配的就生效 → 把更具体的签名写在前面 |
| 实现签名 | 对调用方完全不可见,只用来约束实现体,必须兼容所有声明签名 |
infer 提取 | ReturnType 只能拿到最后一个声明签名,拿不到全部重载 |
| 联合入参 | 传联合类型实参时不会自动匹配多个重载,会直接报错 |
| 可选参数 vs 重载 | 只是参数可省略就用 ?,返回值随参数变化才用重载 |
ts
// ❌ 重载的两个经典坑
function f(a: string): 1;
function f(a: number): 2;
function f(a: any) {
return 1 as any;
}
declare const u: string | number;
// f(u); ❌ 联合实参不会「分发」到多个重载
type RT = ReturnType<typeof f>; // 2 —— 只拿到最后一个声明签名ts
// ✅ 替代方案一:泛型 + 条件返回类型(能被 infer 正确提取)
declare function parse2<T extends string | object>(
v: T
): T extends string ? object : string;
// ✅ 替代方案二:可变参数元组(参数个数随条件变化,比重载简洁)
type EmitArgs<P> = P extends void ? [] : [payload: P];
declare function emit<P>(type: string, ...args: EmitArgs<P>): void;
// ✅ 替代方案三:判别联合入参(最可读,推荐)
type Input = { kind: "str"; v: string } | { kind: "obj"; v: object };
declare function parse3(i: Input): unknown;选型结论: 重载适合对外 API 的少量固定形态(如
addEventListener),写法直观、报错友好;但它不可被类型运算提取、不支持联合实参,所以内部工具函数优先用「泛型 + 条件返回类型」或「判别联合」。类里的方法同样支持重载,构造函数也可以重载(constructor多签名 + 一个实现)。
14. enum 的完整机制:反向映射、const enum 与 as const 替代
ts
// ① 数字枚举:编译产物是对象 + 【反向映射】
enum Status {
Pending, // 0
Success, // 1
Failed = 10, // 10(手动赋值后,后续继续递增)
Timeout, // 11
}js
// 编译产物(关键:k[k[v]=n]=v 双向写入)
var Status;
(function (Status) {
Status[(Status["Pending"] = 0)] = "Pending";
Status[(Status["Success"] = 1)] = "Success";
Status[(Status["Failed"] = 10)] = "Failed";
Status[(Status["Timeout"] = 11)] = "Timeout";
})(Status || (Status = {}));
// 所以:Status.Pending === 0 且 Status[0] === 'Pending'ts
// ② 字符串枚举:没有反向映射!
enum Lang {
ZH = "zh-CN",
EN = "en-US",
}
// Lang['zh-CN'] ❌ undefined,字符串枚举是单向的
// ③ 数字枚举的类型安全漏洞(最常被面试追问)
enum Code {
A = 1,
B = 2,
}
const c: Code = 999; // ✅ TS 5.0 之前不报错!数字枚举可接受任意 number
const l: Lang = "zh-CN" as Lang; // 字符串枚举则严格,必须是成员
// ④ 枚举成员作为类型(字面量枚举类型)
function only(s: Status.Success) {}
// only(Status.Failed); ❌ 单个成员也能当类型用
// ⑤ 异构枚举(混合数字和字符串)—— 能编译但不要用
enum Bad {
No = 0,
Yes = "YES",
}| 写法 | 运行时产物 | 反向映射 | 可做 key 遍历 | isolatedModules 兼容 | 适用场景 |
|---|---|---|---|---|---|
数字 enum | 一个对象 | ✅ 有 | ✅(需过滤) | ✅ | 需要「值 ↔ 名」互查 |
字符串 enum | 一个对象 | ❌ 无 | ✅ | ✅ | 日志/埋点里要可读值 |
const enum | 无(内联字面量) | ❌ | ❌ | ❌ 冲突 | 极致体积,且非 babel 编译 |
as const 对象 | 一个对象 | ❌ | ✅ | ✅ | 推荐默认选择 |
| 联合字面量类型 | 无 | ❌ | ❌ | ✅ | 纯类型约束,零运行时 |
ts
// ⑥ const enum:调用处被直接内联,没有运行时对象
const enum Dir {
Up = 1,
}
const d = Dir.Up; // 编译为 var d = 1 /* Up */;
// ⚠️ 坑一:isolatedModules / babel / esbuild / swc 下单文件编译拿不到跨文件枚举值 → 报错或行为不一致
// ⚠️ 坑二:声明在 .d.ts 里的 const enum,被 --preserveConstEnums 之外的消费方引用会炸
// ⚠️ 坑三:不能被动态索引访问
// ✅ 工程推荐:as const 对象 + 派生类型,兼顾运行时可用与类型精确
const Status2 = {
Pending: "pending",
Success: "success",
Failed: "failed",
} as const;
type Status2 = (typeof Status2)[keyof typeof Status2]; // 'pending'|'success'|'failed'
const values = Object.values(Status2); // ✅ 直接可遍历,无需过滤反向映射
// ❌ 数字枚举遍历必须过滤掉反向映射的 key
Object.keys(Status).filter((k) => isNaN(Number(k))); // ['Pending','Success',...]结论:
enum的唯一不可替代能力是数字枚举的反向映射;其余场景as const对象 +keyof typeof派生联合类型更优——tree-shaking 友好、与isolatedModules/babel 全兼容、Object.values直接可用。团队新代码建议统一用as const,老枚举不动。
15. 模块解析、.d.ts 与 Project References
import './utils' → moduleResolution 决定「去哪找文件」
├─ classic 已废弃
├─ node10 传统 CJS:node_modules 逐级上找 + main 字段
├─ node16 支持 package.json exports / imports + ESM 必须写后缀
└─ bundler 支持 exports,但允许省略后缀(vite/webpack 推荐)
│
▼
找到 .ts / .tsx → 直接用
只有 .js → 去找同名 .d.ts / types 字段
都找不到 → TS2307 Cannot find modulemoduleResolution | 读 exports 字段 | 相对导入要写后缀 | 推荐搭配 |
|---|---|---|---|
node10(旧 node) | ❌ | ❌ | 纯 CJS 老项目 |
node16/nodenext | ✅ | ✅ 必须 .js | Node ESM 库 |
bundler | ✅ | ❌ 可省略 | vite / webpack 前端项目 |
json
// ① 现代库的 package.json:types 必须放在每个条件的最前面
{
"name": "@my/sdk",
"types": "./dist/index.d.ts", // 兜底,给 node10 看
"exports": {
".": {
"types": "./dist/index.d.ts", // ⚠️ 必须第一个,否则 node16 解析不到类型
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./theme": {
"types": "./dist/theme.d.ts",
"default": "./dist/theme.css"
}
}
}ts
// ② 三种「补类型」手段,别混用
// 2.1 给无类型的 npm 包兜底(any,最后手段)
declare module "untyped-lib";
// 2.2 模块扩展:给已有模块【追加】成员(必须在模块文件内,有 import/export)
import "vue-router";
declare module "vue-router" {
interface RouteMeta {
requiresAuth?: boolean; // ✅ 合并进原 interface
title?: string;
}
}
// 2.3 全局扩展:给 window / globalThis 加字段
declare global {
interface Window {
__APP_VERSION__: string;
}
var __DEV__: boolean;
}
// 2.4 非代码资源声明(vite 项目常见)
declare module "*.svg" {
const src: string;
export default src;
}json
// ③ Project References:monorepo 里让包之间按「已构建的 d.ts」互相引用
// packages/shared/tsconfig.json
{
"compilerOptions": {
"composite": true, // 必开:生成 .d.ts + .tsbuildinfo
"declaration": true,
"declarationMap": true, // 让「跳转定义」落到 .ts 源码而不是 d.ts
"rootDir": "src",
"outDir": "dist"
}
}json
// packages/web/tsconfig.json
{
"references": [{ "path": "../shared" }], // ✅ 依赖声明
"compilerOptions": { "module": "ESNext", "moduleResolution": "bundler" }
}
// 构建:tsc --build(增量,只重编变更的包)| 常见报错 | 真实原因 | 解法 |
|---|---|---|
TS2307 Cannot find module | 没 .d.ts,或 exports 里没配 types | 装 @types/x,或补 declare module |
| 包有类型但 IDE 读不到 | moduleResolution 是 node10,读不到 exports | 改 bundler / node16 |
declare module 扩展不生效 | 文件里没有 import/export,被当成全局脚本 | 加一行 import 'x',或移到 declare global |
monorepo 跳转定义跳到 d.ts | 没开 declarationMap | 开启,或配 paths 直指源码 |
tsc --build 不生效 | 被引用的包没开 composite | 每个被引用包都加 composite: true |
--isolatedDeclarations 报错(TS 5.5) | 导出成员的类型需要推断才能确定 | 给导出函数/变量显式标注返回类型 |
16. 装饰器:legacy 实验版 vs TS 5.0 标准装饰器
| 维度 | legacy 装饰器 | 标准装饰器(TS 5.0+ / Stage 3) |
|---|---|---|
| 开启方式 | experimentalDecorators: true | 默认开启(不要开 experimental) |
| 规范阶段 | 旧提案,已废弃 | TC39 Stage 3,未来原生 |
| 方法装饰器签名 | (target, key, descriptor) | (value, context) |
| 属性装饰器 | 只能拿到 target/key,拿不到也改不了值 | 返回 initializer 函数,可改初始值 |
| 参数装饰器 | ✅ 支持 | ❌ 不支持(Stage 3 还没纳入) |
emitDecoratorMetadata | ✅ 支持(Nest/TypeORM 依赖) | ❌ 不支持,改用 context.metadata |
| 执行顺序 | 同一成员自下而上 | 同样自下而上,但类装饰器时机不同 |
| 典型生态 | Angular / NestJS / TypeORM / MobX 旧版 | 新项目、无 DI 需求的场景 |
ts
// ① legacy 方法装饰器:改写 descriptor.value
function logLegacy(target: any, key: string, desc: PropertyDescriptor) {
const origin = desc.value;
desc.value = function (...args: any[]) {
console.log(`[call] ${key}`, args);
return origin.apply(this, args);
};
return desc;
}
class A1 {
@logLegacy
sum(a: number, b: number) {
return a + b;
}
}ts
// ② 标准装饰器:签名完全不同,拿 context 而不是 descriptor
function log<T extends (this: any, ...a: any[]) => any>(
value: T,
ctx: ClassMethodDecoratorContext
) {
return function (this: any, ...args: any[]) {
console.log(`[call] ${String(ctx.name)}`, args);
return value.apply(this, args);
} as T;
}
class A2 {
@log
sum(a: number, b: number) {
return a + b;
}
}ts
// ③ 标准装饰器的四类 context(关键差异点)
type Ctx =
| ClassDecoratorContext // { kind:'class', name, addInitializer, metadata }
| ClassMethodDecoratorContext // + static / private / access
| ClassFieldDecoratorContext // 返回值是 (initial) => newInitial
| ClassAccessorDecoratorContext; // get/set 包装
// 字段装饰器:legacy 做不到的「改初始值」
function upper(_: any, ctx: ClassFieldDecoratorContext) {
return (initial: string) => initial.toUpperCase();
}
class B {
@upper name = "joycode"; // 实例化后 name === 'JOYCODE'
}
// addInitializer:把逻辑推迟到实例构造阶段(自动绑定 this 的标准写法)
function bound(value: Function, ctx: ClassMethodDecoratorContext) {
ctx.addInitializer(function (this: any) {
this[ctx.name] = value.bind(this);
});
}ts
// ④ 装饰器工厂与执行时机(两段式,面试高频)
function Factory(opt: string) {
console.log("1. 工厂执行:求值参数时,自上而下");
return function (..._a: any[]) {
console.log("2. 装饰器应用:自下而上");
};
}
class C {
@Factory("a")
@Factory("b")
m() {}
}
// 输出顺序:工厂 a → 工厂 b → 装饰器 b → 装饰器 a
// 口诀:【工厂自上而下求值,装饰器自下而上应用】ts
// ⑤ emitDecoratorMetadata:NestJS 依赖注入的底层
// tsconfig: { experimentalDecorators: true, emitDecoratorMetadata: true }
@Injectable()
class Svc {
constructor(private repo: UserRepo) {} // ← 没写 @Inject 也能注入
}
// 编译后多出一行:
// Reflect.metadata('design:paramtypes', [UserRepo])
// ⚠️ 依赖 reflect-metadata polyfill;
// ⚠️ 只对 class 类型有效,interface / 联合类型会退化成 Object;
// ⚠️ 标准装饰器无此能力 → 所以 Nest/TypeORM 目前仍必须用 legacy| 常见坑 | 原因与解法 |
|---|---|
| 两套装饰器混用报类型错 | experimentalDecorators 一开就全局切到 legacy,不能并存 |
| 箭头函数属性装饰不上 | 它是实例字段不是原型方法,legacy 下 descriptor 为 undefined |
vite/esbuild 下 emitDecoratorMetadata 失效 | esbuild 不支持该选项 → 用 @swc/core 或 vite-plugin-swc |
| 装饰器里拿不到实例属性 | legacy 装饰器在类定义时执行,实例还不存在 → 用 addInitializer |
| Nest 项目升级 TS 5 后注入全坏 | 误删了 experimentalDecorators → 保留 legacy 配置即可 |
选型结论: 用 Nest / TypeORM / Angular 就继续 legacy +
emitDecoratorMetadata(标准装饰器没有参数装饰器和元数据,替代不了 DI);自研的日志/缓存/节流类装饰器,新项目直接用标准装饰器,不依赖 polyfill 且未来可平滑过渡到原生。
❓ 高频面试题【基础简答 + 连环追问链 + 精简口述答案 + 项目关联话术】
题 1:type 和 interface 有什么区别?实际项目怎么选?
精简口述答案:
最本质的区别是:
interface是「声明」,type是「别名」。interface声明的是一个具名的对象类型,可以重复声明并自动合并,也能被其他库扩展;type只是给一个已有类型起名字,重复定义会直接报错。由此衍生出几个差异:interface只能描述对象和函数,type可以描述联合、交叉、元组、原始类型、模板字面量,条件类型和映射类型也只能写在type上;继承方式上interface extends遇到同名不兼容属性会直接报错,而type的&会把冲突属性合成never,错误被推迟到使用时才暴露,更难排查。性能上interface extends会创建一个可缓存的具名类型,大型项目里比反复展开交叉类型更快。我的选型原则是:对外暴露的数据结构、组件 props、需要被扩展的类型用interface;联合类型、工具类型、任何需要类型运算的场景用type。
连环追问链:
- 追问:声明合并有什么实际用途?
- 答:主要是扩展第三方库的类型。比如给 Vue 实例加全局属性要
declare module 'vue'再扩展ComponentCustomProperties;给window加字段要扩展interface Window;给 Express 的Request加user字段也是靠合并。这些场景type完全做不到。反过来说,如果你想禁止别人篡改你的类型,用type更安全。
- 答:主要是扩展第三方库的类型。比如给 Vue 实例加全局属性要
- 追问:
interface extends和type &有同名属性时分别会怎样?- 答:
interface A extends B时如果A和B的同名属性类型不兼容,声明处就报错;type A = B & { ... }不报错,但该属性会变成string & number,也就是never,赋值时才发现「什么都塞不进去」。所以我更倾向interface extends,让错误尽早暴露。
- 答:
- 追问:
type能实现继承吗?interface能继承type吗?- 答:
type用交叉&模拟继承。interface可以extends一个type,前提是这个 type 是对象字面量类型或它们的交叉;如果是联合类型就不行,因为 interface 的成员必须是静态可知的。
- 答:
- 追问:为什么说
interface在大型项目里性能更好?- 答:
interface创建的是具名的、带缓存的类型引用,TS 在检查赋值兼容性时可以走「同一个引用直接通过」的快路径;而交叉类型每次都可能需要展开合并成员。几十个&串起来的类型是编辑器卡顿的常见原因,这时候用Prettify<T>展开或者改写成interface extends都有帮助。
- 答:
- 追问:什么时候必须用
type?- 答:联合类型
type Status = 'a' | 'b'、元组type P = [number, number]、给原始类型起别名、映射类型{ [K in keyof T]: ... }、条件类型T extends U ? X : Y、模板字面量类型 —— 这些interface都写不了。
- 答:联合类型
✅ 项目关联话术(按真实经历替换):
我们团队的约定是:API 的请求/响应模型、组件 props 一律用
interface,内部的联合枚举和工具类型用type。这么定有两个实际原因:一是 API 模型是由后端 Swagger 自动生成的,生成的interface可以在业务侧通过声明合并补充前端专用字段,不用改生成代码;二是我们之前有个页面用了七八层&交叉的 props 类型,VSCode 悬浮提示要转三秒,改成interface extends之后恢复正常。另外我封装了Prettify<T>放在全局类型里,在需要给同事看清结构的地方套一层。
题 2:讲讲条件类型和 infer,什么是分布式条件类型?
精简口述答案:
条件类型就是类型层面的三元表达式
T extends U ? X : Y,这里的extends不是继承,而是判断「T 是否可以赋值给 U」。infer用来在extends右侧声明一个待推断的类型变量,让 TS 自己做模式匹配并把匹配到的部分交给我们用,ReturnType、Parameters、Awaited全是靠它实现的。注意infer只能出现在extends的右侧,写在左边或者条件类型之外都会报错。分布式条件类型指的是:当被检查的类型是裸露的泛型参数,且传入的是联合类型时,条件类型会自动拆开联合、逐个判断、再把结果合并回联合,Exclude、Extract、NonNullable都依赖这个特性。想关闭分发就把两边都用元组包起来,写成[T] extends [U]。另外有两个高频陷阱:传入never时因为 never 是空联合,分发会直接短路返回never;传入boolean会被当成true | false拆开分发。
连环追问链:
- 追问:分布式条件类型生效需要哪些条件?
- 答:三个条件缺一不可 —— ① 被检查的类型是裸类型参数(不能写成
T[]、[T]、{ a: T });② 类型参数实际接收的是联合类型;③ 用的是条件类型语法。另外对any做分发会同时走两个分支返回联合,这是个常被忽略的特例。
- 答:三个条件缺一不可 —— ① 被检查的类型是裸类型参数(不能写成
- 追问:
Exclude<T, U>为什么能"删掉"成员?- 答:
type Exclude<T, U> = T extends U ? never : T。联合被拆成一个个成员分别判断,命中的返回never,没命中的返回自身,最后合并时never在联合里会被自动吸收掉,所以看起来就是「删除」。
- 答:
- 追问:
infer在同一个位置出现多次会怎样?- 答:如果多个
infer用了同一个变量名,在协变位置(比如返回值、对象属性)会取联合,在逆变位置(函数参数)会取交叉。UnionToIntersection这个工具类型就是利用后者实现的:先把联合变成一堆函数参数位,再用一个infer R收口得到交叉类型。
- 答:如果多个
- 追问:
T extends U里的extends和 class 的extends一样吗?- 答:完全不一样。条件类型里的
extends判断的是可赋值性,所以'a' extends string为真,{a:1,b:2} extends {a:number}也为真(鸭子类型 + 属性多的可以赋给属性少的)。而类的extends是真实的原型继承,有运行时行为。
- 答:完全不一样。条件类型里的
- 追问:
infer配合extends约束怎么写?- 答:TS 4.7 起支持
infer R extends string这种带约束的推断,不满足约束就走 false 分支。常用在解析数字字符串场景,比如S extends `${infer N extends number}` ? N : never可以把'42'直接推成数字字面量42。
- 答:TS 4.7 起支持
- 追问:
infer和泛型参数到底什么关系?- 答:泛型参数是外部传进来的,由调用方决定;
infer是编译器自己推出来的,从已有结构里把隐藏的类型挖出来,不需要外部传。作用域也不同:泛型参数作用于整个类型声明,infer只在当前条件类型的两个分支里可见。一个extends右侧可以同时写多个infer,比如T extends (...a: infer P) => infer R ? [P, R] : never一次拿到参数和返回值。
- 答:泛型参数是外部传进来的,由调用方决定;
✅ 项目关联话术(按真实经历替换):
我们项目里把所有接口请求函数集中注册成一个对象,然后用
infer从中反推出类型:type ApiParams<K extends keyof Api> = Api[K] extends (p: infer P) => any ? P : never,再配合Awaited<ReturnType<Api[K]>>拿到响应类型。这样业务里写request('user/list', params)时,第二个参数和返回值都能自动推导,接口一改类型立刻全链路飘红。还有一个场景是权限码:后端返回的权限字符串形如'order:list:export',我用模板字面量 +infer拆成模块、页面、操作三段做联合校验,避免手写字符串拼错。
题 3:Partial、Pick、Omit 这些工具类型底层是怎么实现的?
精简口述答案:
它们都建立在映射类型 + keyof + 索引访问这三件事上。
Partial<T>是{ [K in keyof T]?: T[K] },遍历所有 key 加上可选修饰符;Required<T>用-?去掉可选;Readonly<T>加readonly,对应的-readonly可以去掉只读。Pick<T, K extends keyof T>是把遍历范围从keyof T换成传入的K;Omit<T, K>则是Pick<T, Exclude<keyof T, K>>,先用分布式条件类型把要排除的 key 删掉再 Pick —— 注意Omit的第二个参数没有约束在 keyof T 上,写错 key 不会报错,这是它和Pick很不一样的地方。Record<K, V>是{ [P in K]: V },用来构造字典。还有 TS 4.1 的as重映射,可以在遍历时改写 key 名或者返回never把 key 过滤掉,Getters、OmitByType这类工具就靠它。要补充一点:Uppercase、Lowercase、Capitalize、Uncapitalize是编译器内置的 intrinsic 实现,无法用 TS 语法手写。
连环追问链:
- 追问:
Partial有什么坑?- 答:它是浅层的,嵌套对象不会变可选,所以表单默认值这类场景要自己写
DeepPartial。另外开了exactOptionalPropertyTypes之后,Partial产生的可选属性不等于「可以显式传 undefined」,需要写成T[K] | undefined。还有Partial会让所有字段都能省略,对「至少要传一个字段」的更新接口不够精确,这时要用AtLeastOne这类自定义工具。
- 答:它是浅层的,嵌套对象不会变可选,所以表单默认值这类场景要自己写
- 追问:
Omit和Exclude容易混,它们区别是什么?- 答:
Exclude作用在联合类型上,做的是「从联合里删成员」;Omit作用在对象类型上,做的是「从对象里删属性」,内部才用到Exclude。同理Extract对应联合,Pick对应对象。
- 答:
- 追问:怎么实现一个"按值类型筛选属性"的工具类型?
- 答:用
as重映射 + 条件返回 never:type PickByType<T, V> = { [K in keyof T as T[K] extends V ? K : never]: T[K] }。重映射时 key 为never的会被直接丢弃,这是过滤 key 的标准手法。常见用途是从一个大 Model 里挑出所有函数属性或所有string字段。
- 答:用
- 追问:
keyof对数组、对any、对联合类型分别得到什么?- 答:对数组得到的是
number | 'length' | 'push' | ...所有数组方法名(所以要取索引应该用T[number]);keyof any是string | number | symbol;keyof (A | B)得到的是两者 key 的交集,而keyof (A & B)是并集,这点和直觉相反但很好理解 —— 联合类型只能访问共有属性。
- 答:对数组得到的是
- 追问:映射类型里的
homomorphic(同态)是什么意思?- 答:形如
{ [K in keyof T]: ... }这种直接遍历keyof T的叫同态映射,它会保留原有的可选性和只读修饰符,并且对数组/元组会映射成数组/元组而不是普通对象。一旦你把keyof T换成别的联合(比如先Exclude一下),就不再是同态的,修饰符会丢失 —— 这是自己手写 Omit 时很容易踩的坑。
- 答:形如
✅ 项目关联话术(按真实经历替换):
我们表单模块有「新增」和「编辑」两套接口,字段高度重叠。我的做法是只维护一份完整的
UserModel,新增用Omit<UserModel, 'id' | 'createdAt'>,编辑用Partial<Omit<UserModel, 'createdAt'>> & { id: number },列表项用Pick挑出展示字段。另外后端返回的字段是下划线的,我写了CamelKeys<T>配合as重映射做类型级转换,运行时则用一个统一的 transform 函数,这样接口字段一改,前端表单和列表的类型会同步报错,不会漏改。
题 4:any、unknown、never 怎么取舍?类型收窄有哪些手段?
精简口述答案:
any是关闭检查,它会沿着调用链传染,一个any能让下游几十个文件的类型保护全部失效,所以我把它当成应急逃生舱,并且在 ESLint 里开no-explicit-any做约束。unknown是安全版的 any:什么都能赋给它,但它不能直接用,必须先收窄,所以凡是「来自外部、形状不确定」的数据 ——JSON.parse的结果、catch到的错误、第三方 SDK 回调 —— 都应该先标成unknown再校验。never是底层类型,表示「不可能存在的值」,典型用途是 switch 的穷尽检查:在 default 分支里写const _: never = x,这样联合类型新增成员时编译期就会报错,强制你补上分支。收窄手段上,typeof管原始类型、instanceof管类实例、in管属性存在性、v is T封装复杂判断、asserts v is T能让收窄效果延续到后续语句。但工程上我最推荐判别联合,给每个变体加一个字面量kind或status字段,编译器能自动收窄,不需要任何断言。
连环追问链:
- 追问:为什么
catch (e)里的e不是Error?- 答:因为 JS 允许
throw任何值,throw 'str'、throw 42都合法。TS 4.4 之后开useUnknownInCatchVariables(strict 包含)e就是unknown,必须先e instanceof Error判断再用e.message。这其实是个好事,能逼着我们处理非 Error 抛出的情况。
- 答:因为 JS 允许
- 追问:什么是协变和逆变?为什么函数参数要逆变?
- 答:返回值位置是协变的(子类型可以赋给父类型),参数位置是逆变的(需要父类型才能赋给要求子类型的位置)。原因很直观:一个「能处理任意 Animal 的函数」可以安全地放在「需要处理 Dog 的回调」的位置上,反过来不行,因为处理 Dog 的函数遇到 Cat 会崩。不过 TS 为了兼容历史代码,用方法语法声明的参数是双变的,这是数组
push之类的 API 不报错的原因,开strictFunctionTypes只对函数属性语法生效。
- 答:返回值位置是协变的(子类型可以赋给父类型),参数位置是逆变的(需要父类型才能赋给要求子类型的位置)。原因很直观:一个「能处理任意 Animal 的函数」可以安全地放在「需要处理 Dog 的回调」的位置上,反过来不行,因为处理 Dog 的函数遇到 Cat 会崩。不过 TS 为了兼容历史代码,用方法语法声明的参数是双变的,这是数组
- 追问:
as断言和类型守卫的本质区别?- 答:
as是告诉编译器闭嘴,没有任何运行时校验,断言错了就是线上 crash;类型守卫是运行时真的做了判断,编译器基于判断结果收窄。所以as只应该用在「我确实比编译器知道得多」的场景,比如document.getElementById('x') as HTMLInputElement。另外v is T虽然是守卫,但函数体里逻辑写错编译器不会提醒,所以它仍然是一种「信任」。
- 答:
- 追问:
never还有哪些实际用处?- 答:① 穷尽检查;② 标记永不返回的函数(抛异常或死循环),能帮助控制流分析;③ 在
as重映射里过滤 key;④ 在条件类型里当「空结果」,因为never会被联合吸收;⑤ 用T extends never ? ...检测分发短路。
- 答:① 穷尽检查;② 标记永不返回的函数(抛异常或死循环),能帮助控制流分析;③ 在
- 追问:怎么在团队里限制
any的使用?- 答:ESLint 开
@typescript-eslint/no-explicit-any和no-unsafe-assignment系列规则,tsconfig 开noImplicitAny;对实在躲不开的地方要求写// eslint-disable-next-line并附原因注释,这样 code review 时能看到;另外统计any数量做成趋势看板,只允许下降不允许上升。
- 答:ESLint 开
✅ 项目关联话术(按真实经历替换):
我们之前线上出过一次事故:后端某个字段从对象改成了 null,前端那一层的返回值被标成了
any,整条链路都没报错,直接白屏。之后我做了两件事:一是把所有 HTTP 层的返回值从any改成unknown,在 response 拦截器里用类型守卫统一校验code/data/message结构,校验通过才转成业务泛型;二是把接口返回的可空字段老老实实标成T | null,配合strictNullChecks把所有没判空的地方全部暴露出来,一次性改掉了三十多处。现在新增字段改类型,编译期就能拦住。
题 5:泛型在实际项目里怎么用?聊聊你的 TS 工程实践
精简口述答案:
泛型的本质是类型的参数,让类型也能复用。项目里最高频的三个用法:第一是封装请求层,
function request<T>(url: string, data?: unknown): Promise<Res<T>>,调用处传入响应类型,整条链路的类型就通了,再配合Awaited<ReturnType<...>>从接口函数反推类型,避免手写两份;第二是封装通用组件,比如表格组件Table<T>的columns里dataIndex必须是keyof T,写错 key 直接编译报错;第三是约束泛型边界,用extends给泛型加约束、用=给默认值,比如<T extends { id: number } = any>。工程层面我的实践是:新项目直接strict: true起步;老项目渐进式迁移,先开noImplicitAny,再开strictNullChecks,用allowJs让 js/ts 共存,// @ts-expect-error替代@ts-ignore(前者在错误消失后会提醒你删掉);CI 里加tsc --noEmit卡点,因为 Vite/esbuild 只做转译不做类型检查,不加这一步类型错误会直接上线。
连环追问链:
- 追问:
tsc --noEmit为什么必须加进 CI?- 答:Vite、esbuild、SWC 这类工具为了速度只剥离类型、不做类型检查,所以本地编辑器飘红但构建依然成功。必须额外跑
tsc --noEmit(或vue-tsc --noEmit)才能真正拦住类型错误。大项目可以配incremental+tsBuildInfoFile加速,或者用--build搭配项目引用。
- 答:Vite、esbuild、SWC 这类工具为了速度只剥离类型、不做类型检查,所以本地编辑器飘红但构建依然成功。必须额外跑
- 追问:泛型默认值和约束能同时用吗?顺序是什么?
- 答:可以,写成
<T extends Base = Base>,约束在前默认值在后。注意默认值必须满足约束。另外多个泛型参数有默认值时,带默认值的要放在后面,和函数参数规则一样。
- 答:可以,写成
- 追问:怎么让函数的返回值类型依赖参数值?
- 答:用函数重载或条件返回类型。比如
function get<K extends keyof Config>(k: K): Config[K],返回值随传入的 key 变化。更复杂的用重载签名,或者 TS 5.0 的const类型参数<const T>让传入的字面量不被宽化成string。
- 答:用函数重载或条件返回类型。比如
- 追问:
import type和普通 import 有什么区别,为什么推荐?- 答:
import type明确标记这是只在类型层面使用的导入,转译时会被完整擦除,不会产生运行时 import。好处是:避免循环依赖、避免把只用于类型的模块打进产物、在isolatedModules/verbatimModuleSyntax下是必须的。
- 答:
- 追问:老项目从 JS 迁移 TS 的具体步骤?
- 答:① 加 tsconfig,开
allowJs+checkJs: false,让两种文件共存;② 先给工具函数、API 层这些「被依赖最多」的底层模块加类型,收益最大;③ 逐个文件改后缀,用@ts-expect-error临时压住,并在注释里登记 TODO;④ 分阶段打开严格选项,每打开一个就修一轮;⑤ 全程保持tsc --noEmit在 CI 里绿灯,避免回退。
- 答:① 加 tsconfig,开
✅ 项目关联话术(按真实经历替换):
我在团队里主导过一次 TS 严格化改造。改造前项目虽然是 .ts 文件,但
strict是关的,到处是隐式 any,类型形同虚设。我的做法是分四个迭代:第一个迭代开noImplicitAny,大约 200 处报错,主要是回调参数和解构;第二个迭代开strictNullChecks,这个最痛,有 400 多处,顺手修掉了十几个真实的空指针隐患;第三个迭代统一 API 层泛型,把any全换成unknown+ 运行时校验;第四个迭代在 CI 加tsc --noEmit和no-explicit-any规则锁死收益。改完之后,同类型的线上空值报错基本消失了,新同学接手业务时靠类型提示就能摸清数据结构,不用再翻接口文档。
题 6:satisfies 解决了什么问题?它和类型标注、as 怎么选?
精简口述答案:
这三者的区别可以用一句话概括:类型标注是「向下取整」,
as是「强行改写」,satisfies是「只校验不改写」。写const config: Config = {...}时,变量类型被拉宽成Config,你自己写的字面量精度全丢了 —— 比如theme: 'dark'会变成string,Object.keys拿到的也只是string[],没法做精确的 key 推导。写as Config更糟,它不仅不保精度,还会跳过检查,拼错属性名、少写必填字段都不报错。satisfies是 TS 4.9 加的,它会用Config去校验这个字面量(少字段、拼错、类型不符都报错),但变量最终的类型保持字面量推断出来的那个最窄的类型。所以配置表、路由表、主题色、枚举映射这类「既要校验结构、又要保留 key 和值的字面量精度」的场景,satisfies是唯一正解。再叠一层as const satisfies Config,还能同时拿到readonly和深层字面量。
连环追问链:
- 追问:能举个「类型标注丢精度」导致实际问题的例子吗?
- 答:最典型的是路由表。写成
const routes: Record<string, RouteItem> = {...}之后,keyof typeof routes就是string,导航函数push(name)的参数没法约束,拼错路由名要到运行时才发现。改成const routes = {...} satisfies Record<string, RouteItem>,keyof typeof routes就是'home' | 'detail' | ...的精确联合,push的参数直接有自动补全,拼错当场报错。
- 答:最典型的是路由表。写成
- 追问:
satisfies能用在函数参数或返回值上吗?- 答:不能,它是表达式级别的操作符,只能写在表达式右边,不能出现在参数位置或类型注解位置。函数返回值想要同样效果,得在函数体里写
return {...} satisfies T,或者用泛型 +const类型参数。
- 答:不能,它是表达式级别的操作符,只能写在表达式右边,不能出现在参数位置或类型注解位置。函数返回值想要同样效果,得在函数体里写
- 追问:
satisfies和泛型约束<T extends Config>(c: T) => T有什么关系?- 答:目的一样 —— 都是「校验但保留精度」。区别在于泛型方案需要包一层函数(社区早期的
defineConfig<T extends Config>(c: T): T就是干这个的),而satisfies是语言原生支持,不用额外运行时函数。所以 TS 4.9 之后,很多库的defineXxx辅助函数其实可以退化成satisfies。
- 答:目的一样 —— 都是「校验但保留精度」。区别在于泛型方案需要包一层函数(社区早期的
- 追问:
as const satisfies X和satisfies X as const哪个对?- 答:
as const satisfies X是对的。as const先把字面量锁成最窄的只读类型,再用X去校验它。反过来写会先校验再as const,而satisfies的结果已经是确定类型,再as const没有意义甚至会报错。
- 答:
- 追问:什么情况下仍然应该用类型标注而不是
satisfies?- 答:当你就是希望变量类型被拉宽的时候。比如一个会被重新赋值的变量
let current: Theme = 'dark',后面要赋别的值,用satisfies推出来的是'dark'字面量,再赋值就报错了。另外对外导出的 API 对象,刻意用标注收口成稳定的公共类型,也能避免实现细节泄漏到类型里。
- 答:当你就是希望变量类型被拉宽的时候。比如一个会被重新赋值的变量
✅ 项目关联话术(按真实经历替换):
我们项目有一份埋点配置,结构是
{ 页面key: { event: string; params: string[] } }。以前写的是类型标注,结果埋点上报函数track(pageKey, event)的两个参数都只能是string,运营同学提需求加了页面但漏改一处,线上埋点丢了一周才发现。后来我改成satisfies TrackConfig,再用keyof typeof trackConfig和typeof trackConfig[K]['event']把track的参数类型关联起来,现在页面 key 和事件名都有自动补全,漏改直接编译不过。同一套做法还用在了路由表和权限码映射上。
题 7:TS 是结构类型系统,怎么在它上面实现「名义类型」?
精简口述答案:
TS 判断两个类型兼容只看结构不看声明来源(鸭子类型),所以
type UserId = string和type OrderId = string在类型上是完全等价的,把订单 ID 传给需要用户 ID 的函数不会有任何报错 —— 这是业务上很容易出事的地方。要实现名义类型(Nominal Typing),标准做法是品牌化(Branded Types):给类型交叉上一个「只存在于类型层面、运行时不存在」的标记字段,比如type Brand<T, B> = T & { readonly __brand: B }。这样UserId和OrderId的结构就不同了,互相赋值会报错,但运行时它们仍然只是普通 string,零开销。再配套两个函数:一个toUserId(raw: string): UserId做校验 + 断言入口,一个类型守卫isUserId。更强的隔离可以用unique symbol当品牌 key,这样连手写同名字段都伪造不出来。另外还有个天然的名义特性:类里只要有private/protected成员,结构相同的两个类也不兼容,因为 TS 对私有成员做的是「同一声明」比较。
连环追问链:
- 追问:品牌字段用
string、unique symbol、declare const有什么差别?- 答:用普通字符串字段(
{ __brand: 'UserId' })最简单,但别人手写一个同名字段就能伪造,而且字段名会出现在 IDE 提示和keyof结果里。用declare const brand: unique symbol当 key({ [brand]: 'UserId' })外部拿不到这个 symbol,无法伪造,而且不污染keyof。如果品牌声明不导出,隔离性最强。
- 答:用普通字符串字段(
- 追问:品牌类型会不会影响运行时性能或打包体积?
- 答:完全不会。交叉的品牌字段只是类型层面的虚构属性,
declare const和type都会被完整擦除,编译产物里一个字节都不多。唯一的「成本」是必须通过构造函数/断言函数才能造出品牌值,这其实正是我们想要的约束。
- 答:完全不会。交叉的品牌字段只是类型层面的虚构属性,
- 追问:品牌类型怎么和接口返回的数据对接?后端给的就是 string。
- 答:在数据入口处统一转换。HTTP 层拿到
unknown之后做运行时校验,校验通过时用as UserId断言一次 —— 断言只在这一个收口点出现,下游全部是品牌类型。这个模式和 zod 的z.string().brand<'UserId'>()思路一致:运行时校验 + 编译期品牌,一次断言换来全链路安全。
- 答:在数据入口处统一转换。HTTP 层拿到
- 追问:除了 ID,品牌类型还有哪些实用场景?
- 答:① 单位,
Meters和Feet都是 number,混用会算错;② 安全性标记,SafeHTML必须经过sanitize()才能产生,从源头防 XSS;③ 校验状态,Email、NonEmptyString、PositiveInt这类「已校验」的语义;④ 货币/精度,Cents和Yuan区分开避免少乘 100。
- 答:① 单位,
- 追问:
private带来的名义性有什么坑?- 答:
private的名义比较是基于声明处的,所以两个长得完全一样、都带private x的类互相赋值会报错,这在「同一个类被打包了两份」(如 npm 依赖版本冲突)时会出现莫名其妙的类型不兼容。另外#x这种 ES 私有字段同样打破结构兼容,但它是真实的运行时私有,和private的「仅编译期」不同。
- 答:
✅ 项目关联话术(按真实经历替换):
我们订单系统里曾经有一个线上 bug:退款接口的参数顺序是
(orderId, userId),有同学调用时写反了,两个都是 string 类型,编译和 code review 都没拦住,导致一批退款落到了错误的账户上。复盘之后我引入了品牌类型,把OrderId、UserId、SkuId都用unique symbol品牌隔开,在 HTTP 响应解析层统一做一次断言转换。改完之后我特意把那个 bug 的调用写法再敲了一遍,编译器立刻报错。整个改造只动了类型和解析层,业务代码零改动,运行时产物也没有任何变化。
题 8:泛型是怎么被推断出来的?遇到推断不符合预期怎么排查?
精简口述答案:
TS 的泛型推断大致分五步:收集候选 → 合并候选 → 字面量宽化 → 校验约束 → 做上下文类型回填。同一个
T在多个参数位置出现时,每个位置都贡献一个候选,协变位置取「最佳公共超类型」,逆变位置取交叉;然后会做字面量宽化,'abc'变成string、[1,2]变成number[],这是「配置对象类型丢精度」最常见的根因 —— 除非有as const、有指向字面量的extends约束、或者用了 TS 5.0 的const类型参数。最后推出的类型还会作为上下文类型回填给回调函数的参数,这就是arr.map(n => ...)里n不用标注的原因。排查时我的固定思路是先问一句「这个类型到底是从哪个参数位置推出来的」:如果是被某个参数「带宽」了,用 TS 5.4 的NoInfer<T>把那个位置排除出推断;如果是字面量被宽化了,用as const或const T extends readonly ...锚住;如果回调参数变成了any,通常是上下文类型链在中间变量那里断了,把回调字面量直接写在调用处就好。实在推不出来就显式传泛型参数,不要到处as。
连环追问链:
- 追问:
NoInfer解决的是什么问题?没有它之前怎么办?- 答:它解决「某个参数把泛型带歪」的问题。比如
createState<T>(values: T[], initial: T),调用createState(['a','b'], 'c')本应报错,但'c'也贡献了候选,T被合并成'a'|'b'|'c'就通过了。把第二个参数改成NoInfer<T>后它只参与校验不参与推断。TS 5.4 之前社区用type NoInfer<T> = [T][T extends any ? 0 : never]这种「延迟求值」技巧,原理是让该位置变成不可推断的间接类型。
- 答:它解决「某个参数把泛型带歪」的问题。比如
- 追问:
const类型参数<const T>和调用处写as const有什么区别?- 答:效果基本等价,区别在于责任方。
as const要求每个调用方都记得写,漏写就丢精度;<const T>是库作者在声明处一次性解决,调用方正常写字面量就能得到只读元组。所以做工具库优先用const类型参数。注意它只对直接传入的字面量生效,传变量进去照样会宽化。
- 答:效果基本等价,区别在于责任方。
- 追问:为什么
const obj = { k: 'abc' }里的k还是string?const不是不宽化吗?- 答:
const的「不宽化」只作用于变量本身绑定的字面量,对象/数组的属性位置依然是可变的,所以仍然会宽化。要让属性也保持字面量,必须as const,或者把它放到一个有字面量约束的泛型函数里。
- 答:
- 追问:函数重载和条件返回类型,推断上有什么差别?
- 答:重载是逐个签名匹配,第一个命中即生效,所以更具体的要写前面;而且重载有两个致命限制:传联合类型实参不会分发到多个重载,
ReturnType只能拿到最后一个声明签名。条件返回类型T extends string ? A : B则是真正参与类型运算的,能被infer正确提取,也能配合联合类型分发。所以内部工具函数优先条件返回类型,对外 API 的少量固定形态才用重载。
- 答:重载是逐个签名匹配,第一个命中即生效,所以更具体的要写前面;而且重载有两个致命限制:传联合类型实参不会分发到多个重载,
- 追问:上下文类型失效有哪些典型场景?
- 答:① 回调先赋值给变量再传入,变量声明时没有上下文;② 参数是联合的函数类型,TS 只能从最后一个候选取上下文;③ 用了
any断言后链路断开;④ 对象字面量里的方法在多层嵌套下上下文丢失。统一的解法是让回调字面量直接写在调用处,或者给回调参数显式标注一次类型。
- 答:① 回调先赋值给变量再传入,变量声明时没有上下文;② 参数是联合的函数类型,TS 只能从最后一个候选取上下文;③ 用了
✅ 项目关联话术(按真实经历替换):
我们封装了一个权限指令的配置函数
definePermission(codes),早期签名是(codes: string[]) => string[],调用处传进去的权限码全都宽化成了string,后续想基于这份配置生成联合类型做校验完全做不到。后来我改成<const T extends readonly string[]>(codes: T): T,调用方什么都不用改,就直接拿到了readonly ['order:list', 'order:export']这样的只读元组,再用T[number]派生出权限码联合类型,全局的hasPermission函数参数就有了自动补全。还有一处是表单校验器的rules对象,之前回调参数全是any,排查发现是先赋值给中间变量导致上下文类型断了,把回调内联回调用处就恢复了。
✍️ 手撕代码【核心实现 + 边界 Case 自测清单 + 变种拓展题】
题 1:手写 Partial / Required / Readonly / Pick / Omit / Record
核心实现:
ts
// ① Partial:全部变可选
type MyPartial<T> = { [K in keyof T]?: T[K] };
// ② Required:去掉可选(-? 是「移除修饰符」语法)
type MyRequired<T> = { [K in keyof T]-?: T[K] };
// ③ Readonly / Mutable
type MyReadonly<T> = { readonly [K in keyof T]: T[K] };
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
// ④ Pick:K 必须约束在 keyof T 上,否则写错 key 不报错
type MyPick<T, K extends keyof T> = { [P in K]: T[P] };
// ⑤ Exclude:分布式条件类型,命中返回 never 被联合吸收
type MyExclude<T, U> = T extends U ? never : T;
// ⑥ Omit:官方实现里 K 是 keyof any,故意不做约束(为了兼容可选 key 场景)
type MyOmit<T, K extends keyof any> = MyPick<T, MyExclude<keyof T, K>>;
// ⑦ Record:构造字典
type MyRecord<K extends keyof any, V> = { [P in K]: V };
// 验证
interface User {
id: number;
name?: string;
readonly age: number;
}
type T1 = MyPartial<User>; // { id?: number; name?: string; readonly age?: number }
type T2 = MyRequired<User>; // { id: number; name: string; readonly age: number }
type T3 = MyOmit<User, "age">; // { id: number; name?: string }
type T4 = MyRecord<"a" | "b", number>; // { a: number; b: number }三个必须记住的点: ①
[K in keyof T]这种同态映射会自动保留原有的?和readonly,所以MyPartial不会把 readonly 弄丢;②-?/-readonly是移除修饰符,+?是默认行为可以省略;③MyOmit因为中间过了一层Exclude,不再是同态映射,readonly修饰符会丢失,这也是官方Omit的既有行为。
✅ 边界 Case 自测:
MyPick<User, 'notExist'>→ 应该编译报错(约束生效);而MyOmit传不存在的 key 不会报错MyPartial<User[]>→ 同态映射对数组会映射成数组,结果是(T | undefined)[]而非普通对象MyRequired只去掉?,不会把string | undefined里的 undefined 去掉,需要配合NonNullableMyOmit<User, keyof User>→ 得到{},不是never- 嵌套对象不受影响(全是浅层),
MyPartial<{ a: { b: number } }>的b仍然是必填 keyof T包含 symbol key 时,用K in keyof T & string会把 symbol 过滤掉,按需选择
🔁 变种拓展题:
ts
// 变种 1:PickByType —— 按值类型筛选属性(as 重映射 + never 过滤 key)
type PickByType<T, V> = {
[K in keyof T as T[K] extends V ? K : never]: T[K];
};
type Fns = PickByType<{ a: string; b: () => void }, Function>; // { b: () => void }
// 变种 2:保留修饰符的 Omit(用 as 重映射代替 Exclude,保持同态)
type StrictOmit<T, K extends keyof T> = {
[P in keyof T as P extends K ? never : P]: T[P];
};
// 变种 3:指定若干 key 变可选 / 变必填(业务里极其常用)
type PartialByKeys<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
type RequiredByKeys<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;
// 变种 4:Getters —— key 重命名
type Getters<T> = {
[K in keyof T as `get${Capitalize<K & string>}`]: () => T[K];
};
// 变种 5:至少传一个字段(更新接口常用)
type AtLeastOne<T, K extends keyof T = keyof T> = K extends keyof T
? Partial<T> & Required<Pick<T, K>>
: never;题 2:手写 ReturnType / Parameters / InstanceType / Awaited
核心实现:
ts
// ① ReturnType:从函数类型里推返回值
type MyReturnType<T extends (...args: any[]) => any> = T extends (
...args: any[]
) => infer R
? R
: never;
// ② Parameters:推参数元组
type MyParameters<T extends (...args: any[]) => any> = T extends (
...args: infer P
) => any
? P
: never;
// ③ 第一个参数 / 最后一个参数
type FirstParam<T extends (...args: any[]) => any> = T extends (
first: infer F,
...rest: any[]
) => any
? F
: never;
// ④ ConstructorParameters / InstanceType:注意是 abstract new
type MyConstructorParameters<T extends abstract new (...args: any) => any> =
T extends abstract new (...args: infer P) => any ? P : never;
type MyInstanceType<T extends abstract new (...args: any) => any> =
T extends abstract new (...args: any) => infer R ? R : never;
// ⑤ Awaited:必须递归,否则 Promise<Promise<string>> 解不干净
type MyAwaited<T> = T extends PromiseLike<infer V>
? V extends PromiseLike<any>
? MyAwaited<V>
: V
: T;
// ⑥ ThisParameterType / OmitThisParameter
type MyThisParameterType<T> = T extends (this: infer U, ...args: any[]) => any
? U
: unknown;
// 验证
type F = (a: number, b: string) => Promise<Promise<boolean>>;
type R1 = MyReturnType<F>; // Promise<Promise<boolean>>
type R2 = MyParameters<F>; // [a: number, b: string]
type R3 = MyAwaited<R1>; // boolean三个必须记住的点: ①
infer只能写在extends的右侧,T extends infer R ? ...这种在左侧声明是非法的;② 约束写(...args: any[]) => any而不是Function,因为Function无法被 infer 匹配;③Awaited必须递归,而且判断用PromiseLike而非Promise,这样 thenable 对象也能解包。
✅ 边界 Case 自测:
- 重载函数:
infer只会匹配最后一个重载签名,这是 TS 的既定行为,无法拿到全部重载 - 泛型函数:
MyReturnType<<T>(x: T) => T>得到的是unknown,因为泛型未实例化 MyAwaited<string>→ 直接返回string(非 Promise 原样返回)MyAwaited<Promise<never>>→never;MyAwaited<any>→any- 构造函数约束要带
abstract,否则传入抽象类会报错 MyParameters对可选参数(a?: number)得到[a?: number | undefined],长度是 0 或 1- 箭头函数没有
this,MyThisParameterType返回unknown
🔁 变种拓展题:
ts
// 变种 1:柯里化函数的类型(面试常考)
type Curry<F> = F extends (...args: infer A) => infer R
? A extends [infer First, ...infer Rest]
? Rest extends []
? (a: First) => R
: (a: First) => Curry<(...args: Rest) => R>
: () => R
: never;
// 变种 2:Promisify —— 把 callback 风格转成 Promise 风格
type Promisify<F> = F extends (
...args: [...infer A, (err: any, data: infer D) => void]
) => any
? (...args: A) => Promise<D>
: never;
// 变种 3:追加/移除参数
type AppendArg<F, A> = F extends (...args: infer P) => infer R
? (...args: [...P, A]) => R
: never;
type DropFirstArg<F> = F extends (first: any, ...rest: infer P) => infer R
? (...args: P) => R
: never;
// 变种 4:从对象里反推某个方法的返回值(API 层高频)
type ApiResult<T, K extends keyof T> = Awaited<
T[K] extends (...args: any[]) => infer R ? R : never
>;题 3:手写 Exclude / Extract / NonNullable / UnionToIntersection
核心实现:
ts
// ① 三个分布式工具类型
type MyExclude<T, U> = T extends U ? never : T;
type MyExtract<T, U> = T extends U ? T : never;
type MyNonNullable<T> = T & {}; // 官方新实现,等价于 T extends null | undefined ? never : T
// ② 关闭分发:两边都用元组包起来
type NoDistribute<T, U> = [T] extends [U] ? true : false;
type A1 = MyExclude<"a" | "b", "a">; // 'b'(分发)
type A2 = NoDistribute<"a" | "b", "a">; // false(整体判断)
// ③ 判断是否为 never(必须关闭分发,否则永远走 true 分支)
type IsNever<T> = [T] extends [never] ? true : false;
// ④ 判断两个类型是否完全相等(type-challenges 的经典解法)
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y
? 1
: 2
? true
: false;
// ⑤ UnionToIntersection:利用函数参数位置的「逆变 → 取交叉」
type UnionToIntersection<U> = (U extends any ? (x: U) => void : never) extends (
x: infer I
) => void
? I
: never;
type I1 = UnionToIntersection<{ a: 1 } | { b: 2 }>; // { a: 1 } & { b: 2 }
// ⑥ 联合转元组(基于 UnionToIntersection 取"最后一个成员"再递归)
type LastOfUnion<U> = UnionToIntersection<
U extends any ? () => U : never
> extends () => infer R
? R
: never;
type UnionToTuple<U, Acc extends unknown[] = []> = [U] extends [never]
? Acc
: UnionToTuple<Exclude<U, LastOfUnion<U>>, [LastOfUnion<U>, ...Acc]>;三个必须记住的点: ① 分发的前提是裸类型参数 + 联合类型,
[T] extends [U]是标准的关闭手段;②never是空联合,分发时直接短路返回never,所以IsNever必须包元组;③UnionToIntersection的原理是把联合分发成一堆(x: U) => void,这些函数类型求公共参数时按逆变规则取交叉。
✅ 边界 Case 自测:
MyExclude<never, 'a'>→never(分发短路,不走任何分支)MyExclude<boolean, true>→false(boolean 被当作true | false拆开)MyExclude<any, 'a'>→any参与分发会同时走两个分支,结果是两分支的联合MyExtract<string | number, string | boolean>→stringMyNonNullable<null>→never;MyNonNullable<string | undefined>→stringEqual<any, 1>→false(这正是 Equal 相比extends互判的优势)UnionToTuple的顺序不保证稳定,依赖编译器内部联合排序,别在业务里依赖它
🔁 变种拓展题:
ts
// 变种 1:数组长度 / 获取首尾元素
type Length<T extends readonly unknown[]> = T["length"];
type First<T extends unknown[]> = T extends [infer F, ...unknown[]] ? F : never;
type Last<T extends unknown[]> = T extends [...unknown[], infer L] ? L : never;
type Pop<T extends unknown[]> = T extends [...infer R, unknown] ? R : [];
// 变种 2:Reverse / Includes / Flatten
type Includes<T extends unknown[], U> = T extends [infer H, ...infer R]
? Equal<H, U> extends true
? true
: Includes<R, U>
: false;
type Flatten<T extends unknown[]> = T extends [infer H, ...infer R]
? H extends unknown[]
? [...Flatten<H>, ...Flatten<R>]
: [H, ...Flatten<R>]
: [];
// 变种 3:DeepPartial / DeepRequired(跳过函数和数组)
type DeepPartial<T> = T extends (...args: any[]) => any
? T
: T extends Array<infer U>
? Array<DeepPartial<U>>
: T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;
// 变种 4:Chainable / 可选链类型、Trim、Replace 等字符串体操
type TrimLeft<S extends string> = S extends ` ${infer R}` ? TrimLeft<R> : S;
type TrimRight<S extends string> = S extends `${infer R} ` ? TrimRight<R> : S;
type Trim<S extends string> = TrimLeft<TrimRight<S>>;
type Replace<
S extends string,
From extends string,
To extends string
> = From extends ""
? S
: S extends `${infer H}${From}${infer T}`
? `${H}${To}${T}`
: S;题 4:手写类型安全的 EventBus 与深层路径取值 get
核心实现:
ts
// ① 类型安全的 EventBus:事件名和 payload 一一对应,写错事件名直接报错
type EventMap = {
login: { userId: number };
logout: void;
error: { code: number; msg: string };
};
class EventBus<M extends Record<string, any>> {
private map = new Map<keyof M, Set<(p: any) => void>>();
on<K extends keyof M>(type: K, fn: (payload: M[K]) => void) {
if (!this.map.has(type)) this.map.set(type, new Set());
this.map.get(type)!.add(fn);
return () => this.off(type, fn); // 返回取消订阅函数
}
off<K extends keyof M>(type: K, fn: (payload: M[K]) => void) {
this.map.get(type)?.delete(fn);
}
// 用条件类型做「payload 为 void 时第二个参数可省略」
emit<K extends keyof M>(
...args: M[K] extends void ? [type: K] : [type: K, payload: M[K]]
) {
const [type, payload] = args as [K, M[K]];
this.map.get(type)?.forEach((fn) => fn(payload));
}
}
const bus = new EventBus<EventMap>();
bus.on("login", (p) => p.userId); // ✅ p 自动推导为 { userId: number }
bus.emit("logout"); // ✅ void 事件不用传 payload
// bus.emit('login') ❌ 缺少 payload
// bus.on('loginn', () => {}) ❌ 事件名拼错直接报错
// ② 深层路径取值:路径字符串和返回值都有类型
type Get<T, P extends string> = P extends `${infer K}.${infer Rest}`
? K extends keyof T
? Get<T[K], Rest>
: undefined
: P extends keyof T
? T[P]
: undefined;
function get<T, P extends Paths<T>>(obj: T, path: P): Get<T, P> {
return path
.split(".")
.reduce<any>((acc, k) => (acc == null ? undefined : acc[k]), obj);
}
const cfg = { api: { base: "/v1", timeout: 3000 } };
const base = get(cfg, "api.base"); // string
// get(cfg, 'api.bass') ❌ 路径拼错编译报错三个必须记住的点: ① 用
K extends keyof M做泛型约束,可以让「key 和 value 类型联动」,这是类型安全容器的通用套路;② 可变参数元组...args: Cond ? [A] : [A, B]是实现「条件可选参数」的标准写法,比函数重载更简洁;③ 递归解析路径字符串时,先匹配带点的情况再匹配单段,顺序反了会漏掉嵌套。
✅ 边界 Case 自测:
on返回的取消函数要能正确移除,匿名箭头函数直接传给off会移除失败(引用不同)emit时在回调里off自己 → 遍历中修改 Set,需要先拷贝一份再遍历- 回调内抛异常 → 会中断后续回调,应 try/catch 包裹单个回调
Get遇到可选属性:{ a?: { b: number } }取'a.b'时运行时可能是 undefined,类型上要手动并上undefined- 路径里有数组索引
'list.0.name'→ 需要额外处理数字字面量转 index 的情况 - 对象含
null中间层时 reduce 要短路,否则抛Cannot read property of null Paths<T>对循环引用的类型会递归爆栈,需要加深度限制
🔁 变种拓展题:
ts
// 变种 1:类型安全的 Store(状态 + action 联动)
type Actions<S> = Record<string, (state: S, payload?: any) => void>;
declare function createStore<S, A extends Actions<S>>(
state: S,
actions: A
): {
state: S;
dispatch<K extends keyof A>(type: K, payload?: Parameters<A[K]>[1]): void;
};
// 变种 2:类型安全的 localStorage 封装
interface StorageMap {
token: string;
user: { id: number; name: string };
}
const storage = {
set<K extends keyof StorageMap>(k: K, v: StorageMap[K]) {
localStorage.setItem(k as string, JSON.stringify(v));
},
get<K extends keyof StorageMap>(k: K): StorageMap[K] | null {
const raw = localStorage.getItem(k as string);
return raw ? (JSON.parse(raw) as StorageMap[K]) : null;
},
};
// 变种 3:带 setter 的深层路径(Set<T, P, V> 生成新类型)
type SetPath<T, P extends string, V> = P extends `${infer K}.${infer Rest}`
? K extends keyof T
? Omit<T, K> & { [X in K]: SetPath<T[K], Rest, V> }
: T
: Omit<T, P> & { [X in P]: V };题 5:实现一套类型安全的 Branded ID 系统
核心实现:
ts
// ① 品牌基座:unique symbol 做 key,外部无法伪造
declare const __brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [__brand]: B };
// ② 定义业务 ID
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Cents = Brand<number, "Cents">;
// ③ 校验 + 构造入口(唯一允许出现 as 的地方)
class BrandError extends Error {}
function toUserId(raw: string): UserId {
if (!/^u_[0-9a-f]{8}$/.test(raw)) throw new BrandError(`bad UserId: ${raw}`);
return raw as UserId;
}
function toOrderId(raw: string): OrderId {
if (!/^o_\d{12}$/.test(raw)) throw new BrandError(`bad OrderId: ${raw}`);
return raw as OrderId;
}
// ④ 类型守卫版本(不抛异常,用于分支判断)
function isUserId(raw: unknown): raw is UserId {
return typeof raw === "string" && /^u_[0-9a-f]{8}$/.test(raw);
}
// ⑤ 解除品牌:回到原始类型(给第三方 API / 序列化用)
type Unbrand<T> = T extends Brand<infer U, string> ? U : T;
const unbrand = <T,>(v: T): Unbrand<T> => v as Unbrand<T>;ts
// ⑥ 效果验证
declare function refund(orderId: OrderId, userId: UserId): void;
const oid = toOrderId("o_202603050001");
const uid = toUserId("u_1a2b3c4d");
refund(oid, uid); // ✅
// refund(uid, oid); ❌ 参数写反当场报错(这正是品牌类型要解决的事故)
// refund('o_1', 'u_1'); ❌ 裸 string 不能当 OrderId
// const bad: UserId = 'u_1a2b3c4d'; ❌ 必须走 toUserId
// ⑦ 品牌类型仍然保留原始类型的能力(交叉的是 string)
const upper = uid.toUpperCase(); // string ✅ 方法可用,但返回值已脱品牌
const len: number = uid.length; // ✅
// ⑧ 泛型工厂:少写重复代码
function makeBrander<B extends string, T = string>(
name: B,
check: (v: T) => boolean
) {
return (raw: T): Brand<T, B> => {
if (!check(raw)) throw new BrandError(`bad ${name}`);
return raw as Brand<T, B>;
};
}
const toSkuId = makeBrander("SkuId", (v: string) => v.startsWith("sku_"));ts
// ⑨ 和数据入口对接:全链路只在这里断言一次
interface RawUser {
id: string;
name: string;
}
interface User {
id: UserId;
name: string;
}
function parseUser(raw: unknown): User {
if (typeof raw !== "object" || raw === null) throw new BrandError("not obj");
const r = raw as RawUser;
return { id: toUserId(r.id), name: String(r.name) }; // ✅ 收口点
}✅ 边界 Case 自测:
UserId和OrderId互相赋值必须报错;两者都能赋给string(单向兼容,符合预期)- 裸字面量
'u_1'直接赋给UserId必须报错,只能走toUserId JSON.stringify(uid)结果必须和普通 string 完全一致(品牌不进运行时)keyof UserId不应出现__brand(unique symbol不出现在 IDE 提示里)Brand<number, 'Cents'>做+运算后类型退化成number,需要再包一次toCentsRecord<UserId, T>作为索引 key 是合法的(品牌 string 仍可索引)- 品牌类型放进
Set<UserId>/Map<UserId, T>后,has('u_1')会报错 —— 这是期望行为 declare const __brand若被导出,外部就能手搓品牌值 → 必须不导出- 用
as unknown as UserId双重断言能绕过 → 需要 ESLint 禁止双重断言兜住
🔁 变种拓展题:
ts
// 变种 1:多重品牌(既是 Email 又是已校验的 NonEmpty)
type Email = Brand<string, "Email">;
type NonEmpty = Brand<string, "NonEmpty">;
type ValidEmail = Email & NonEmpty; // ✅ 交叉可叠加
// 变种 2:单位安全的数值运算(避免米/英尺混算)
type Meters = Brand<number, "Meters">;
type Feet = Brand<number, "Feet">;
const addM = (a: Meters, b: Meters) => (a + b) as Meters;
const m2f = (m: Meters) => (m * 3.28084) as Feet;
// 变种 3:SafeHTML —— 从类型层面防 XSS
type SafeHTML = Brand<string, "SafeHTML">;
declare function sanitize(dirty: string): SafeHTML;
declare function render(html: SafeHTML): void;
// render(userInput); ❌ 必须先 sanitize,源头上堵住 XSS
// 变种 4:类型层面提取所有品牌名
type BrandOf<T> = T extends { readonly [__brand]: infer B } ? B : never;
type N = BrandOf<UserId>; // 'UserId'
// 变种 5:用 private 实现名义类型(不借助 symbol)
class NominalId {
private readonly _nominal!: void;
constructor(public readonly value: string) {}
}
// 结构相同的两个类,只要有 private 成员就互不兼容题 6:实现 Chainable 链式类型(经典类型体操高频题)
题目: 实现 Chainable,支持无限次 .option(key, value) 链式调用,最后 .get() 返回由所有 key/value 累积出的对象类型;同名 key 重复设置时,后者的类型覆盖前者。
ts
declare const config: Chainable;
const result = config
.option("foo", 123)
.option("name", "joycode")
.option("bar", { value: "x" })
.get();
// 期望:{ foo: number; name: string; bar: { value: string } }核心实现:
ts
type Chainable<R = {}> = {
option<K extends string, V>(
// ① K 约束成 string 才能做 key;不能用 keyof R,否则无法新增 key
key: K extends keyof R
? V extends R[K]
? never // ② 同名且同类型 → 禁止无意义重复设置
: K
: K,
value: V
): Chainable<Omit<R, K> & Record<K, V>>; // ③ 先 Omit 再并,实现「后者覆盖」
get(): R;
};ts
// ④ 为什么必须 Omit 后再交叉?
type Bad = { a: string } & { a: number }; // a: never —— 直接交叉会冲突
type Good = Omit<{ a: string }, "a"> & { a: number }; // a: number ✅
// ⑤ 结果展开:交叉类型的 IDE 提示很难读,套一层 Prettify 压平
type Prettify<T> = { [K in keyof T]: T[K] } & {};
type Chainable2<R = {}> = {
option<K extends string, V>(
key: K extends keyof R ? (V extends R[K] ? never : K) : K,
value: V
): Chainable2<Prettify<Omit<R, K> & Record<K, V>>>;
get(): Prettify<R>;
};ts
// ⑥ 运行时实现(类型体操题通常只要类型,但工程里要能跑)
function createChainable<R extends object = {}>(store = {} as R): Chainable<R> {
return {
option(key: any, value: any) {
return createChainable({ ...store, [key]: value }) as any;
},
get: () => store,
};
}ts
// ⑦ 进阶:同名 key 必须「类型不同才允许覆盖」的严格版
type ChainableStrict<R = {}> = {
option<K extends string, V>(
key: K extends keyof R
? // 同名:只有新类型与旧类型不互相兼容时才放行
[V] extends [R[K]]
? never
: K
: K,
value: V
): ChainableStrict<Omit<R, K> & Record<K, V>>;
get(): R;
};✅ 边界 Case 自测:
- 零次调用直接
.get()→ 应得到{},所以泛型默认值必须写R = {} - 同名 key 不同类型覆盖:
.option('a', 1).option('a', 'x')→{ a: string },不能是never - 同名 key 同类型重复:
.option('a', 1).option('a', 2)→ 第二个 key 参数类型是never,调用报错(题目要求) - key 传变量而非字面量:会宽化成
string,Record<string, V>把结果变成索引签名 → 需要const类型参数或要求传字面量 - value 传字面量:
.option('a', 1)的V推成number(宽化),若要保留1需V extends ...或调用处as const - 链式超过 20 层:每层嵌一次
Omit & Record,类型展开层数线性增长,可能触发「too complex」→ 用Prettify及时压平 - key 用数字
.option(1, 'x'):K extends string会直接报错,需要K extends string | number才支持 Omit对neverkey 的处理:Omit<R, never>等于R,不会误删字段
🔁 变种拓展题:
ts
// 变种 1:链式构建 SQL 查询(累积 select 字段 → 精确返回类型)
type Query<T, S extends keyof T = never> = {
select<K extends keyof T>(...keys: K[]): Query<T, S | K>;
where(fn: (row: T) => boolean): Query<T, S>;
exec(): Pick<T, S>[];
};
// 变种 2:链式 Promise 中间件(累积上下文类型)
type Ctx<C = {}> = {
use<N extends object>(fn: (c: C) => N): Ctx<Prettify<C & N>>;
run(): C;
};
// 变种 3:链式表单校验器(累积字段 + 累积错误类型)
type Validator<T = {}, E = {}> = {
field<K extends string, V>(
k: K,
rule: (v: unknown) => v is V
): Validator<Prettify<T & Record<K, V>>, Prettify<E & Record<K, string>>>;
validate(input: unknown): { data?: T; errors?: Partial<E> };
};
// 变种 4:只允许「先 option 再 get」的状态机式链式类型
type Builder<R = {}, Done extends boolean = false> = Done extends true
? { get(): R }
: { option<K extends string, V>(k: K, v: V): Builder<R & Record<K, V>>; done(): Builder<R, true> };⚠️ 当日踩坑实录
| 常见坑 | 解决方案 |
|---|---|
❌ 把条件类型里的 extends 当成「继承」 | ✅ 它判断的是可赋值性,'a' extends string 为真、属性多的对象可以赋给属性少的 |
❌ infer 写在 extends 左边或条件类型外 | ✅ infer 只能出现在 extends 右侧的模式里,否则报 'infer' declarations are only permitted... |
❌ 传入 never 时工具类型返回空 | ✅ never 是空联合,分发直接短路;需要判断就用 [T] extends [never] 关闭分发 |
❌ 传 boolean 进条件类型结果变成联合 | ✅ boolean 会被拆成 true | false 分别分发,不想分发就包元组 |
❌ A & B 同名属性类型冲突后变成 never | ✅ 改用 interface extends,冲突在声明处就报错,而不是赋值时才发现 |
❌ 自己写的 Omit 把 readonly/? 弄丢了 | ✅ 过了 Exclude 就不是同态映射;想保留修饰符要用 as 重映射版本 |
❌ Omit<T, 'nam'> 拼错 key 却不报错 | ✅ 官方 Omit 第二参约束是 keyof any,需要严格就自己写 StrictOmit<T, K extends keyof T> |
❌ Partial 不能让嵌套对象变可选 | ✅ 内置工具类型全是浅层的,深层要自己写 DeepPartial(记得跳过函数和数组) |
❌ 一个 any 让整条链路的类型保护失效 | ✅ 外部数据统一用 unknown + 类型守卫;ESLint 开 no-explicit-any 锁住 |
❌ let v: void = null 在 strict 下报错 | ✅ strictNullChecks 下 void 只接受 undefined;想允许 null 就显式写 void | null |
❌ 以为 void | string 会被简化成 string | ✅ 只有 never 会被联合吸收,void 是普通成员,联合里永远保留分支,容易让收窄「看着没错却失效」 |
❌ 把「函数没有返回值」写成 never | ✅ 正常执行完没返回值是 void;never 表示根本走不到结尾(throw 或死循环) |
❌ as 断言蒙混过关,线上直接 crash | ✅ as 没有任何运行时校验,能用类型守卫就别用断言;禁止 as any as T 双重断言 |
❌ catch (e) 里直接访问 e.message 报错 | ✅ strict 下 e 是 unknown,先 e instanceof Error 收窄,并处理非 Error 抛出 |
❌ 递归类型报 excessively deep and possibly infinite | ✅ 改写成尾递归 + 累加器,或加深度计数泛型主动截断 |
| ❌ 模板字面量联合把编辑器卡死 | ✅ 联合会做笛卡尔积,100×100 就是一万个成员,控制规模或退化成 string |
❌ 几十层 & 交叉类型导致悬浮提示转圈 | ✅ 改 interface extends,或套一层 Prettify<T> 压平展示 |
| ❌ Vite 构建成功但线上类型错误 | ✅ esbuild 只转译不校验,CI 必须加 tsc --noEmit / vue-tsc --noEmit 卡点 |
❌ skipLibCheck: true 掩盖了库之间的类型冲突 | ✅ 它只是跳过 .d.ts 检查换速度,升级依赖后建议临时关掉跑一次全量检查 |
❌ 开了 isolatedModules 后类型导入报错 | ✅ 类型必须用 import type { X } from '...',或开 verbatimModuleSyntax 统一规范 |
❌ enum 在产物里留下了运行时代码 | ✅ 需要纯类型就用 const enum(注意 isolatedModules 不兼容)或 as const 对象 + 联合类型 |
❌ as const 忘了加,字面量被宽化成 string | ✅ 配置对象、路由表一律加 as const;函数参数可用 TS 5.0 的 const 泛型参数 |
❌ 解构 props / reactive 后类型和响应性都没了 | ✅ Vue 里用 toRefs,React 里注意 useState 的泛型要显式写出联合类型 |
❌ keyof 数组拿到的是一堆方法名 | ✅ 取元素类型用 T[number],取索引联合用 Exclude<keyof T, keyof any[]> |
❌ strictNullChecks 打开后报错上百处 | ✅ 分阶段迁移:先 noImplicitAny,再 strictNullChecks,用 @ts-expect-error 临时压住并登记 TODO |
❌ 用了 @ts-ignore,后来错误修了也没人删 | ✅ 一律改用 @ts-expect-error,错误消失后它自己会报错提醒你删掉 |
❌ 可选属性和 | undefined 混为一谈 | ✅ 开 exactOptionalPropertyTypes 后两者语义不同:前者可省略,后者必须显式传 |
❌ d.ts 里写了 import 后全局声明失效 | ✅ 有顶层 import/export 的文件就成了模块,全局声明必须包在 declare global {} 里 |
❌ interface 被第三方意外合并,类型莫名多了字段 | ✅ 不希望被扩展的类型用 type;希望被扩展的(如 Window、Vue 实例)才用 interface |
| ❌ 函数参数的双变导致类型漏洞 | ✅ 开 strictFunctionTypes,并把回调声明成属性语法fn: (a: A) => void 而非方法语法 |
❌ 泛型默认值写了 any,约束形同虚设 | ✅ 默认值用 unknown 或具体类型,让调用方必须显式传入 |
❌ 用类型标注写配置对象,keyof 拿到的只是 string | ✅ 改用 satisfies:既校验结构又保留字面量精度;要只读再叠 as const satisfies |
❌ 用 as Config 兜配置对象,拼错字段也不报错 | ✅ as 会跳过多余属性检查和必填校验,配置对象一律用 satisfies 代替 |
| ❌ 对象字面量直接赋值报「多余属性」,赋给变量却不报 | ✅ 这是 Freshness(多余属性检查),只对字面量生效;先赋给变量、satisfies 或加索引签名都能绕过 |
❌ UserId 和 OrderId 都是 string,参数传反没报错 | ✅ TS 是结构类型系统,用 Brand<T, B> 品牌化隔离,unique symbol 做 key 防伪造 |
❌ 品牌类型的 declare const brand 被导出了 | ✅ 导出后外部就能手搓品牌值,隔离失效;品牌 symbol 必须不导出,只暴露 toXxx 构造函数 |
❌ as unknown as UserId 绕过了品牌约束 | ✅ 品牌只防误用不防故意;ESLint 禁止双重断言,并把断言收口在数据解析层 |
| ❌ 两个结构相同的类莫名不兼容 | ✅ 含 private/protected/#x 成员的类走名义比较,依赖装了两份时就会冲突 |
❌ const obj = { k: 'abc' } 的 k 还是 string | ✅ const 只锁变量绑定,属性位置仍会宽化;要保字面量必须 as const |
| ❌ 泛型被某个参数「带宽」了,本该报错却通过 | ✅ 多候选会合并成公共超类型;用 TS 5.4 的 NoInfer<T> 把该位置排除出推断 |
❌ 库里要求调用方到处写 as const | ✅ 改用 TS 5.0 的 const 类型参数 <const T extends readonly ...>,在声明处一次性解决 |
❌ 回调参数突然变成 any | ✅ 上下文类型链断了(通常是先赋值给中间变量)→ 把回调字面量直接写在调用处 |
❌ ReturnType<typeof 重载函数> 只拿到一个签名 | ✅ 重载不可被类型运算提取,内部工具函数改用「泛型 + 条件返回类型」或判别联合 |
| ❌ 给重载函数传联合类型实参直接报错 | ✅ 重载不会把联合分发到多个签名;需要就显式分支调用,或改成条件返回类型 |
| ❌ 重载顺序写反,具体签名永远匹配不到 | ✅ 重载是自上而下第一个命中即生效,更具体的必须写在前面 |
❌ Object.keys(数字枚举) 多出一堆数字 key | ✅ 数字枚举有反向映射,遍历要 filter(k => isNaN(Number(k)));或直接换 as const 对象 |
❌ 数字枚举能接受任意 number,类型形同虚设 | ✅ TS 5.0 前 const c: Code = 999 不报错;需要严格约束就用字符串枚举或 as const 联合 |
❌ const enum 在 vite/babel 下报错或取值不对 | ✅ 它依赖跨文件内联,和 isolatedModules/esbuild/swc 冲突 → 换 as const 对象 |
| ❌ 库有类型但 IDE 一直报找不到模块 | ✅ moduleResolution 为 node10 读不到 exports 字段 → 前端项目改 bundler |
| ❌ 自己发布的包被消费方说「没类型」 | ✅ exports 的每个条件里 types 必须放在最前面,并保留顶层 types 给旧解析兜底 |
❌ declare module 'vue-router' 扩展不生效 | ✅ 文件里没有 import/export 会被当成全局脚本 → 补一行 import 'vue-router' |
❌ monorepo 里跳转定义跳进了 .d.ts | ✅ 被引用包开 composite + declarationMap,或配 paths 直指源码 |
| ❌ 升级 TS 5 后 NestJS 依赖注入全挂 | ✅ 标准装饰器没有参数装饰器和 emitDecoratorMetadata,Nest/TypeORM 必须保留 experimentalDecorators |
| ❌ 两套装饰器语法混写,类型报错看不懂 | ✅ experimentalDecorators 一开就全局切到 legacy,不能并存,整个项目只能选一套 |
| ❌ 装饰器里拿不到实例属性 | ✅ legacy 装饰器在类定义时执行,实例还不存在 → 用标准装饰器的 ctx.addInitializer |
| ❌ 箭头函数属性上的装饰器不生效 | ✅ 它是实例字段不是原型方法,legacy 下 descriptor 为 undefined,要用字段装饰器 |
❌ 链式类型 A & B 同名字段变成 never | ✅ 实现「后者覆盖」要写 Omit<R, K> & Record<K, V>,不能直接交叉 |
| ❌ 多层交叉类型的 IDE 提示完全读不懂 | ✅ 套一层 Prettify<T> = { [K in keyof T]: T[K] } & {} 压平展示 |
| ❌ 类型写得「看起来对」但没人验证 | ✅ 用 expect-type / tsd 给工具类型写类型单测,CI 里和 tsc --noEmit 一起跑 |
📌 当日复盘任务
- [ ] 能脱稿说清
type与interface的 8 条差异,并讲出自己项目里的选型规则 - [ ] 能解释条件类型里
extends的本质是「可赋值性判断」,并举出三个反直觉的例子 - [ ] 能画出分布式条件类型的拆分 → 判断 → 合并流程,并说明生效的三个前提
- [ ] 能说清为什么
[T] extends [U]可以关闭分发,以及never、boolean、any三个特例 - [ ] 不看资料手写
Partial、Required、Readonly、Pick、Omit、Record六个工具类型 - [ ] 能说清「同态映射」为什么能保留修饰符,以及自己写的
Omit为什么会丢readonly - [ ] 不看资料手写
ReturnType、Parameters、InstanceType、Awaited(注意递归解包) - [ ] 能说清
infer的三条规则(只能在 extends 右侧、本质是模式匹配、必须依附条件类型),并手写FirstItem/LastItem - [ ] 能说清同名
infer在协变位取联合、逆变位取交叉,并各写一个验证例子 - [ ] 不看资料手写
Exclude、Extract、NonNullable、UnionToIntersection - [ ] 能解释
UnionToIntersection为什么要借助函数参数的逆变 - [ ] 能用
as重映射写出Getters、PickByType、StrictOmit三个工具类型 - [ ] 能写出
Split、Trim、Replace、CamelCase四个字符串类型体操 - [ ] 能用元组构造实现类型层面的加法,并说清尾递归与 50/1000 层深度限制
- [ ] 能对比
any/unknown/never/void,并说出各自的典型使用场景 - [ ] 能说清
void与never的四维区别(有无返回值 / 能否执行完 / 是否可赋值 / 联合中行为),并讲出void | string不会被简化的原因 - [ ] 能一句话说清
unknown与any的分水岭(any双向可赋值断链,unknown只能进不能出、必须先收窄),并解释为什么外部数据入口一律用unknown - [ ] 不看答案做完「四类型判断题」8 题,且能指出第 6/7/8 题背后的联合运算规则
- [ ] 能写出 switch 的
never穷尽检查,并演示新增联合成员时的编译报错 - [ ] 能列举 6 种类型收窄手段,并说明为什么工程上最推荐判别联合
- [ ] 能解释协变、逆变、双变,以及
strictFunctionTypes对方法语法为什么不生效 - [ ] 在 type-challenges 上刷完 easy 全部 + medium 至少 15 题,记录卡住的点
- [ ] 给现有项目补一份
global.d.ts:约束process.env、扩展Window、声明静态资源 - [ ] 给现有项目开启
strict(或至少noImplicitAny+strictNullChecks),记录报错数量与修复过程 - [ ] 在 CI 里加上
tsc --noEmit卡点,验证能拦住一个故意写错的类型 - [ ] 用泛型封装一次 API 请求层:
request<T>+Res<T>+ 从接口函数反推参数与响应类型 - [ ] 写一个类型安全的 EventBus 或 Storage 封装,要求事件名/key 写错能编译报错
- [ ] 整理一份自己的「TS 工具类型速查表」,区分内置、自写、intrinsic 三类
- [ ] 能说清结构类型系统(鸭子类型)的兼容判断规则,并举出两个「结构相同却不兼容」的例子
- [ ] 能解释多余属性检查(Freshness)只对对象字面量生效,并列出 4 种绕过方式及各自代价
- [ ] 能对比类型标注 /
as/satisfies三者的差异(是否校验、是否保精度、是否改写类型) - [ ] 把项目里的一份配置对象(路由表 / 埋点 / 主题色)改成
satisfies,并基于keyof typeof做参数约束 - [ ] 不看资料手写
Brand<T, B>,并用unique symbol版本验证外部无法伪造品牌值 - [ ] 能说出品牌类型的 4 种实现方式差异,以及为什么它运行时零开销
- [ ] 给项目里的一组业务 ID 加品牌隔离,并把
as断言收口到数据解析层 - [ ] 能画出泛型推断的五步流程(收集候选 → 合并 → 宽化 → 校验约束 → 上下文回填)
- [ ] 能解释为什么
const obj = { k: 'abc' }的属性仍会宽化,并给出三种锚住字面量的方法 - [ ] 能说清
NoInfer<T>解决的问题,并手写出 TS 5.4 之前的等价实现 - [ ] 能对比
<const T>类型参数和调用处as const,说明库作者该选哪个 - [ ] 能写出函数重载的声明签名 + 实现签名,并说清实现签名对调用方不可见
- [ ] 能说出重载的三个限制(联合实参不分发 /
ReturnType只取最后一个 / 匹配顺序敏感)及三种替代方案 - [ ] 能写出数字枚举的编译产物,解释反向映射的
k[k[v]=n]=v写法 - [ ] 能说清
const enum与isolatedModules/babel/esbuild 的冲突根因 - [ ] 把项目里一个枚举改写成
as const对象 +keyof typeof派生联合类型 - [ ] 能对比
moduleResolution的node10/node16/bundler三者差异及适用场景 - [ ] 能写出一份现代库的
package.jsonexports字段,并说明types为什么必须放最前 - [ ] 能区分
declare module '包名'(兜底 any)、模块扩展、declare global三种补类型手段 - [ ] 在 monorepo 里配一次 Project References,验证
tsc --build的增量效果和跳转定义落到源码 - [ ] 能对比 legacy 装饰器与 TS 5.0 标准装饰器的 7 项差异,并说清 Nest 为什么还离不开 legacy
- [ ] 手写一个标准装饰器(方法日志 + 字段初始值改写),并验证工厂与装饰器的执行顺序
- [ ] 不看资料实现
Chainable,要求支持同名 key 覆盖且重复同类型时报错 - [ ] 能解释
Chainable为什么必须Omit<R, K> & Record<K, V>而不能直接交叉 - [ ] 给自己写的 3 个工具类型补上
expect-type或tsd类型单测,并接进 CI