TypeScript 热门面试题
使用说明
本篇共 60 道题。
TypeScript 面试重点是能否用类型表达真实约束,而不是堆复杂体操。回答时先讲用途和安全边界,再给简单类型关系。
Q1: TypeScript 给 JavaScript 带来了什么?
答案:
- 编译时类型检查:在代码运行前发现错误
- 更好的 IDE 支持:智能提示、自动补全、重构
- 代码可读性:类型即文档,易于理解
- 可维护性:大型项目更易维护
- 渐进式采用:可逐步迁移,与 JS 兼容
// 类型检查示例
function processUser(user: { name: string; age: number }) {
// IDE 自动提示 user 的属性
console.log(user.name.toUpperCase());
// user.email; // 编译错误:属性 'email' 不存在
}
Q2: any、unknown 和 never 有什么区别?
答案:
这三个类型分别代表 TypeScript 类型系统的三个极端:最宽松(any)、最安全的宽松(unknown)、最严格(never)。
| 类型 | 含义 | 可赋值给 | 可被赋值 | 能否直接操作 |
|---|---|---|---|---|
any | 任意类型,关闭类型检查 | 任何类型 | 接受任何值 | 可以(不安全) |
unknown | 未知类型,安全的 any | 只能赋给 unknown/any | 接受任何值 | 不行,必须先收窄 |
never | 不存在的类型 | 可赋给任何类型 | 不接受任何值 | 不适用 |
// ===== any =====
// 放弃类型检查,任何操作都不报错
let a: any = 'hello';
a.foo.bar.baz(); // 不报错,但运行时爆炸
a = 42;
a = true;
// ===== unknown =====
// 类型安全的 any,必须先收窄才能使用
let u: unknown = 'hello';
// u.toUpperCase(); // ❌ 错误:Object is of type 'unknown'
// 必须先进行类型收窄
if (typeof u === 'string') {
u.toUpperCase(); // ✅ OK
}
// ===== never =====
// 表示永远不会出现的值
function throwError(msg: string): never {
throw new Error(msg);
}
function infiniteLoop(): never {
while (true) {}
}
never 的高级用途:穷举检查
type Shape = 'circle' | 'square' | 'triangle';
function getArea(shape: Shape): number {
switch (shape) {
case 'circle':
return Math.PI * 10 ** 2;
case 'square':
return 10 * 10;
case 'triangle':
return (10 * 10) / 2;
default:
const _exhaustive: never = shape; // 如果漏掉某个 case,这里会报错
return _exhaustive;
}
}
// 假设后续新增了 'rectangle',但忘了加 case
// type Shape = 'circle' | 'square' | 'triangle' | 'rectangle';
// default 中 shape 类型变为 'rectangle',不能赋给 never,编译报错!
- 避免 any:每个
any都是类型安全的漏洞,用unknown替代 - unknown 用于外部输入:API 响应、
JSON.parse结果、第三方数据 - never 用于穷举检查:确保 switch/if 覆盖了所有情况
- ESLint 规则
@typescript-eslint/no-explicit-any可以禁止使用 any
Q3: type 和 interface 怎么选?
答案:
二者都能描述对象和函数契约,核心差异不在“谁更高级”,而在扩展方式:
interface支持声明合并,适合公开、可扩展的对象契约和类的implements。type可以直接表达联合、交叉、元组、条件类型、映射类型和原始类型别名。- 两者可以通过
extends或交叉类型组合,但复杂交叉出现属性冲突时可能得到never,接口继承冲突则会直接报错。
项目内两者都能表达时,遵循团队一致性即可。公共库若希望消费者做模块扩展,可优先接口;需要联合和类型运算时使用 type。不要把“接口一定更快”当成日常选型依据。
Q4: 联合类型和交叉类型是什么?
答案:
interface A {
a: string;
shared: number;
}
interface B {
b: boolean;
shared: number;
}
// 联合类型:值必须完全符合 A 或完全符合 B
type AorB = A | B;
const ab1: AorB = { a: 'hello', shared: 1 }; // OK (A)
const ab2: AorB = { b: true, shared: 2 }; // OK (B)
const ab3: AorB = { a: 'hello', b: true, shared: 3 }; // OK (满足 A 或 B)
// AorB 只能访问共同属性 shared
// 交叉类型:值必须同时满足 A 和 B
type AandB = A & B;
const aAndB: AandB = {
a: 'hello',
b: true,
shared: 1
}; // 必须有所有属性
// AandB 可以访问所有属性
Q5: 什么是可辨识联合?
答案:
给联合中的每个成员一个共同的字面量标记,例如 status: 'loading' | 'success' | 'error',通过该字段安全缩小类型。
它很适合前端状态机,能避免同时出现互相矛盾的字段,并配合 never 做穷尽检查。
Q6: TypeScript 有哪些常见类型守卫?
答案:
类型守卫通过运行时判断让编译器缩小联合类型,常见方式包括:
typeof:适合字符串、数字、布尔、函数等原始类型。instanceof:判断构造函数原型链,适合同一运行环境中的类实例。in:判断属性是否存在于对象或其原型链。- 可辨识联合:根据共同的字面量字段,例如
kind、status。 - 自定义谓词
value is T:把校验逻辑封装为可复用守卫。 - 断言函数
asserts value is T:不满足条件时抛错,成功后收窄后续代码。
类型守卫必须与真实运行时事实一致。外部 JSON 不能只写一次 as User,应通过 Schema 或完整字段检查后再进入可信类型域。
Q7: 泛型解决什么问题?
答案:
泛型在保持类型关系的前提下复用实现。例如输入 T 返回 T,调用者的具体类型不会退化成 unknown 或联合。
泛型参数应表达真实关联;只出现一次、没有约束关系的泛型通常没有价值。可用 extends 限制能力,用默认类型降低调用成本。
Q8: keyof、typeof 和索引访问类型怎么配合?
答案:
// 场景:从运行时值派生类型
const config = {
apiUrl: 'https://api.example.com',
timeout: 5000,
retries: 3
} as const;
// typeof:获取值的类型
type Config = typeof config;
// {
// readonly apiUrl: "https://api.example.com";
// readonly timeout: 5000;
// readonly retries: 3;
// }
// keyof:获取类型的键
type ConfigKey = keyof typeof config;
// 'apiUrl' | 'timeout' | 'retries'
// 获取值的类型
type ConfigValue = typeof config[ConfigKey];
// "https://api.example.com" | 5000 | 3
// 实际应用:创建 getter 函数
function getConfig<K extends keyof typeof config>(key: K): typeof config[K] {
return config[key];
}
const url = getConfig('apiUrl'); // "https://api.example.com"
const timeout = getConfig('timeout'); // 5000
Q9: 常用 Utility Types 的原理是什么?
答案:
Partial、Required、Readonly、Pick、Omit、Record 等大多由映射类型、keyof 和条件类型组合而来。
面试不只要背名字,还要说明语义。例如 Partial<T> 只是第一层可选,不会递归,也不能自动保证更新对象的业务合法性。
Q10: 条件类型是什么?
答案:
// 分发行为:联合类型会逐个应用条件类型
type ToArray<T> = T extends any ? T[] : never;
type Result = ToArray<string | number>;
// = ToArray<string> | ToArray<number>
// = string[] | number[]
// 不是 (string | number)[]
// 阻止分发:用元组包裹
type ToArrayNoDistribute<T> = [T] extends [any] ? T[] : never;
type Result2 = ToArrayNoDistribute<string | number>;
// = (string | number)[]
// 为什么?
// [string | number] extends [any] 作为整体判断
// 而不是分发到每个成员
// 实际应用:判断是否为 never
type IsNever<T> = [T] extends [never] ? true : false;
type Test1 = IsNever<never>; // true
type Test2 = IsNever<string>; // false
// 注意:不用 [T] 的话
type IsNeverBroken<T> = T extends never ? true : false;
type Test3 = IsNeverBroken<never>; // never!不是 true
// 因为 never 是空联合,分发后没有任何成员
Q11: infer 有什么作用?
答案:
infer 只能出现在条件类型的匹配位置,用来从一个已知结构中声明并提取未知类型。它相当于在类型模式匹配中引入局部类型变量。
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type Return<T> = T extends (...args: any[]) => infer R ? R : never;
type Element<T> = T extends readonly (infer U)[] ? U : never;
如果模式不匹配就走条件类型的另一分支。遇到联合类型时,条件类型可能分发并产生联合结果;复杂嵌套还要注意递归深度和编译性能。面试中应说明“提取位置”和“匹配失败”两件事,而不只是背 ReturnType。
Q12: 映射类型是什么?
答案:
映射类型遍历一组键并生成新属性,可修改只读和可选修饰符,也可通过 as 重映射键。
它适合从领域模型派生表单、权限和事件类型,但要防止派生链过深,让业务语义难以追踪。
- 映射类型会遍历
keyof T,对每个属性重新计算键、值和修饰符;+/-readonly、+/-?可添加或移除修饰符。 - 配合条件类型和键重映射
as,可以实现过滤键、重命名键和深层工具类型,但复杂度应服务于真实约束。
Q13: as const 和 satisfies 有什么区别?
答案:
as const 把字面量尽量收窄并设为只读;satisfies 检查表达式满足某个类型,同时尽量保留表达式自身更精确的推断。
配置对象常用 satisfies 校验结构,用 as const 生成字面量联合。二者都不会做运行时冻结或校验。
Q14: 类型断言为什么有风险?
答案:
断言是在告诉编译器“相信我”,不会生成运行时检查。它可能掩盖真实不确定性,尤其是外部数据的双重断言。
更好的顺序是改进推断、使用守卫/Schema、最后才在已被运行时事实保证的窄边界断言。
- 断言只影响编译器,不会在运行时校验或转换数据;断言错了,错误会推迟到线上。
- 外部输入应先用类型守卫或 Schema 校验收窄。只有开发者确实掌握编译器不知道的信息时才用断言,并避免连续
as unknown as T。
Q15: 函数重载和联合参数怎么选?
答案:
输入与输出存在多组明确对应关系时重载更清晰;只是同一逻辑接受几种输入且返回一致,联合参数通常更简单。
实现签名必须覆盖全部重载但对调用者不可见。过多重载会难维护,可考虑泛型或配置对象。
- 参数组合较少且不同输入对应不同返回类型时,重载能给调用方更准确的提示;实现签名要覆盖所有重载。
- 如果各分支返回类型相同,联合参数通常更简单。组合很多时优先考虑可辨识联合,避免重载列表和实现逐渐失配。
Q16: 什么是类型兼容性和结构类型?
答案:
TypeScript 主要按结构判断兼容,只要必要成员类型匹配,不要求显式声明同一名义类型。
这让对象组合灵活,但也可能把结构相同、语义不同的 ID 混用。关键领域值可用 branded type 等方式增加名义区分。
- TypeScript 主要采用结构类型:只要成员结构兼容就可以赋值,不要求显式继承。这非常适合 JavaScript 的鸭子类型生态。
- 函数参数还涉及严格函数类型和型变;对象字面量会有额外属性检查。需要名义类型时,可用私有字段或品牌类型模拟。
Q17: enum 有哪些取舍?
答案:
普通 enum 会生成运行时代码,提供命名空间和反向映射等行为;字符串字面量联合加 as const 通常更轻、更易与普通对象配合。
是否使用取决于项目约定、运行时需求和发布目标。库代码还要谨慎对待 const enum 的跨编译兼容。
Q18: tsconfig 中哪些选项最关键?
答案:
首先开启 strict,再根据运行环境配置 target、module、moduleResolution、lib 和 JSX。项目引用、路径别名和声明输出用于大型仓库或库构建。
编译器配置必须与实际运行时和构建工具一致;paths 只影响类型解析,不一定会改运行时导入路径。
Q19: .d.ts 文件有什么作用?
答案:
声明文件描述现有 JavaScript 模块或全局对象的类型,不包含业务实现。库可随包发布声明,也可由 TypeScript 构建生成。
扩展第三方模块要使用正确的 module augmentation,避免随意声明全局 any 让整个项目失去类型保护。
Q20: TypeScript 在 React 中常见的类型设计原则是什么?
答案:
React 类型设计重点是让组件 API 表达真实约束,而不是给每个变量补一个显式类型:
- Props 优先描述最小公开契约,互斥状态用可辨识联合,避免多个布尔值形成非法组合。
- 让函数组件的返回值和 Hook 的大部分类型自然推断,不必默认使用
React.FC。 - DOM 事件、元素引用和
children使用 React 提供的具体类型,不要退化成any。 - Context 若无合理默认值,使用
T | null并在自定义 Hook 中集中校验。 - 服务端响应先做运行时校验,再交给组件;TypeScript 不能保证网络数据真实符合接口。
- 泛型组件要保留调用方的键和值关系,但不要为了“通用”设计难以推断的类型体操。
Q21: noUncheckedIndexedAccess 解决什么问题?
答案:
默认情况下,Record<string, T> 或数组索引访问常被推断为 T,即使这个键或下标运行时可能不存在。noUncheckedIndexedAccess 会把未被证明存在的访问结果扩展为 T | undefined。
const scores: Record<string, number> = {};
const value = scores['missing'];
// 开启后:number | undefined
它能暴露字典、环境变量和数组越界假设,但也会增加收窄代码。启用后应通过 Map、明确键联合、边界判断或封装访问函数表达真实约束,而不是到处使用非空断言 ! 抹掉检查。
Q22: interface 和 type 可以互相扩展吗?
答案:
可以,它们可以互相扩展:
// type 扩展 interface
interface Animal {
name: string;
}
type Dog = Animal & {
breed: string;
};
// interface 扩展 type
type Person = {
name: string;
age: number;
};
interface Employee extends Person {
employeeId: number;
department: string;
}
// interface 扩展多个 type
type HasName = { name: string };
type HasAge = { age: number };
interface User extends HasName, HasAge {
email: string;
}
Q23: 什么是泛型参数默认值?什么时候适合使用?
答案:
泛型参数可以像函数参数一样提供默认类型,让调用方在常见场景下省略显式参数:
interface ApiResult<TData, TError = Error> {
data?: TData;
error?: TError;
}
有默认值的参数通常放在必填泛型参数之后。默认类型应代表最常见且安全的语义,不能为了少报错默认成 any。
如果编译器能从实参推断出类型,会优先使用推断结果;只有无法推断且调用方未显式传入时才使用默认值。默认值与 extends 约束同时存在时,默认类型也必须满足约束。
Q24: 为什么 TypeScript 类型不能替代运行时数据校验?
答案:
TypeScript 类型在编译后会被擦除,无法验证接口响应、URL 参数、Local Storage、消息事件或用户输入。写 response.json() as User 只是跳过检查,不会把错误数据变成 User。
可靠边界通常是:
- 外部输入先视为
unknown。 - 使用 Schema 库或完整的自定义守卫做运行时校验。
- 校验成功后得到可信类型,失败时返回结构化错误。
- 内部代码再依赖 TypeScript 做静态约束。
这样能把“不可信世界”和“可信领域模型”分开,也避免前后端接口漂移直到线上才暴露。
Q25: 类型断言和类型转换的区别?
答案:
// 类型断言:只影响编译时,不改变运行时行为
const value: unknown = '123';
const num = value as number; // 编译时认为是 number
console.log(typeof num); // 运行时仍是 'string'
// 类型转换:改变运行时的值
const converted = Number(value); // 真正转换为数字
console.log(typeof converted); // 'number'
// 对比
const str = '123';
const asNum = str as unknown as number; // 类型断言,str 还是字符串
const realNum = parseInt(str, 10); // 类型转换,realNum 是数字
Q26: 可选属性和 T | undefined 有什么区别?
答案:
prop?: T 表示属性可以不存在;prop: T | undefined 表示属性必须存在,但值可以是 undefined。这会影响 in 判断、对象展开、序列化和补丁语义。
开启 exactOptionalPropertyTypes 后,若没有显式写入 undefined,可选属性不能随意赋值为 undefined:
interface Options {
theme?: 'dark' | 'light';
}
const options: Options = {};
// options.theme = undefined; // 开启该选项后可能报错
delete options.theme; // 明确表示属性不存在
API 的 PATCH 模型中,“未提供”和“显式清空”经常含义不同,应在类型里明确区分。
Q27: noImplicitOverride 有什么价值?
答案:
开启 noImplicitOverride 后,子类覆盖父类成员时必须写 override。它能防止父类重命名或删除方法后,子类留下一个看似覆盖、实际已变成无关新方法的实现。
class Base {
save(): void {}
}
class Child extends Base {
override save(): void {}
}
如果父类不存在对应成员,override 会报错;确实覆盖却漏写也会报错。它不能解决继承本身的耦合问题,但能让框架生命周期和领域基类的重写关系更明确。
Q28: 实现 Exclude 和 Extract
答案:
// Exclude:从 T 中排除可赋值给 U 的类型
type MyExclude<T, U> = T extends U ? never : T;
// 工作原理(利用分发)
type Test = MyExclude<'a' | 'b' | 'c', 'a'>;
// = ('a' extends 'a' ? never : 'a')
// | ('b' extends 'a' ? never : 'b')
// | ('c' extends 'a' ? never : 'c')
// = never | 'b' | 'c'
// = 'b' | 'c'
// Extract:从 T 中提取可赋值给 U 的类型
type MyExtract<T, U> = T extends U ? T : never;
type Test2 = MyExtract<'a' | 'b' | 'c', 'a' | 'b'>;
// = 'a' | 'b'
Q29: 实现 Readonly 和 Mutable
答案:
// Readonly:添加 readonly 修饰符
type MyReadonly<T> = {
readonly [P in keyof T]: T[P];
};
// Mutable:移除 readonly 修饰符
type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};
// 测试
interface Example {
readonly a: string;
b: number;
}
type Mut = Mutable<Example>;
// { a: string; b: number }
type RO = MyReadonly<Example>;
// { readonly a: string; readonly b: number }
Q30: let 和 const 推断的区别?
答案:
// let:推断为基础类型(可变)
let str = 'hello'; // string
let num = 42; // number
let arr = [1, 2, 3]; // number[]
// const:推断为字面量类型(不可变)
const constStr = 'hello'; // "hello"
const constNum = 42; // 42
// 但对象和数组仍推断为可变类型
const obj = { name: 'Alice' }; // { name: string }
const constArr = [1, 2, 3]; // number[]
// 使用 as const 获得完全不可变类型
const tuple = [1, 2, 3] as const; // readonly [1, 2, 3]
const constObj = { name: 'Alice' } as const;
// { readonly name: "Alice" }
Q31: 装饰器的执行顺序是什么?
答案:
function ClassDec() {
console.log('ClassDec: evaluated');
return (constructor: Function) => console.log('ClassDec: executed');
}
function MethodDec() {
console.log('MethodDec: evaluated');
return (t: any, k: string, d: PropertyDescriptor) =>
console.log('MethodDec: executed');
}
function PropDec() {
console.log('PropDec: evaluated');
return (t: any, k: string) => console.log('PropDec: executed');
}
function ParamDec() {
console.log('ParamDec: evaluated');
return (t: any, k: string, i: number) => console.log('ParamDec: executed');
}
@ClassDec()
class Example {
@PropDec()
prop = 1;
@MethodDec()
method(@ParamDec() arg: string) {}
}
// 执行顺序:
// 1. 属性装饰器:PropDec evaluated -> PropDec executed
// 2. 参数装饰器:ParamDec evaluated -> ParamDec executed
// 3. 方法装饰器:MethodDec evaluated -> MethodDec executed
// 4. 类装饰器:ClassDec evaluated -> ClassDec executed
// 多个装饰器:从上到下求值,从下到上执行
Q32: strictFunctionTypes 为什么和函数参数安全有关?
答案:
函数参数存在型变问题:一个只能处理更具体类型的函数,不能安全地替代一个需要处理更宽类型的回调,否则调用方可能传入它无法处理的值。
开启 strictFunctionTypes 后,普通函数类型的参数会更严格地按逆变方向检查,减少回调赋值漏洞。方法声明为了部分兼容性仍可能表现为双变,因此 API 设计不能只依赖编译器侥幸通过。
面试时可以概括为:返回值可以更具体,输入参数不能擅自变窄。事件处理器、比较器和中间件链是最常见的实际场景。
Q33: 为什么要特殊处理函数类型?
答案:
// 不处理函数的问题
type BrokenDeepReadonly<T> = {
readonly [K in keyof T]: BrokenDeepReadonly<T[K]>;
};
// 当 T 是函数时
type Fn = () => void;
type Test = BrokenDeepReadonly<Fn>;
// {} 或错误结果,因为函数的 keyof 是其静态属性
// 函数应该保持原样
type DeepReadonly<T> = T extends (...args: any[]) => any
? T // 函数直接返回
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
type Test2 = DeepReadonly<Fn>;
// () => void,保持不变
Q34: 如何将对象键从 camelCase 转换为 kebab-case?
答案:
// 驼峰转连字符
type CamelToKebab<S extends string> = S extends `${infer C}${infer Rest}`
? C extends Uppercase<C>
? C extends Lowercase<C>
? `${C}${CamelToKebab<Rest>}`
: `-${Lowercase<C>}${CamelToKebab<Rest>}`
: `${C}${CamelToKebab<Rest>}`
: '';
// 转换对象键
type KeysToKebab<T> = {
[K in keyof T as CamelToKebab<K & string>]: T[K];
};
// 测试
interface CamelStyle {
backgroundColor: string;
fontSize: number;
marginTop: string;
}
type KebabStyle = KeysToKebab<CamelStyle>;
// {
// 'background-color': string;
// 'font-size': number;
// 'margin-top': string;
// }
Q35: strict 模式包含哪些检查?
答案:
{
"strict": true
}
// 等同于开启以下所有选项:
| 选项 | 作用 |
|---|---|
strictNullChecks | null/undefined 需显式处理 |
strictFunctionTypes | 函数参数逆变检查 |
strictBindCallApply | bind/call/apply 类型检查 |
strictPropertyInitialization | 类属性必须初始化 |
noImplicitAny | 禁止隐式 any |
noImplicitThis | 禁止隐式 this |
alwaysStrict | 输出 "use strict" |
useUnknownInCatchVariables | catch 变量为 unknown |
Q36: React.FC 的问题是什么?
答案:
// 1. 隐式 children(React 18 已移除)
const Comp: React.FC<Props> = ({ children }) => {
// 之前:children 自动可用
// React 18:需要显式定义
};
// 2. 不支持泛型
// ❌ 不能这样写
const List: React.FC<ListProps<T>> = ...
// ✅ 直接定义
function List<T>(props: ListProps<T>) { ... }
// 3. defaultProps 支持不好
// ❌ 类型推断有问题
Comp.defaultProps = { };
// ✅ 使用参数默认值
function Comp({ value = 'default' }: Props) { ... }
// 推荐写法:直接定义函数,不用 React.FC
interface Props {
title: string;
children?: React.ReactNode;
}
function MyComponent({ title, children }: Props) {
return <div>{title}{children}</div>;
}
Q37: TypeScript 的 readonly 和 Object.freeze() 有什么区别?
答案:
readonly 是编译期约束,编译后不存在;Object.freeze() 是运行时操作,阻止当前对象自身属性被重新配置或赋值。
二者默认都不是通用的“深度不可变”方案:
Readonly<T>只处理一层属性,嵌套对象仍需递归类型。Object.freeze()只冻结当前对象,嵌套对象仍可变化。- 类型断言、JavaScript 调用方或共享引用仍可能绕过编译期只读。
- 冻结对象也不等于使用持久化数据结构,频繁深冻结可能有运行时成本。
对外 API 用 readonly 表达契约;确实需要运行时防护时再选择冻结、复制或不可变数据结构。
Q38: TypeScript 的声明合并是什么?使用时有什么风险?
答案:
同名的部分声明可以在类型层合并,最常见的是多个 interface 合并成员,以及通过模块扩充为第三方模块补充类型。它适合描述会被插件扩展的开放接口,或补齐真实存在的运行时 API。
风险在于声明合并会影响整个全局或模块:同名接口可能意外合并;扩充出的成员若运行时不存在,会让类型与行为脱节;第三方升级后还可能与本地补丁冲突。
应把扩充放在明确的声明文件中,使用精确模块名,配合运行时实现和版本测试,而不是用它掩盖不兼容。
Q39: TypeScript 的 const 类型参数解决什么问题?
答案:
const 类型参数让泛型调用更倾向保留对象、数组等参数的字面量结构,而不是立即把字符串扩宽为 string、把元组扩宽为普通数组。它适合路由、事件名和配置 DSL 等需要从输入字面量推导精确类型的 API。
它只影响类型推断,不会让运行时对象不可变,也不等同于对调用参数写 as const。约束仍要允许推断出的只读形态,否则可能因约束不兼容而回退到更宽类型。
只在精确字面量确实改善调用体验时使用;过度保留字面量会让类型巨大、错误信息复杂。
Q40: 类型谓词 is 和断言函数 asserts 有什么区别?
答案:
value is T 返回布尔值,调用方可以在条件分支中收窄:
function isUser(value: unknown): value is User {
return typeof value === 'object' && value !== null && 'id' in value;
}
if (isUser(input)) {
input.id;
}
asserts value is T 表示函数正常返回后条件一定成立;失败时函数应抛错,适合集中式前置条件:
function assertUser(value: unknown): asserts value is User {
if (!isUser(value)) throw new Error('Invalid user');
}
两者都必须真的执行运行时检查,不能无条件返回 true 或不校验就断言,否则只是把不安全隐藏到类型系统背后。
Q41: TypeScript 的 Excess Property Checking 什么时候会发生?
答案:
对象字面量直接赋给目标类型,或直接作为带明确参数类型的实参时,TypeScript 会额外检查目标类型之外的属性,帮助发现拼写错误。先把对象保存到变量再赋值时,通常改用结构兼容性判断,多余属性不一定报错。
这不是运行时过滤,也不代表对象只剩目标字段。需要拒绝接口额外字段时,仍要用运行时 Schema 校验;需要在字面量处校验又保留具体类型时,可评估 satisfies。
面试时应说明它是一项针对“新鲜对象字面量”的额外检查,不是 TypeScript 结构类型规则失效。
Q42: 如何处理联合类型收窄?
答案:
type Result =
| { type: 'text'; content: string }
| { type: 'image'; url: string; alt: string }
| { type: 'video'; src: string; duration: number };
// 方法1:switch + 类型标签
function render(result: Result) {
switch (result.type) {
case 'text':
return renderText(result.content);
case 'image':
return renderImage(result.url, result.alt);
case 'video':
return renderVideo(result.src, result.duration);
}
}
// 方法2:in 操作符
function processResult(result: Result) {
if ('content' in result) {
// result: { type: 'text'; content: string }
console.log(result.content);
} else if ('url' in result) {
// result: { type: 'image'; url: string; alt: string }
console.log(result.alt);
} else {
// result: { type: 'video'; src: string; duration: number }
console.log(result.duration);
}
}
// 方法3:类型守卫函数
function isImage(result: Result): result is Extract<Result, { type: 'image' }> {
return result.type === 'image';
}
if (isImage(result)) {
console.log(result.url);
}
Q43: 实现 Pick<T, K> 和 Omit<T, K>
答案:
// Pick:从 T 中选择 K 中的属性
type MyPick<T, K extends keyof T> = {
[P in K]: T[P];
};
// Omit:从 T 中排除 K 中的属性
type MyOmit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
// 或者直接实现
type MyOmit2<T, K extends keyof any> = {
[P in keyof T as P extends K ? never : P]: T[P];
};
// 测试
interface Example {
a: string;
b: number;
c: boolean;
}
type Picked = MyPick<Example, 'a' | 'b'>; // { a: string; b: number }
type Omitted = MyOmit<Example, 'c'>; // { a: string; b: number }
Q44: 如何阻止某个泛型参数参与类型推断?
答案:
当一个类型参数应由主要参数推断,而另一个参数只能被它校验时,可以使用内置工具类型 NoInfer<T> 阻止后者反向影响推断结果。
function createState<T>(
initial: T,
fallback: NoInfer<T>,
): T {
return initial ?? fallback;
}
createState('ready', 'loading');
// createState('ready', 0); // fallback 不能把 T 扩成 string | number
它适合默认值、状态机和配置 API。不要用它掩盖本就含糊的泛型关系;如果多个参数都应该共同决定类型,就不该阻止推断。
Q45: 实现 Partial 和 Required
答案:
// Partial:添加可选修饰符
type MyPartial<T> = {
[P in keyof T]?: T[P];
};
// Required:移除可选修饰符
type MyRequired<T> = {
[P in keyof T]-?: T[P];
};
// 测试
interface Example {
a: string;
b?: number;
}
type Part = MyPartial<Example>;
// { a?: string; b?: number }
type Req = MyRequired<Example>;
// { a: string; b: number }
Q46: 什么是上下文类型推断?
答案:
// 上下文类型:TypeScript 根据表达式出现的位置推断类型
// 1. 函数参数位置
window.onclick = (event) => {
// event 从 onclick 类型推断为 MouseEvent
console.log(event.button);
};
// 2. 数组方法回调
['a', 'b'].map((item) => {
// item 从数组元素类型推断为 string
return item.toUpperCase();
});
// 3. 对象字面量属性
interface Handlers {
onClick: (x: number, y: number) => void;
onHover: (element: HTMLElement) => void;
}
const handlers: Handlers = {
onClick: (x, y) => {
// x, y 从接口推断为 number
},
onHover: (el) => {
// el 从接口推断为 HTMLElement
}
};
// 4. 返回值推断
function returnsHandler(): (n: number) => string {
return (n) => {
// n 从返回类型推断为 number
return n.toString();
};
}
Q47: 实验性装饰器和标准装饰器的区别?
答案:
| 特性 | 实验性装饰器 | 标准装饰器 (TS 5.0+) |
|---|---|---|
| 配置 | 需要 experimentalDecorators | 无需配置 |
| 参数 | (target, key, descriptor) | (target, context) |
| 元数据 | 支持 emitDecoratorMetadata | 不支持 |
| 参数装饰器 | 支持 | 不支持 |
| Accessor | 不支持 | 支持 accessor 关键字 |
| 规范 | 旧提案 | ECMAScript 标准 |
// 实验性装饰器
function OldLog(
target: any,
key: string,
descriptor: PropertyDescriptor
) {
// target: 类的原型
// key: 方法名
// descriptor: 属性描述符
}
// 标准装饰器
function NewLog<T extends (...args: any[]) => any>(
target: T,
context: ClassMethodDecoratorContext
): T {
// target: 方法本身
// context: 包含名称、类型等元数据
return target;
}
Q48: 如何阻止条件类型对联合类型自动分发?
答案:
当条件类型左侧是裸类型参数时,传入联合类型会逐个成员分发。例如 T extends unknown ? T[] : never 对 string | number 得到 string[] | number[]。
若希望把联合类型作为整体判断,可以把两侧包进单元素元组:[T] extends [unknown] ? T[] : never,结果就是 (string | number)[]。
实现过滤联合成员时分发很有用;判断“整个联合是否满足条件”时通常要关闭。复杂工具类型应为 never、any 和联合类型补测试,避免只在单一类型上正确。
Q49: DeepReadonly 为什么要特殊处理数组和函数?
答案:
简单地对所有 object 做映射会把数组的方法和索引一起展开,也可能破坏函数调用签名。深度工具类型应先匹配特殊结构,再处理普通对象:
type DeepReadonly<T> =
T extends (...args: any[]) => any
? T
: T extends readonly (infer U)[]
? ReadonlyArray<DeepReadonly<U>>
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
真实项目还要决定如何处理 Map、Set、Date、Promise 和递归类型。深度工具类型不是越“万能”越好,应根据领域数据范围设定边界,避免类型实例化过深。
Q50: 如何从路由路径中提取参数类型?
答案:
// 方法一:递归提取
type ExtractParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractParams<Rest>
: T extends `${string}:${infer Param}`
? Param
: never;
type Params = ExtractParams<'/api/:version/users/:userId/posts/:postId'>;
// 'version' | 'userId' | 'postId'
// 方法二:构建对象类型
type ParamsObject<T extends string> = {
[K in ExtractParams<T>]: string;
};
type ParamsObj = ParamsObject<'/users/:id/posts/:postId'>;
// { id: string; postId: string }
// 类型安全的路由函数
function navigate<T extends string>(
path: T,
params: ParamsObject<T>
): void {
// ...
}
navigate('/users/:id', { id: '123' }); // OK
// navigate('/users/:id', {}); // 错误:缺少 id
Q51: module 和 moduleResolution 如何选择?
答案:
// 前端项目(Vite/Webpack)
{
"module": "ESNext",
"moduleResolution": "bundler"
}
// Node.js ESM
{
"module": "NodeNext",
"moduleResolution": "NodeNext"
}
// Node.js CommonJS
{
"module": "CommonJS",
"moduleResolution": "node"
}
// 库(支持多种环境)
{
"module": "ESNext",
"moduleResolution": "bundler"
}
规则:
bundler:配合打包工具使用NodeNext:Node.js 原生 ESMnode:Node.js CommonJS
Q52: 如何处理 useRef 的类型?
答案:
// 三种场景:
// 1. DOM 引用(只读)
const divRef = useRef<HTMLDivElement>(null);
// divRef.current 是 HTMLDivElement | null
// 传递给 JSX:<div ref={divRef}>
// 2. 可变值容器
const countRef = useRef<number>(0);
// countRef.current 是 number
// 可以直接修改:countRef.current = 1
// 3. 可能为 null 的可变值
const timerRef = useRef<NodeJS.Timeout | null>(null);
timerRef.current = setTimeout(() => {}, 1000);
clearTimeout(timerRef.current!);
// 区分关键:
// useRef<T>(null) 且 T 不含 null → 只读,用于 DOM
// useRef<T | null>(null) → 可变
// useRef<T>(initialValue) → 可变
Q53: TypeScript 中的 ! 和 ? 的区别?
答案:
| 符号 | 名称 | 作用 |
|---|---|---|
? | 可选链/可选属性 | 表示属性可能不存在 |
! | 非空断言 | 告诉编译器值一定存在 |
// ? 可选属性
interface User {
name: string;
email?: string; // 可选
}
// ? 可选链
const email = user?.email?.toLowerCase();
// ! 非空断言(告诉编译器这里一定有值)
const element = document.getElementById('app')!;
element.innerHTML = 'Hello';
// ! 明确赋值断言
class Example {
name!: string; // 告诉编译器会在其他地方初始化
initialize() {
this.name = 'initialized';
}
}
! 非空断言会跳过类型检查,如果值实际为 null/undefined 会导致运行时错误。应谨慎使用,优先使用类型守卫。
Q54: interface 和 type 有运行时或编译性能差异吗?
答案:
二者都会在编译后擦除,因此通常没有运行时性能差异。
类型检查性能也不能简单概括为“interface 一定更快”。接口继承的关系更容易被编译器缓存,而大型交叉类型、分发条件类型和深递归类型可能增加检查成本;但普通业务模型的差异通常不值得作为选型依据。
真正遇到编辑器或 CI 类型检查变慢时,应使用 TypeScript 的诊断与 trace 工具定位高实例化类型,简化递归、巨型联合和重复交叉,而不是全仓机械替换 type 或 interface。
Q55: 如何约束泛型为特定类型?
答案:
使用 extends 关键字约束泛型:
// 约束为特定接口
interface HasId {
id: number;
}
function updateEntity<T extends HasId>(entity: T): T {
entity.id = entity.id + 1;
return entity;
}
// 约束为对象类型
function merge<T extends object, U extends object>(a: T, b: U): T & U {
return { ...a, ...b };
}
// 约束为函数
function call<T extends (...args: any[]) => any>(
fn: T,
...args: Parameters<T>
): ReturnType<T> {
return fn(...args);
}
// 约束为类构造函数
function create<T>(Constructor: new () => T): T {
return new Constructor();
}
Q56: 如何实现穷尽检查?
答案:
使用 never 类型确保所有情况都被处理:
type Action =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' }
| { type: 'RESET' };
function assertNever(x: never): never {
throw new Error('Unexpected action: ' + JSON.stringify(x));
}
function reducer(state: number, action: Action): number {
switch (action.type) {
case 'INCREMENT':
return state + 1;
case 'DECREMENT':
return state - 1;
case 'RESET':
return 0;
default:
// 如果新增了 Action 类型但忘记处理,这里会报编译错误
return assertNever(action);
}
}
Q57: 如何用品牌类型避免相同基础类型被误用?
答案:
TypeScript 是结构类型系统,UserId 和 OrderId 若都只是 string 就能互相传递。品牌类型通过交叉一个不会在业务中自然出现的标记,让它们在类型层不再兼容。
常用做法是用 unique symbol 声明只读品牌,再由 parseUserId(value) 之类的工厂在完成运行时校验后返回 UserId。
断言应集中在解析边界,业务代码不要到处写 as UserId。品牌只提供编译期区分,序列化后仍是普通字符串,跨网络或存储读取后必须重新校验并构造。
Q58: 什么情况下交叉类型会产生 never?
答案:
// 基本类型交叉 -> never
type Impossible = string & number; // never
type Also = boolean & null; // never
// 冲突的对象属性
interface X { prop: string }
interface Y { prop: number }
type XY = X & Y;
// XY 不是 never,但 prop 是 never
const xy: XY = {
prop: 'hello' as never // 无法正确赋值
};
// 字面量冲突
type A = { status: 'active' };
type B = { status: 'inactive' };
type AB = A & B;
// AB.status = 'active' & 'inactive' = never
// 可兼容的交叉
type Good = { a: string } & { b: number }; // OK
type Also = string & { length: number }; // OK, string 有 length
Q59: 实现 Parameters<T> 和 ReturnType<T>
答案:
// Parameters:获取函数参数类型元组
type MyParameters<T extends (...args: any) => any> =
T extends (...args: infer P) => any ? P : never;
// ReturnType:获取函数返回类型
type MyReturnType<T extends (...args: any) => any> =
T extends (...args: any) => infer R ? R : any;
// 使用 infer 在条件类型中推断类型
// 测试
type Fn = (a: string, b: number) => boolean;
type Params = MyParameters<Fn>; // [a: string, b: number]
type Return = MyReturnType<Fn>; // boolean
Q60: 实现 Awaited(解包 Promise)
答案:
// 递归解包 Promise
type MyAwaited<T> = T extends Promise<infer U>
? MyAwaited<U>
: T;
// 测试
type A1 = MyAwaited<Promise<string>>; // string
type A2 = MyAwaited<Promise<Promise<number>>>; // number
type A3 = MyAwaited<boolean>; // boolean
// 更完善:处理 PromiseLike
type Awaited2<T> = T extends null | undefined
? T
: T extends object & { then(onfulfilled: infer F): any }
? F extends (value: infer V) => any
? Awaited2<V>
: never
: T;