Skip to content

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 的全部差异 ​

维度interfacetype
声明合并✅ 同名自动合并❌ 重复声明直接报错
可描述类型只能是对象 / 函数 / 类(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
约束为对象的 keyK 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  ==>  boolean
ts
// 关掉分布式:用方括号包起来,让 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]>; // boolean
ts
// ❌ 三种常见错误写法
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 恒等于 T
T = (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">; // never

infer 和泛型参数的区别:

维度泛型参数 <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>剔除 keyPick<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/undefinedT & {}(旧版 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>递归解包 PromiseT 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(面试官最爱问):

维度voidnever
函数能否跑完✅ 正常执行结束❌ 根本到不了结束(抛错/死循环)
有没有返回值有返回动作,值是 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,用之前必须收窄或断言
3undefined 通过,null 报错strictNullChecks 下 null 不属于 void
4❌ 报错never 是空集,没有任何值能赋给它
5❌ 报错void 函数可以 return;,但不能 return 值
6stringnever 被联合吸收(恒等元)
7unknownunknown 会吸收掉所有兄弟类型
8void | stringvoid 不被吸收,是独立成员,不会被"吞掉"

第 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一键开启下面全部严格检查,新项目必开
strictNullChecksnull / undefined 不再能赋给任意类型,收益最大
noImplicitAny禁止隐式 any
strictFunctionTypes函数参数按逆变严格检查(方法语法仍是双变)
noUncheckedIndexedAccessarr[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 成员的 classclass 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 自动是 MouseEvent
ts
// ⑥ 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 module
moduleResolution读 exports 字段相对导入要写后缀推荐搭配
node10(旧 node)❌❌纯 CJS 老项目
node16/nodenext✅✅ 必须 .jsNode 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。

连环追问链:

  1. 追问:声明合并有什么实际用途?
    • 答:主要是扩展第三方库的类型。比如给 Vue 实例加全局属性要 declare module 'vue' 再扩展 ComponentCustomProperties;给 window 加字段要扩展 interface Window;给 Express 的 Request 加 user 字段也是靠合并。这些场景 type 完全做不到。反过来说,如果你想禁止别人篡改你的类型,用 type 更安全。
  2. 追问:interface extends 和 type & 有同名属性时分别会怎样?
    • 答:interface A extends B 时如果 A 和 B 的同名属性类型不兼容,声明处就报错;type A = B & { ... } 不报错,但该属性会变成 string & number,也就是 never,赋值时才发现「什么都塞不进去」。所以我更倾向 interface extends,让错误尽早暴露。
  3. 追问:type 能实现继承吗?interface 能继承 type 吗?
    • 答:type 用交叉 & 模拟继承。interface 可以 extends 一个 type,前提是这个 type 是对象字面量类型或它们的交叉;如果是联合类型就不行,因为 interface 的成员必须是静态可知的。
  4. 追问:为什么说 interface 在大型项目里性能更好?
    • 答:interface 创建的是具名的、带缓存的类型引用,TS 在检查赋值兼容性时可以走「同一个引用直接通过」的快路径;而交叉类型每次都可能需要展开合并成员。几十个 & 串起来的类型是编辑器卡顿的常见原因,这时候用 Prettify<T> 展开或者改写成 interface extends 都有帮助。
  5. 追问:什么时候必须用 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 拆开分发。

连环追问链:

  1. 追问:分布式条件类型生效需要哪些条件?
    • 答:三个条件缺一不可 —— ① 被检查的类型是裸类型参数(不能写成 T[]、[T]、{ a: T });② 类型参数实际接收的是联合类型;③ 用的是条件类型语法。另外对 any 做分发会同时走两个分支返回联合,这是个常被忽略的特例。
  2. 追问:Exclude<T, U> 为什么能"删掉"成员?
    • 答:type Exclude<T, U> = T extends U ? never : T。联合被拆成一个个成员分别判断,命中的返回 never,没命中的返回自身,最后合并时 never 在联合里会被自动吸收掉,所以看起来就是「删除」。
  3. 追问:infer 在同一个位置出现多次会怎样?
    • 答:如果多个 infer 用了同一个变量名,在协变位置(比如返回值、对象属性)会取联合,在逆变位置(函数参数)会取交叉。UnionToIntersection 这个工具类型就是利用后者实现的:先把联合变成一堆函数参数位,再用一个 infer R 收口得到交叉类型。
  4. 追问:T extends U 里的 extends 和 class 的 extends 一样吗?
    • 答:完全不一样。条件类型里的 extends 判断的是可赋值性,所以 'a' extends string 为真,{a:1,b:2} extends {a:number} 也为真(鸭子类型 + 属性多的可以赋给属性少的)。而类的 extends 是真实的原型继承,有运行时行为。
  5. 追问:infer 配合 extends 约束怎么写?
    • 答:TS 4.7 起支持 infer R extends string 这种带约束的推断,不满足约束就走 false 分支。常用在解析数字字符串场景,比如 S extends `${infer N extends number}` ? N : never 可以把 '42' 直接推成数字字面量 42。
  6. 追问: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 语法手写。

连环追问链:

  1. 追问:Partial 有什么坑?
    • 答:它是浅层的,嵌套对象不会变可选,所以表单默认值这类场景要自己写 DeepPartial。另外开了 exactOptionalPropertyTypes 之后,Partial 产生的可选属性不等于「可以显式传 undefined」,需要写成 T[K] | undefined。还有 Partial 会让所有字段都能省略,对「至少要传一个字段」的更新接口不够精确,这时要用 AtLeastOne 这类自定义工具。
  2. 追问:Omit 和 Exclude 容易混,它们区别是什么?
    • 答:Exclude 作用在联合类型上,做的是「从联合里删成员」;Omit 作用在对象类型上,做的是「从对象里删属性」,内部才用到 Exclude。同理 Extract 对应联合,Pick 对应对象。
  3. 追问:怎么实现一个"按值类型筛选属性"的工具类型?
    • 答:用 as 重映射 + 条件返回 never:type PickByType<T, V> = { [K in keyof T as T[K] extends V ? K : never]: T[K] }。重映射时 key 为 never 的会被直接丢弃,这是过滤 key 的标准手法。常见用途是从一个大 Model 里挑出所有函数属性或所有 string 字段。
  4. 追问:keyof 对数组、对 any、对联合类型分别得到什么?
    • 答:对数组得到的是 number | 'length' | 'push' | ... 所有数组方法名(所以要取索引应该用 T[number]);keyof any 是 string | number | symbol;keyof (A | B) 得到的是两者 key 的交集,而 keyof (A & B) 是并集,这点和直觉相反但很好理解 —— 联合类型只能访问共有属性。
  5. 追问:映射类型里的 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 字段,编译器能自动收窄,不需要任何断言。

连环追问链:

  1. 追问:为什么 catch (e) 里的 e 不是 Error?
    • 答:因为 JS 允许 throw 任何值,throw 'str'、throw 42 都合法。TS 4.4 之后开 useUnknownInCatchVariables(strict 包含)e 就是 unknown,必须先 e instanceof Error 判断再用 e.message。这其实是个好事,能逼着我们处理非 Error 抛出的情况。
  2. 追问:什么是协变和逆变?为什么函数参数要逆变?
    • 答:返回值位置是协变的(子类型可以赋给父类型),参数位置是逆变的(需要父类型才能赋给要求子类型的位置)。原因很直观:一个「能处理任意 Animal 的函数」可以安全地放在「需要处理 Dog 的回调」的位置上,反过来不行,因为处理 Dog 的函数遇到 Cat 会崩。不过 TS 为了兼容历史代码,用方法语法声明的参数是双变的,这是数组 push 之类的 API 不报错的原因,开 strictFunctionTypes 只对函数属性语法生效。
  3. 追问:as 断言和类型守卫的本质区别?
    • 答:as 是告诉编译器闭嘴,没有任何运行时校验,断言错了就是线上 crash;类型守卫是运行时真的做了判断,编译器基于判断结果收窄。所以 as 只应该用在「我确实比编译器知道得多」的场景,比如 document.getElementById('x') as HTMLInputElement。另外 v is T 虽然是守卫,但函数体里逻辑写错编译器不会提醒,所以它仍然是一种「信任」。
  4. 追问:never 还有哪些实际用处?
    • 答:① 穷尽检查;② 标记永不返回的函数(抛异常或死循环),能帮助控制流分析;③ 在 as 重映射里过滤 key;④ 在条件类型里当「空结果」,因为 never 会被联合吸收;⑤ 用 T extends never ? ... 检测分发短路。
  5. 追问:怎么在团队里限制 any 的使用?
    • 答:ESLint 开 @typescript-eslint/no-explicit-any 和 no-unsafe-assignment 系列规则,tsconfig 开 noImplicitAny;对实在躲不开的地方要求写 // eslint-disable-next-line 并附原因注释,这样 code review 时能看到;另外统计 any 数量做成趋势看板,只允许下降不允许上升。

✅ 项目关联话术(按真实经历替换):

我们之前线上出过一次事故:后端某个字段从对象改成了 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 只做转译不做类型检查,不加这一步类型错误会直接上线。

连环追问链:

  1. 追问:tsc --noEmit 为什么必须加进 CI?
    • 答:Vite、esbuild、SWC 这类工具为了速度只剥离类型、不做类型检查,所以本地编辑器飘红但构建依然成功。必须额外跑 tsc --noEmit(或 vue-tsc --noEmit)才能真正拦住类型错误。大项目可以配 incremental + tsBuildInfoFile 加速,或者用 --build 搭配项目引用。
  2. 追问:泛型默认值和约束能同时用吗?顺序是什么?
    • 答:可以,写成 <T extends Base = Base>,约束在前默认值在后。注意默认值必须满足约束。另外多个泛型参数有默认值时,带默认值的要放在后面,和函数参数规则一样。
  3. 追问:怎么让函数的返回值类型依赖参数值?
    • 答:用函数重载或条件返回类型。比如 function get<K extends keyof Config>(k: K): Config[K],返回值随传入的 key 变化。更复杂的用重载签名,或者 TS 5.0 的 const 类型参数 <const T> 让传入的字面量不被宽化成 string。
  4. 追问:import type 和普通 import 有什么区别,为什么推荐?
    • 答:import type 明确标记这是只在类型层面使用的导入,转译时会被完整擦除,不会产生运行时 import。好处是:避免循环依赖、避免把只用于类型的模块打进产物、在 isolatedModules/verbatimModuleSyntax 下是必须的。
  5. 追问:老项目从 JS 迁移 TS 的具体步骤?
    • 答:① 加 tsconfig,开 allowJs + checkJs: false,让两种文件共存;② 先给工具函数、API 层这些「被依赖最多」的底层模块加类型,收益最大;③ 逐个文件改后缀,用 @ts-expect-error 临时压住,并在注释里登记 TODO;④ 分阶段打开严格选项,每打开一个就修一轮;⑤ 全程保持 tsc --noEmit 在 CI 里绿灯,避免回退。

✅ 项目关联话术(按真实经历替换):

我在团队里主导过一次 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 和深层字面量。

连环追问链:

  1. 追问:能举个「类型标注丢精度」导致实际问题的例子吗?
    • 答:最典型的是路由表。写成 const routes: Record<string, RouteItem> = {...} 之后,keyof typeof routes 就是 string,导航函数 push(name) 的参数没法约束,拼错路由名要到运行时才发现。改成 const routes = {...} satisfies Record<string, RouteItem>,keyof typeof routes 就是 'home' | 'detail' | ... 的精确联合,push 的参数直接有自动补全,拼错当场报错。
  2. 追问:satisfies 能用在函数参数或返回值上吗?
    • 答:不能,它是表达式级别的操作符,只能写在表达式右边,不能出现在参数位置或类型注解位置。函数返回值想要同样效果,得在函数体里写 return {...} satisfies T,或者用泛型 + const 类型参数。
  3. 追问:satisfies 和泛型约束 <T extends Config>(c: T) => T 有什么关系?
    • 答:目的一样 —— 都是「校验但保留精度」。区别在于泛型方案需要包一层函数(社区早期的 defineConfig<T extends Config>(c: T): T 就是干这个的),而 satisfies 是语言原生支持,不用额外运行时函数。所以 TS 4.9 之后,很多库的 defineXxx 辅助函数其实可以退化成 satisfies。
  4. 追问:as const satisfies X 和 satisfies X as const 哪个对?
    • 答:as const satisfies X 是对的。as const 先把字面量锁成最窄的只读类型,再用 X 去校验它。反过来写会先校验再 as const,而 satisfies 的结果已经是确定类型,再 as const 没有意义甚至会报错。
  5. 追问:什么情况下仍然应该用类型标注而不是 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 对私有成员做的是「同一声明」比较。

连环追问链:

  1. 追问:品牌字段用 string、unique symbol、declare const 有什么差别?
    • 答:用普通字符串字段({ __brand: 'UserId' })最简单,但别人手写一个同名字段就能伪造,而且字段名会出现在 IDE 提示和 keyof 结果里。用 declare const brand: unique symbol 当 key({ [brand]: 'UserId' })外部拿不到这个 symbol,无法伪造,而且不污染 keyof。如果品牌声明不导出,隔离性最强。
  2. 追问:品牌类型会不会影响运行时性能或打包体积?
    • 答:完全不会。交叉的品牌字段只是类型层面的虚构属性,declare const 和 type 都会被完整擦除,编译产物里一个字节都不多。唯一的「成本」是必须通过构造函数/断言函数才能造出品牌值,这其实正是我们想要的约束。
  3. 追问:品牌类型怎么和接口返回的数据对接?后端给的就是 string。
    • 答:在数据入口处统一转换。HTTP 层拿到 unknown 之后做运行时校验,校验通过时用 as UserId 断言一次 —— 断言只在这一个收口点出现,下游全部是品牌类型。这个模式和 zod 的 z.string().brand<'UserId'>() 思路一致:运行时校验 + 编译期品牌,一次断言换来全链路安全。
  4. 追问:除了 ID,品牌类型还有哪些实用场景?
    • 答:① 单位,Meters 和 Feet 都是 number,混用会算错;② 安全性标记,SafeHTML 必须经过 sanitize() 才能产生,从源头防 XSS;③ 校验状态,Email、NonEmptyString、PositiveInt 这类「已校验」的语义;④ 货币/精度,Cents 和 Yuan 区分开避免少乘 100。
  5. 追问: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。

连环追问链:

  1. 追问: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] 这种「延迟求值」技巧,原理是让该位置变成不可推断的间接类型。
  2. 追问:const 类型参数 <const T> 和调用处写 as const 有什么区别?
    • 答:效果基本等价,区别在于责任方。as const 要求每个调用方都记得写,漏写就丢精度;<const T> 是库作者在声明处一次性解决,调用方正常写字面量就能得到只读元组。所以做工具库优先用 const 类型参数。注意它只对直接传入的字面量生效,传变量进去照样会宽化。
  3. 追问:为什么 const obj = { k: 'abc' } 里的 k 还是 string?const 不是不宽化吗?
    • 答:const 的「不宽化」只作用于变量本身绑定的字面量,对象/数组的属性位置依然是可变的,所以仍然会宽化。要让属性也保持字面量,必须 as const,或者把它放到一个有字面量约束的泛型函数里。
  4. 追问:函数重载和条件返回类型,推断上有什么差别?
    • 答:重载是逐个签名匹配,第一个命中即生效,所以更具体的要写前面;而且重载有两个致命限制:传联合类型实参不会分发到多个重载,ReturnType 只能拿到最后一个声明签名。条件返回类型 T extends string ? A : B 则是真正参与类型运算的,能被 infer 正确提取,也能配合联合类型分发。所以内部工具函数优先条件返回类型,对外 API 的少量固定形态才用重载。
  5. 追问:上下文类型失效有哪些典型场景?
    • 答:① 回调先赋值给变量再传入,变量声明时没有上下文;② 参数是联合的函数类型,TS 只能从最后一个候选取上下文;③ 用了 any 断言后链路断开;④ 对象字面量里的方法在多层嵌套下上下文丢失。统一的解法是让回调字面量直接写在调用处,或者给回调参数显式标注一次类型。

✅ 项目关联话术(按真实经历替换):

我们封装了一个权限指令的配置函数 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 去掉,需要配合 NonNullable
  • MyOmit<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> → string
  • MyNonNullable<null> → never;MyNonNullable<string | undefined> → string
  • Equal<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,需要再包一次 toCents
  • Record<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 对 never key 的处理: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.json exports 字段,并说明 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