跳到主要内容

TypeScript 热门面试题

使用说明

本篇共 60 道题。

TypeScript 面试重点是能否用类型表达真实约束,而不是堆复杂体操。回答时先讲用途和安全边界,再给简单类型关系。

Q1: TypeScript 给 JavaScript 带来了什么?

答案

  1. 编译时类型检查:在代码运行前发现错误
  2. 更好的 IDE 支持:智能提示、自动补全、重构
  3. 代码可读性:类型即文档,易于理解
  4. 可维护性:大型项目更易维护
  5. 渐进式采用:可逐步迁移,与 JS 兼容
// 类型检查示例
function processUser(user: { name: string; age: number }) {
// IDE 自动提示 user 的属性
console.log(user.name.toUpperCase());
// user.email; // 编译错误:属性 'email' 不存在
}

Q2: anyunknownnever 有什么区别?

答案

这三个类型分别代表 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,编译报错!
使用建议
  1. 避免 any:每个 any 都是类型安全的漏洞,用 unknown 替代
  2. unknown 用于外部输入:API 响应、JSON.parse 结果、第三方数据
  3. never 用于穷举检查:确保 switch/if 覆盖了所有情况
  4. ESLint 规则 @typescript-eslint/no-explicit-any 可以禁止使用 any

Q3: typeinterface 怎么选?

答案

二者都能描述对象和函数契约,核心差异不在“谁更高级”,而在扩展方式:

  • 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 有哪些常见类型守卫?

答案

类型守卫通过运行时判断让编译器缩小联合类型,常见方式包括:

  1. typeof:适合字符串、数字、布尔、函数等原始类型。
  2. instanceof:判断构造函数原型链,适合同一运行环境中的类实例。
  3. in:判断属性是否存在于对象或其原型链。
  4. 可辨识联合:根据共同的字面量字段,例如 kindstatus
  5. 自定义谓词 value is T:把校验逻辑封装为可复用守卫。
  6. 断言函数 asserts value is T:不满足条件时抛错,成功后收窄后续代码。

类型守卫必须与真实运行时事实一致。外部 JSON 不能只写一次 as User,应通过 Schema 或完整字段检查后再进入可信类型域。

Q7: 泛型解决什么问题?

答案

泛型在保持类型关系的前提下复用实现。例如输入 T 返回 T,调用者的具体类型不会退化成 unknown 或联合。

泛型参数应表达真实关联;只出现一次、没有约束关系的泛型通常没有价值。可用 extends 限制能力,用默认类型降低调用成本。

Q8: keyoftypeof 和索引访问类型怎么配合?

答案

// 场景:从运行时值派生类型

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 的原理是什么?

答案

PartialRequiredReadonlyPickOmitRecord 等大多由映射类型、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 constsatisfies 有什么区别?

答案

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,再根据运行环境配置 targetmodulemoduleResolutionlib 和 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

可靠边界通常是:

  1. 外部输入先视为 unknown
  2. 使用 Schema 库或完整的自定义守卫做运行时校验。
  3. 校验成功后得到可信类型,失败时返回结构化错误。
  4. 内部代码再依赖 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
}
// 等同于开启以下所有选项:
选项作用
strictNullChecksnull/undefined 需显式处理
strictFunctionTypes函数参数逆变检查
strictBindCallApplybind/call/apply 类型检查
strictPropertyInitialization类属性必须初始化
noImplicitAny禁止隐式 any
noImplicitThis禁止隐式 this
alwaysStrict输出 "use strict"
useUnknownInCatchVariablescatch 变量为 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 的 readonlyObject.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[] : neverstring | number 得到 string[] | number[]

若希望把联合类型作为整体判断,可以把两侧包进单元素元组:[T] extends [unknown] ? T[] : never,结果就是 (string | number)[]

实现过滤联合成员时分发很有用;判断“整个联合是否满足条件”时通常要关闭。复杂工具类型应为 neverany 和联合类型补测试,避免只在单一类型上正确。

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 原生 ESM
  • node: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: interfacetype 有运行时或编译性能差异吗?

答案

二者都会在编译后擦除,因此通常没有运行时性能差异。

类型检查性能也不能简单概括为“interface 一定更快”。接口继承的关系更容易被编译器缓存,而大型交叉类型、分发条件类型和深递归类型可能增加检查成本;但普通业务模型的差异通常不值得作为选型依据。

真正遇到编辑器或 CI 类型检查变慢时,应使用 TypeScript 的诊断与 trace 工具定位高实例化类型,简化递归、巨型联合和重复交叉,而不是全仓机械替换 typeinterface

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 是结构类型系统,UserIdOrderId 若都只是 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;

相关链接