← 全部材料 TS
TYPESCRIPT · 15 个高频点

类型是用来提前暴露错误的,不是写注释

TS 面试很少问语法,基本都在问"为什么这里不报错"和"为什么这里报错"。这两件事的答案,几乎都能追到三个底层机制:结构化类型、条件类型的分布式行为、类型擦除。下面每一节都从一段会出问题的代码开始。

01看报错,别看结论每节都有一段"本该报错却没报"或"本以为没事却炸了"的代码,先想清楚为什么。
02追到底层机制形状兼容、类型擦除、分布式条件类型——三件事能解释绝大多数现象。
03手写一遍工具类型自己写一遍,比背定义牢得多,也是面试常考的动手题。
1

类型是结构化的,不是名义的★

为什么两个不相干的 interface 能互相赋值
TS 判断兼容性看的是形状,不是名字。只要成员对得上,两个毫无关系的类型就是兼容的。这一点决定了后面几乎所有"为什么这里不报错"的问题。
◐ 可交互:勾掉一个成员,看赋值什么时候开始报错
const a: A(传进来的值)
function f(x: B)(要求的形状)
f(a) 能不能通过,只看 A 有没有把 B 要的成员都占上
但字面量会触发"多余属性检查" const n2: Named = { name:'a', age:18 }; // ❌ Object literal may only specify known properties // 因为这是"直接赋字面量",TS 会额外查一遍多余属性 // 中间转一道变量就没这个检查了 —— 这也是它最让人困惑的地方
面试怎么答TypeScript 是结构化类型系统,比较的是成员形状而不是类型名,所以只要结构兼容就能赋值。直接写字面量时会额外做多余属性检查,赋给变量就绕过了。需要区分语义相同的类型时用品牌类型。
2

any / unknown / never / void★

四个容易混的顶层类型
记住一句话:any 是关掉检查,unknown 是"我不知道,用之前你得先证明"。never 是"不可能有值",void 是"这个函数不返回东西"。
never 真正有用的地方 // 穷尽检查:将来加了新成员,这里会编译报错 type Shape = { kind:'circle' } | { kind:'square' }; function area(s: Shape) { switch (s.kind) { case 'circle': return 1; case 'square': return 2; default: const _exhaustive: never = s; // ← 加了新 kind 就会报错 return _exhaustive; } }
面试怎么答any 关闭类型检查并且会传染,unknown 是类型安全的顶层类型,必须收窄后才能使用。never 表示不可能有值,常用于穷尽性检查和永远抛错的函数。void 表示函数没有返回值。外部输入一律先标 unknown。
3

泛型:extends 是约束,不是继承★

别把它读成面向对象的继承
泛型里的 extends 意思是"必须满足这个形状",跟 class 的继承没关系。它限制的是调用方能传什么,不是让 T 变成那个类型的子类。
默认值与 keyof 约束function get<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key]; } const user = { name: 'a', age: 18 }; get(user, 'name'); // string get(user, 'age'); // number get(user, 'xxx'); // ❌ "xxx" 不在 keyof T 里
面试怎么答泛型是把类型当参数传,extends 是约束这个参数必须满足的形状,而不是继承。约束之后才能在函数体里安全访问对应成员。配合 keyof 可以让返回值精确到具体属性的类型。
4

联合、交叉、字面量与 as const

& 不是"或者",| 也不是"和"
| 是并集(满足其一),& 是交集(同时满足)。对象类型做交集是合并成员,而原始类型做交集往往是 never。
字面量类型为什么重要 function request(method: 'GET' | 'POST') { /* ... */ } request('GET'); ✅ request('get'); ❌ 拼错立刻报错,不用等运行时 // 从常量数组推出联合类型,是最常用的套路 const METHODS = ['GET', 'POST'] as const; type Method = typeof METHODS[number]; // "GET" | "POST"
面试怎么答| 是满足其一,& 是同时满足,对象交叉是成员合并。as const 把字面量推断成最窄的字面量类型并加 readonly,常用来从一个常量数组推出联合类型。
5

类型收窄:五种手段 + 判别联合★

为什么 switch 之后 TS 就知道是哪个分支
收窄就是让 TS 在某个分支里知道更精确的类型。最可靠的一招是判别联合:给每个成员加一个字面量字段,用 switch 一分,类型自动收窄。
面试怎么答收窄靠 typeof、in、instanceof、判别联合和自定义类型谓词。其中判别联合最好维护,因为新增分支时 switch 会强制你处理。类型谓词 v is T 是人工断言,编译器不会验证函数体,写错了它也不知道。
6

interface 还是 type

大多数时候没区别,但有三处不同
能表达的形状两者都行。选 interface 当你要被 implements 或被别人扩展;选 type 当你要写联合、元组、条件类型。团队里统一一种比争论哪个好更重要。

type 更擅长的

  • 联合类型 A | B
  • 元组、模板字面量类型
  • 映射类型、条件类型
  • 给联合类型起个别名

interface 更擅长的

  • 声明合并(同名自动合并)
  • 被 class implements
  • extends 多个接口,语义自然
  • 给别人留扩展口子(库的对外类型)
最容易考的一处:声明合并 interface Win { a: string } interface Win { b: number } // 自动合并成 { a: string; b: number } ✅ type L { a: string } type L { b: number } // ❌ Duplicate identifier 'L' // 这就是为什么给 window 挂全局变量要用 interface: declare global { interface Window { __APP__: App } }
面试怎么答两者大部分场景等价。interface 支持声明合并、更适合被 implements 和对外暴露;type 能表达联合、元组、条件类型等 interface 做不到的东西。继承冲突时 interface extends 会报错,交叉类型会静默变成 never,所以继承场景优先用 extends。
7

keyof、索引签名与映射类型★

工具类型的底层原料
keyof T 得到 T 所有键的联合;映射类型 { [K in keyof T]: ... } 则能把每个键的值重新算一遍。所有内置工具类型都是这两者的组合。
索引签名// 这种写法所有键都得是同一类型 type Dict = { [key: string]: number }; const d: Dict = { a: 1, b: 2 }; d.c = 3; ✅ d.c = 'x'; <span class="no">❌</span> // Record 就是它的泛型版本 type D2 = Record<string, number>; // ⚠️ 索引签名要求"所有已声明的属性也必须符合它" type Bad = { [k: string]: number; name: string }; <span class="no">// ❌ name 是 string,不符合 number</span>
面试怎么答keyof 取键的联合,映射类型遍历键重新生成类型,as 子句可以改写或过滤键名。Partial、Required、Readonly、Pick、Omit 全都是这两种能力的组合,理解了这个就不用背它们的实现。
8

条件类型与 infer★

工具类型真正的引擎
条件类型长得像三元表达式:T extends U ? X : Y。infer 能在 extends 的位置现抓一个类型出来命名,用来拆解函数、数组、Promise。
内置 ReturnType 的源码就这么短type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
面试怎么答条件类型是类型层面的三元表达式,T extends U ? X : Y。infer 在 extends 右侧声明一个待推断的类型变量,用来拆解函数参数、返回值、Promise、元组。ReturnType 和 Parameters 都是这么实现的。
9

分布式条件类型:为什么 Exclude 能这么写

一个容易写出 bug 的隐式行为
当条件类型作用在裸的类型参数上、且传入的是联合类型时,它会把联合拆开逐个算,再合并结果。这就是"分布式"。想关掉它,就把参数包进元组。
Exclude 就是这么来的type Exclude<T, U> = T extends U ? never : T; // 拆开算: // "a" extends "a"|"b" → never // "b" extends "a"|"b" → never // "c" extends "a"|"b" → "c" type R = Exclude<'a'|'b'|'c', 'a'|'b'>; // "c"
面试怎么答分布式条件类型是指当条件类型作用于裸类型参数且传入联合类型时,会对每个成员分别求值再合并。Exclude 和 Extract 都依赖这个特性。要阻止分发就用元组把参数包起来,写成 [T] extends [U]。
10

模板字面量类型

在类型层面拼字符串
用反引号能在类型里拼字符串,配合联合类型可以展开出所有组合。它让"事件名必须符合约定"这类约束能在编译期检查。
配合映射类型过滤键名type EventMap = { onClick: () => void; onFocus: () => void; value: string; }; // 只留 on 开头的键 type OnlyHandlers<T> = { [K in keyof T as K extends `on${string}` ? K : never]: T[K] }; // { onClick: ...; onFocus: ... }
面试怎么答模板字面量类型允许在类型层拼接字符串,联合类型会被组合展开。配合 infer 可以做反向解析,配合映射类型的 as 子句可以按命名约定过滤键。常用于事件名、路由参数这类有命名规律的场景。
11

手写五个工具类型★

自己写一遍,就不用背了
这五个是面试最爱让手写的。写完之后你会发现它们全都是"映射类型 + 修饰符"或者"条件类型 + infer"。
验证一下type User = { name: string; age: number; pwd: string }; type Public = MyOmit<User, 'pwd'>; // { name: string; age: number } ✅ type Pick2 = MyPick<User, 'name' | 'age'>; // { name: string; age: number } ✅
面试怎么答Partial 和 Required 是加/去可选修饰符,Readonly 是加 readonly,Pick 是限定键的联合,Omit 是 Pick 加 Exclude 的组合。它们的共同底座都是映射类型。
12

声明文件与模块扩充

怎么给没有类型的库补类型
三种场景三种写法:declare module 给第三方模块补类型,declare global 扩展现有的全局,declare namespace 描述挂在全局上的对象。
常见坑 忘了 export {} → 文件被当成全局脚本,declare global 报错 放了 import → 文件变成模块,之前的 declare 不再全局可见
面试怎么答declare module 用来给没有类型声明的模块补类型,declare global 用来扩展已有的全局对象,注意文件里必须有 export {} 才被当作模块。类型查找顺序是先看包内 types 字段,再找 @types 目录。
13

装饰器与 emitDecoratorMetadata★

Nest 凭什么只写类型就能注入
装饰器本身只是在类定义时执行的函数。emitDecoratorMetadata 让它多存一份"这个参数的类型是什么"的元数据,DI 容器就是靠这个知道要注入谁的。
编译后的关键一步 开启 emitDecoratorMetadata 后,编译器会额外插入: __metadata("design:paramtypes", [Repo]) 于是运行时能读到参数类型列表。 但注意:接口和联合类型在这里会变成 Object —— 因为类型擦除之后就没了。这也是为什么 Nest 里 注入 interface 必须用 @Inject('TOKEN') 显式指定。

类型擦除带来的限制

  • interface 不能作为注入依据(编译后不存在)
  • 泛型参数完全拿不到
  • 联合类型退化成 Object

所以要靠 token

  • 用字符串或 Symbol 作为 provider token
  • @Inject(USER_REPO) 显式指定
  • 抽象类也可以当 token,因为它编译后有实体
面试怎么答装饰器是在类定义阶段执行的函数,可以修改或记录类的元信息。emitDecoratorMetadata 让编译器把参数类型写入元数据,Nest 的 DI 就是读取它来决定注入什么。但因为类型擦除,interface 和泛型拿不到,必须用 @Inject 加 token。
14

协变逆变、strict 三兄弟与 satisfies

三个能让老项目一夜之间满屏报错的开关
函数参数是逆变的,返回值是协变的。开启 strictFunctionTypes 后 TS 才会严格检查这一点——这是很多"升级 TS 版本后突然报错"的原因。
strict 里最该打开的三个 strictNullChecks null / undefined 不再属于所有类型(影响最大) strictFunctionTypes 函数参数严格逆变 strictPropertyInitialization 类属性必须初始化(常配 ! 或构造函数赋值) strict: true 是上面所有的集合,新项目直接开满
面试怎么答函数返回值协变、参数逆变,strictFunctionTypes 开启后才会严格检查参数,方法参数因为有历史兼容考虑是双变的。strict 是这一组开关的集合,其中 strictNullChecks 影响最大。satisfies 用来校验类型同时保留最窄推断。
15

速答清单

考前十分钟只看这页
01TS 是结构化还是名义类型?
结构化,比较成员形状不看名字。所以形状对就能赋值,空对象类型 {} 几乎接受一切。
02any 和 unknown 的区别?
any 关闭检查并且会传染;unknown 必须先收窄才能用。外部输入一律 unknown。
03never 有什么用?
表示不可能有值。用于穷尽性检查——新增联合成员时 default 分支会编译报错。
04泛型里 extends 是什么?
约束,不是继承。限制调用方能传什么形状,之后才能在函数体内安全访问对应成员。
05interface 和 type 怎么选?
要被 implements 或需要声明合并用 interface;要写联合、元组、条件类型用 type。
06Partial / Pick / Omit 怎么实现?
都是映射类型。Partial 加 ?,Pick 限定键,Omit 是 Pick + Exclude。
07条件类型里的 infer 是什么?
在 extends 右侧声明一个待推断的类型变量,用来拆函数参数、返回值、Promise 内部类型。
08什么是分布式条件类型?
裸类型参数遇上联合类型时,会拆开逐个求值再合并。Exclude 就靠这个。包成 [T] 可以关掉。
09Nest 的 DI 怎么知道注入什么?
emitDecoratorMetadata 把参数类型写进元数据。但 interface 和泛型擦除后拿不到,必须用 @Inject 加 token。
10satisfies 和类型标注的区别?
satisfies 只校验不拓宽,保留最窄推断;类型标注会把值拓宽成声明类型,丢掉字面量信息。