类型是用来提前暴露错误的,不是写注释
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 会额外查一遍多余属性
// 中间转一道变量就没这个检查了 —— 这也是它最让人困惑的地方
- 空对象类型是个洞:
{}几乎接受任何非 null/undefined 的值,包括数字和字符串。想表达"空对象"要用Record<string, never>。 - 结构化带来的好处:不用为了传参去继承某个基类,只要形状对就行。这让 mock 和测试非常轻。
- 结构化带来的麻烦:两个本该区分开的类型(比如 userId 和 orderId,都是 string)会被认为是同一个类型。要区分就得用品牌类型。
面试怎么答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 会传染:
any参与的任何运算结果还是 any,一路传下去整个调用链都失去检查。所以noImplicitAny是必开的。 - unknown 才是安全的 any:外部输入(JSON.parse、catch 的 error、API 响应)都应该是 unknown,强迫自己先收窄。
- never 还会出现在交叉类型里:
string & number的结果就是 never,所以有些"不可能同时满足"的约束可以用它表达。
面试怎么答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 里
- 返回值用 T[K] 而不是 T[keyof T]:前者能精确到"传 name 就返回 string",后者只会得到联合类型。这个差别在封装工具函数时非常明显。
- 泛型参数可以给默认值:
<T = unknown>,调用时不写也能推断。 - 别滥用泛型:一个泛型只在函数体里出现一次、且没参与返回类型,基本说明它没用——那种情况直接用具体类型更清楚。
面试怎么答泛型是把类型当参数传,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,且元素推成字面量类型。
- 联合类型上的属性访问受限:只能访问所有成员都有的属性。想访问特有的,必须先收窄。
- 交叉类型在运行时不做任何合并,它只是编译期的形状叠加。同名属性类型不同会直接变成 never。
面试怎么答| 是满足其一,& 是同时满足,对象交叉是成员合并。as const 把字面量推断成最窄的字面量类型并加 readonly,常用来从一个常量数组推出联合类型。
5
类型收窄:五种手段 + 判别联合★
为什么 switch 之后 TS 就知道是哪个分支
收窄就是让 TS 在某个分支里知道更精确的类型。最可靠的一招是判别联合:给每个成员加一个字面量字段,用 switch 一分,类型自动收窄。
- 可选属性会让收窄失手:
{ status?: 'ok' }里 status 可能是 undefined,判别联合的字段应该是必填字面量。 - typeof x === 'object' 之后还要排除 null,因为 typeof null 是 object,这是 JS 的老坑在类型层的延续。
- 类型谓词是要自己负责的:函数体里写错了 TS 也发现不了,等于对编译器撒了个谎。所以校验逻辑要尽量简单直接。
面试怎么答收窄靠 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 略快:官方性能建议里提到,接口会创建一个"扁平的对象类型",而交叉类型需要递归合并。大项目里大量用
&拼类型会拖慢编译。 - interface extends 比 & 更严格:extends 时如果有不兼容的同名属性,会立刻报错;
&会安静地把它变成 never。 - 两者可以互相用:interface 能 extends type 别名(只要它是对象类型),type 也能用 & 组合 interface。
面试怎么答两者大部分场景等价。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>
- 映射类型可以遍历联合:
[K in 'a' | 'b']也合法,不一定是 keyof 出来的。 - 修饰符能加也能减:
+?-?+readonly-readonly,减号常用于"把可选变必填"。 - as 子句能过滤键:返回 never 的键会被直接丢掉,这是实现 Omit 的关键技巧。
面试怎么答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;
- infer 可以出现多个,也可以放在参数位置、索引位置。
infer在同一个条件类型里出现多次时,同名的会取联合。 - 条件类型是延迟求值的:泛型没确定之前,结果会先挂起。这也是有时候 IDE 提示看起来"慢半拍"的原因。
- 想阻止分发就包一层:
[T] extends [U],见下一节。
面试怎么答条件类型是类型层面的三元表达式,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"
- never 在联合里会被吸收:
"c" | never就是"c"。Exclude 靠的就是这个性质。 - Boolean 也是联合:
boolean实际上是true | false,所以某些条件类型会在它身上分发两次,结果变成联合。 - 要小心"意外分发":写工具类型时如果参数没约束,用户传个联合类型就得到意料之外的结果。
面试怎么答分布式条件类型是指当条件类型作用于裸类型参数且传入联合类型时,会对每个成员分别求值再合并。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: ... }
- 内置的字符串类型工具:
Uppercase、Lowercase、Capitalize、Uncapitalize。 - 别玩过头:组合爆炸会让编译变慢。
4 个联合各 10 个成员已经有 10000 种组合,类型提示会卡。 - 常见真实用途:路由路径参数提取、i18n key 校验、CSS 变量名约束、事件名。
面试怎么答模板字面量类型允许在类型层拼接字符串,联合类型会被组合展开。配合 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 } ✅
- Omit 的 K 为什么是 keyof any:这样能传不存在的键而不报错。如果写成
K extends keyof T,传错键名会直接编译失败——两种取舍都见过,内置版本选的是宽松的。 - Exclude 和 Omit 不一样:Exclude 处理的是联合类型,Omit 处理的是对象类型。名字像,作用对象不同。
- 再加两个常用:
NonNullable<T> = T & {}去掉 null 和 undefined;Record<K, V> = { [P in K]: V }。
面试怎么答Partial 和 Required 是加/去可选修饰符,Readonly 是加 readonly,Pick 是限定键的联合,Omit 是 Pick 加 Exclude 的组合。它们的共同底座都是映射类型。
12
声明文件与模块扩充
怎么给没有类型的库补类型
三种场景三种写法:declare module 给第三方模块补类型,declare global 扩展现有的全局,declare namespace 描述挂在全局上的对象。
常见坑
忘了 export {} → 文件被当成全局脚本,declare global 报错
放了 import → 文件变成模块,之前的 declare 不再全局可见
- types 字段与 @types:
import x from 'lib'时 TS 先看包内types字段,再找node_modules/@types/lib。 - declare module 的两种用法:带具体名字是"补类型",只写通配符(
'*')是"兜底当 any"——后者很危险,等于关掉检查。 - 类型要跟着代码走:自己发的包,
package.json里types必须指向真实的 .d.ts,否则用户装了也没提示。
面试怎么答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,因为它编译后有实体
- 标准装饰器(2022)和旧版不兼容:新版是
(value, context) => value的形式,且不能改类型。用 Nest、Angular 这类框架还是开experimentalDecorators。 - 执行顺序:同一个目标上,装饰器表达式从上往下求值,但应用顺序是从下往上。属性、方法、类各不相同,面试偶尔会考。
- 本质就是函数:不加元数据的话,装饰器完全可以手写成
Injectable(UserService)。
面试怎么答装饰器是在类定义阶段执行的函数,可以修改或记录类的元信息。emitDecoratorMetadata 让编译器把参数类型写入元数据,Nest 的 DI 就是读取它来决定注入什么。但因为类型擦除,interface 和泛型拿不到,必须用 @Inject 加 token。
14
协变逆变、strict 三兄弟与 satisfies
三个能让老项目一夜之间满屏报错的开关
函数参数是逆变的,返回值是协变的。开启
strictFunctionTypes 后 TS 才会严格检查这一点——这是很多"升级 TS 版本后突然报错"的原因。strict 里最该打开的三个
strictNullChecks null / undefined 不再属于所有类型(影响最大)
strictFunctionTypes 函数参数严格逆变
strictPropertyInitialization 类属性必须初始化(常配 ! 或构造函数赋值)
strict: true 是上面所有的集合,新项目直接开满
- 数组是可变的,所以协变不安全:
Dog[]能赋给Animal[],然后你往里 push 一只 Cat,运行时才炸。这是 JS 的历史包袱,TS 用 readonly 数组才能做到真正安全。 - 方法参数双变是刻意的:为了兼容大量现有库(比如 Array 的方法签名)。所以类的方法和函数类型属性的行为不一样。
- satisfies 是 4.9 的好东西:既能校验类型,又不会把推断结果拓宽。
面试怎么答函数返回值协变、参数逆变,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 只校验不拓宽,保留最窄推断;类型标注会把值拓宽成声明类型,丢掉字面量信息。