主题
第 10 章 · 面试题集(TypeScript 高级类型)
Q1:什么是泛型?为什么需要它?
考察点:泛型的意义
标准答案: 泛型是"可参数化的类型"——把类型当作参数传入函数/接口/类,让同一份代码适配多种类型。 价值:
- 类型安全:不再用
any兜底 - 代码复用:一份逻辑覆盖多种类型
- 类型推断:调用时 TS 自动推出具体类型,无需手写
通俗理解:泛型 = "不限菜品的快餐盒",盒子(结构)固定,里面装的菜(类型)可以是任何一种。
代码示例:
ts
// ❌ 没泛型:返回 any,调用方失去类型
function first(arr: any[]): any { return arr[0]; }
// ✅ 有泛型:返回类型跟着数组元素走
function first<T>(arr: T[]): T { return arr[0]; }
const x = first([1, 2, 3]); // x: number
const y = first(["a", "b"]); // y: string加分回答:
- 泛型支持约束:
<T extends { length: number }> - 支持默认类型:
<T = string> - 多泛型可以互相引用:
<K extends keyof T> - 在类、接口、类型别名、函数都可以用
追问:
- 泛型有运行时开销吗?(无,编译后被擦除)
- 什么场景必须用泛型?(容器类、工具函数、库 API)
Q2:extends 在泛型里是什么意思?
考察点:泛型约束 vs 条件类型
标准答案: extends 在 TS 中有两种含义,取决于上下文:
- 泛型约束:
<T extends Constraint>表示 T 必须是 Constraint 的子类型 - 条件类型:
T extends U ? X : Y类型层面的"三元运算符"
通俗理解:
- 约束 = "这个工位只能放工程师"(限制类型)
- 条件 = "如果是工程师就发显示器,否则发笔记本"(按类型分发)
代码示例:
ts
// 约束:T 必须有 length
function len<T extends { length: number }>(x: T) { return x.length; }
// 条件:根据 T 是否是 string 输出不同类型
type IsString<T> = T extends string ? "yes" : "no";
type R = IsString<"abc">; // "yes"加分回答:
- 条件类型 + 联合类型有"分发"行为:
T extends U ? X : Y,T 是裸类型参数时联合自动分发 - 想关闭分发:用元组包裹
[T] extends [U] ? X : Y
追问:
- 怎么禁用分发?(
[T] extends [U]) extends和implements区别?(前者继承类型/类,后者类实现接口)
Q3:infer 关键字怎么用?
考察点:条件类型中的类型推断
标准答案: infer X 只能出现在条件类型的 extends 子句里,意思是"让 TS 帮我把这一段类型起个名叫 X"。常用于"从复杂类型里抽出某一部分"。
通俗理解:infer = 侦探——你给一个"线索类型",告诉 TS"这里应该有一段什么",TS 帮你抓出来命名。
代码示例:
ts
// 取数组元素类型
type ElementOf<T> = T extends (infer U)[] ? U : never;
type N = ElementOf<number[]>; // number
// 取函数返回值
type ReturnTypeX<F> = F extends (...args: any[]) => infer R ? R : never;
// 取 Promise 内部
type Awaited2<T> = T extends Promise<infer U> ? U : T;
// 取首尾元素
type First<T extends any[]> = T extends [infer F, ...any[]] ? F : never;
type Last<T extends any[]> = T extends [...any[], infer L] ? L : never;加分回答:
infer后续多个位置用同一个名字 → 联合或交叉,取决于位置(协变/逆变)- 函数参数位置上 infer 同名变量会"逆变交叉"
- 内置
Awaited<T>是递归 infer 的经典实现
追问:
- 能在普通 type 里直接用 infer 吗?(不行,必须在 extends 子句中)
- infer 推断不出来时返回什么?(你写的"否则分支",通常是 never)
Q4:什么是映射类型?请手写一个 Partial
考察点:[K in keyof T] 与修饰符
标准答案: 映射类型 = 遍历一个类型的所有 key,生成新类型。语法:{ [K in keyof T]: ... },可加修饰符 ?、readonly,TS 4.1+ 支持 as 重映射 key。
通俗理解:映射类型 = 复印机+滤镜——一张原稿(T)扫描后逐字段输出,加上水印(修饰)。
代码示例:
ts
// 手写 Partial
type MyPartial<T> = { [K in keyof T]?: T[K] };
// 手写 Required
type MyRequired<T> = { [K in keyof T]-?: T[K] };
// 手写 Readonly
type MyReadonly<T> = { readonly [K in keyof T]: T[K] };
// 重映射 key:生成 getter
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};加分回答:
- 修饰符前的
-可以移除:-?去掉可选,-readonly去掉只读 as重映射 + 模板字面量类型可以做出极强的 DSL(如自动生成 getter/setter)- 映射类型常和
keyof、条件类型组合做"过滤型工具类型"
追问:
Partial<T>是深可选还是浅可选?(浅)- 怎么写深可选
DeepPartial?(递归 + 条件类型)
Q5:常用工具类型有哪些?分别用在什么场景?
考察点:内置工具类型熟练度
标准答案:
| 工具 | 一句话说明 | 典型场景 |
|---|---|---|
Partial<T> | 全部字段可选 | 更新接口入参 |
Required<T> | 全部字段必填 | 反向收紧 |
Readonly<T> | 全部字段只读 | 不可变配置 |
Pick<T, K> | 挑出指定字段 | 出参裁剪 |
Omit<T, K> | 去掉指定字段 | 屏蔽 password |
Record<K, V> | 索引对象 | 字典 / 枚举映射 |
Exclude<U, V> | 联合中剔除 | 类型过滤 |
Extract<U, V> | 联合中保留 | 类型筛选 |
NonNullable<T> | 去 null/undefined | 安全收窄 |
ReturnType<F> | 取函数返回类型 | 推导 hooks 返回 |
Parameters<F> | 取函数参数元组 | 转发参数 |
Awaited<T> | 拆 Promise(递归) | 处理 async 返回 |
通俗理解:工具类型 = 官方预制配件,避免你重复造轮子。
代码示例:
ts
interface User { id: number; name: string; password: string }
type UpdateUser = Partial<User>; // 更新入参
type PublicUser = Omit<User, "password">; // 对外脱敏
type UserMap = Record<number, PublicUser>;
type Args = Parameters<typeof fetch>; // [RequestInfo, RequestInit?]
type Resp = Awaited<ReturnType<typeof fetch>>; // Response加分回答:
Partial / Readonly / Required都是浅操作Pick / Omit内部都是映射类型实现- TS 4.5 起
Awaited是递归实现,能展开Promise<Promise<T>>
追问:
- 怎么实现深可选/深只读?(递归 + 条件类型)
Pick和Omit哪个更安全?(Pick改字段不会"漏拒";Omit字段名拼错不会报错——业务里看场景)
Q6:联合类型在条件类型里为什么会"分发"?
考察点:分发条件类型(distributive conditional types)
标准答案: 当条件类型 T extends U ? X : Y 中的 T 是裸类型参数(没有被包成元组、Promise 等),TS 会把联合"按成员分发",分别求值再合并。
通俗理解:联合分发 = 流水线分检——传送带上每个工件单独过 X 光,最后再汇总。
代码示例:
ts
type ToArr<T> = T extends any ? T[] : never;
type R = ToArr<string | number>;
// = ToArr<string> | ToArr<number>
// = string[] | number[]
// 想关闭分发:包成元组
type ToArr2<T> = [T] extends [any] ? T[] : never;
type R2 = ToArr2<string | number>; // (string | number)[]加分回答:
Exclude<T, U>的实现T extends U ? never : T就是利用分发:U 的成员被 never 吞掉- "分发陷阱":写
T extends Function时如果忘了关分发,可能误伤其它成员 - 关闭分发的常见写法:
[T] extends [U]、T & {}等
追问:
never在分发条件类型里行为?(never 直接被吞掉,结果是 never)- 如何用条件类型实现
IsUnion?(利用分发的差异:[T] extends [U] ? false : true)
Q7:类的 class 私有 # 和 private 有什么区别?
考察点:TS 的访问修饰符 vs JS 的私有字段
标准答案:
| 维度 | private(TS) | #field(ES2022) |
|---|---|---|
| 检查时机 | 编译时(运行时可被绕过) | 运行时强保护 |
| 访问方式 | this.x | this.#x |
| 子类访问 | 不能 | 不能 |
| 反射/调试 | 可见 | 完全隔离 |
通俗理解:
private= "门没锁,只是写了'员工通道'"#field= "真有锁,外人进不来"
代码示例:
ts
class Foo {
private a = 1;
#b = 2;
}
const f: any = new Foo();
console.log(f.a); // ✅ 编译期会报错,运行时仍能拿到 1
console.log(f["#b"]); // undefined,运行时真的拿不到加分回答:
- 新代码、有运行时安全要求 → 用
# - 兼容旧代码、追求最大可读性 → 用
private - 二者不可在一个字段上同时使用
追问:
- 为什么
#是 ES2022 而不是 TS 特性?(JS 标准化的私有字段语法) - protected 是什么?(子类可访问的"半私有")
Q8:怎么手写 DeepReadonly?
考察点:递归类型 + 边界处理
标准答案: 深只读 = 一直递归到原始类型为止,所有对象层都加上 readonly。
代码示例:
ts
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends Function
? T[K] // 函数不递归
: T[K] extends object
? DeepReadonly<T[K]> // 对象继续递归
: T[K]; // 原始类型直接返回
};
interface Cfg {
meta: { author: string; tags: string[] };
version: number;
}
const c: DeepReadonly<Cfg> = {
meta: { author: "A", tags: ["ts"] },
version: 1,
};
// c.meta = ... // ❌ readonly
// c.meta.author = ... // ❌ 深 readonly
// c.meta.tags.push(...) // ❌(数组也是只读)通俗理解:DeepReadonly = 整栋楼贴封条,每个房间、每个柜子、每个抽屉都封了。
加分回答:
- 数组/元组单独处理:可改成
T[K] extends Array<infer U> ? ReadonlyArray<DeepReadonly<U>> : ... Date/Map/Set这类内置对象往往不希望递归,需要在 union 里排除- TS 4.5 之后利用尾递归优化,深递归类型不易栈溢出
追问:
- 怎么写
DeepPartial?(同思路,把 readonly 换成 ?,递归) - 类型递归有上限吗?(默认 50 层,可优化到 1000)
Q9:什么时候要写 .d.ts?怎么写?
考察点:声明文件用法
标准答案: .d.ts = 只声明类型、不含实现的文件。常见三类用途:
- 给 JS 库写类型:
declare module "xxx" - 全局类型增强:
declare global { interface Window { ... } } - 资源模块声明:
declare module "*.svg"、declare module "*.scss"
代码示例:
ts
// types/legacy-lib.d.ts
declare module "legacy-lib" {
export function add(a: number, b: number): number;
export const VERSION: string;
}
// types/global.d.ts
declare global {
interface Window { __APP_VERSION__: string }
}
export {};
// types/assets.d.ts
declare module "*.svg" { const url: string; export default url; }加分回答:
- 通过
tsconfig.json的typeRoots或include让 TS 找到 d.ts - DefinitelyTyped (
@types/xxx) 是社区维护的官方 d.ts 仓库 - 写库时用
tsc --emitDeclarationOnly自动产出 d.ts ///<reference types="..." />是老式引用,新代码用import即可
追问:
- 不写 d.ts,导入 JS 库会怎样?(默认报错,可设
allowJs或noImplicitAny: false兜底) - 如何"扩展"已有 d.ts?(用 interface 同名合并;type 不能合并)
Q10(加餐):as const、satisfies、as 三者区别?
考察点:TS 4.9 后的现代写法
标准答案:
| 操作 | 作用 | 示例 |
|---|---|---|
as Foo | 类型断言(不安全,可能错) | x as Foo |
as const | 字面量收窄 + 全 readonly | [1,2] as const → readonly [1, 2] |
satisfies T | 校验值符合 T,但保留原推断类型 | obj satisfies Config |
代码示例:
ts
// as const:保住字面量
const arr = [1, 2, 3] as const; // readonly [1, 2, 3]
// satisfies:既校验又保留窄类型
type RGB = { [k in "r" | "g" | "b"]: number };
const colors = {
r: 255, g: 0, b: 0,
// a: 1, // ❌ 不在 RGB 里
} satisfies RGB;
colors.r.toFixed(); // ✅ 保留 number,不会变 "RGB[keyof RGB]"追问:
satisfies解决了什么痛点?(as Type会丢窄推断;: Type又会让所有字段变宽)- 为什么
as const不能给函数用?(能用,但只把字面量变常量,参数/返回类型不会变)