Skip to content

第 10 章 · 面试题集(TypeScript 高级类型)


Q1:什么是泛型?为什么需要它?

考察点:泛型的意义

标准答案泛型是"可参数化的类型"——把类型当作参数传入函数/接口/类,让同一份代码适配多种类型。 价值:

  1. 类型安全:不再用 any 兜底
  2. 代码复用:一份逻辑覆盖多种类型
  3. 类型推断:调用时 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 中有两种含义,取决于上下文:

  1. 泛型约束<T extends Constraint> 表示 T 必须是 Constraint 的子类型
  2. 条件类型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]
  • extendsimplements 区别?(前者继承类型/类,后者类实现接口)

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>>

追问

  • 怎么实现深可选/深只读?(递归 + 条件类型)
  • PickOmit 哪个更安全?(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.xthis.#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 = 只声明类型、不含实现的文件。常见三类用途:

  1. 给 JS 库写类型declare module "xxx"
  2. 全局类型增强declare global { interface Window { ... } }
  3. 资源模块声明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.jsontypeRootsinclude 让 TS 找到 d.ts
  • DefinitelyTyped (@types/xxx) 是社区维护的官方 d.ts 仓库
  • 写库时用 tsc --emitDeclarationOnly 自动产出 d.ts
  • ///<reference types="..." /> 是老式引用,新代码用 import 即可

追问

  • 不写 d.ts,导入 JS 库会怎样?(默认报错,可设 allowJsnoImplicitAny: false 兜底)
  • 如何"扩展"已有 d.ts?(用 interface 同名合并;type 不能合并)

Q10(加餐):as constsatisfiesas 三者区别?

考察点:TS 4.9 后的现代写法

标准答案

操作作用示例
as Foo类型断言(不安全,可能错)x as Foo
as const字面量收窄 + 全 readonly[1,2] as constreadonly [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 不能给函数用?(能用,但只把字面量变常量,参数/返回类型不会变)