TypeScript核心

22 分钟

为什么大厂面试越来越重视 TypeScript

几年前前端面试几乎不考 TypeScript,如今中大厂的面试中 TS 相关问题已经成为标配。原因不复杂:当项目规模膨胀到几十万行代码、几十人协作时,纯 JavaScript 的"自由"就变成了负担——函数签名不明确、重构靠全局搜索、线上频繁出现 Cannot read properties of undefined。TypeScript 通过静态类型系统,把大量运行时才能发现的错误提前到编译期,同时给 IDE 提供了精准的代码补全和跳转能力。

这篇文章按面试高频考点组织:先讲 TS 的核心价值,再拆解类型系统的关键概念,最后进入泛型、工具类型和类型体操等高级话题。

TypeScript 相比 JavaScript 的核心价值

面试追问:"TS 和 JS 的本质区别是什么?TS 能做到哪些 JS 做不到的事?"

三个核心价值:

类型安全:编译期捕获类型错误,避免 undefined is not a function 这类运行时崩溃。一个典型场景——接口返回的字段从 string 变成了 string | null,纯 JS 项目可能直到线上报错才发现,TS 项目在编译时就会标红所有未处理 null 的调用点。

开发体验:精确的代码补全、跳转到定义、重命名重构。当你在一个 500 个文件的项目中修改一个接口字段名,TS + IDE 能自动定位所有引用并提示修改,JS 只能靠全局搜索和祈祷。

可维护性:类型本身就是最好的文档。函数签名写清楚参数类型和返回值,新人接手不用猜"这个 options 参数到底长什么样"。

// JS 时代:options 是什么结构?看文档?文档过期了?
function createUser(options) {
  // ...
}

// TS 时代:类型即文档,IDE 直接提示
interface CreateUserOptions {
  name: string;
  email: string;
  role?: 'admin' | 'user';
}

function createUser(options: CreateUserOptions): Promise<User> {
  // ...
}

需要强调一点:TypeScript 是 JavaScript 的超集,最终编译产物是 JS。TS 的类型信息在编译后会被完全擦除,不会影响运行时行为。这意味着 TS 的类型系统是零运行时开销的。

基础类型与类型注解

TypeScript 的类型注解语法是在变量、参数、返回值后面加 : Type

// 基本类型
const userName: string = '南祎';
const age: number = 25;
const isActive: boolean = true;

// 数组
const scores: number[] = [90, 85, 92];
const names: Array<string> = ['Alice', 'Bob'];

// 元组:固定长度和类型的数组
const pair: [string, number] = ['age', 25];

// 枚举(后面单独讲)
enum Status { Active, Inactive }

// any、unknown、never、void
let flexible: any = 42;       // 跳过类型检查,尽量别用
let safe: unknown = 42;       // 类型安全的 any,使用前必须收窄
function throwError(): never { // 永远不会正常返回
  throw new Error('boom');
}
function logMessage(msg: string): void { // 没有返回值
  console.log(msg);
}

面试追问:anyunknown 的区别是什么?

any 关闭了类型检查,对它做任何操作都不会报错。unknown 则要求你在使用前先进行类型收窄——不收窄就用,编译器直接报错。在需要表示"不确定类型"的场景下,优先用 unknown,它保留了类型安全。

function processValue(value: unknown) {
  // value.toUpperCase(); // 编译错误:Object is of type 'unknown'
  if (typeof value === 'string') {
    value.toUpperCase(); // 收窄后可以安全使用
  }
}

interface vs type:区别与选择

这是面试中出现频率极高的问题。两者在大多数场景下可以互换,但有几个关键差异:

相同点

都能描述对象结构、函数签名,都支持泛型,都可以被 extends 或交叉类型组合。

// interface 描述对象
interface User {
  name: string;
  age: number;
}

// type 描述对象
type UserType = {
  name: string;
  age: number;
};

核心差异

特性interfacetype
声明合并✅ 同名 interface 自动合并❌ 同名 type 直接报错
扩展方式extends 关键字交叉类型 &
描述能力只能描述对象/函数/类可以描述联合类型、元组、基本类型别名等
计算属性不支持映射类型支持 in 映射
// 声明合并:interface 独有能力
interface Window {
  customProperty: string;
}
// 这会和 lib.dom.d.ts 中的 Window 合并,而不是冲突

// 联合类型:type 独有能力
type Result = Success | Failure;
type ID = string | number;

// 元组
type Pair = [string, number];

选择原则

  • 描述对象结构、类的公共 API → 优先用 interface,可以利用声明合并的扩展能力
  • 需要联合类型、交叉类型、元组、条件类型等复杂类型运算 → 用 type
  • 团队统一即可,不必纠结

面试时把上面这张表说清楚,再给一个声明合并的例子,基本就过关了。

泛型:类型的"参数化"

泛型是 TypeScript 类型系统的核心能力。简单说,泛型就是把类型当参数传,让函数、接口、类能够适配多种类型而不丢失类型信息。

基本语法

// 没有泛型:返回 any,类型信息丢失
function identity(value: any): any {
  return value;
}

// 有泛型:类型信息保留
function identity<T>(value: T): T {
  return value;
}

const result = identity<string>('hello'); // result 的类型是 string
const inferred = identity(42);           // result 的类型是 number(自动推导)

泛型约束

当你需要限制泛型参数的范围时,用 extends 关键字。

// 约束 T 必须有 length 属性
function getLength<T extends { length: number }>(item: T): number {
  return item.length;
}

getLength('hello');     // ✅ string 有 length
getLength([1, 2, 3]);   // ✅ 数组有 length
// getLength(123);      // ❌ number 没有 length

常见使用场景

// 泛型接口
interface ApiResponse<T> {
  code: number;
  data: T;
  message: string;
}

type UserResponse = ApiResponse<User>;
type OrderListResponse = ApiResponse<Order[]>;

// 泛型类
class DataStore<T> {
  private items: T[] = [];

  add(item: T): void {
    this.items.push(item);
  }

  getAll(): T[] {
    return [...this.items];
  }
}

const userStore = new DataStore<User>();
userStore.add({ name: '南祎', age: 25 });

面试追问:泛型擦除后运行时还存在吗?

不存在。和所有 TS 类型一样,泛型在编译后被完全擦除。DataStore<User>DataStore<Order> 编译后是同一个类,没有运行时多态。这和 Java 的泛型擦除机制类似。

工具类型:内置的类型变换器

TypeScript 内置了一批工具类型(Utility Types),本质上是用泛型 + 映射类型 + 条件类型实现的"类型函数"。面试中最常考的有以下几个:

Partial 和 Required

interface User {
  name: string;
  email: string;
  age: number;
}

// Partial:所有属性变为可选
type PartialUser = Partial<User>;
// 等价于 { name?: string; email?: string; age?: number; }

// Required:所有属性变为必选(反向操作)
type RequiredUser = Required<PartialUser>;
// 恢复为 { name: string; email: string; age: number; }

Partial 的实现原理只有两行:

type Partial<T> = {
  [P in keyof T]?: T[P];
};

keyof T 获取 T 的所有键的联合类型,in 遍历每个键,? 把属性变成可选,T[P] 保留原来的值类型。理解了这个实现,映射类型就算入门了。

Pick 和 Omit

// Pick:从类型中选取指定属性
type UserBasic = Pick<User, 'name' | 'email'>;
// { name: string; email: string; }

// Omit:从类型中排除指定属性
type UserWithoutAge = Omit<User, 'age'>;
// { name: string; email: string; }

Record

// Record:构造一个键为 K、值为 V 的对象类型
type RolePermissions = Record<'admin' | 'user' | 'guest', string[]>;
// { admin: string[]; user: string[]; guest: string[]; }

// 常见用法:替代 { [key: string]: T }
const cache: Record<string, User> = {};

ReturnType 和 Parameters

function fetchUser(id: number, force?: boolean): Promise<User> {
  // ...
}

// ReturnType:提取函数返回值类型
type FetchUserReturn = ReturnType<typeof fetchUser>;
// Promise<User>

// Parameters:提取函数参数类型(元组)
type FetchUserParams = Parameters<typeof fetchUser>;
// [id: number, force?: boolean]

面试追问:这些工具类型是怎么实现的?能手写一个 Pick 吗?

type MyPick<T, K extends keyof T> = {
  [P in K]: T[P];
};

能写出这个,说明你理解了 keyofextends 约束和映射类型,面试官通常会满意。

类型守卫与类型收窄

TypeScript 的类型收窄(Type Narrowing)是指在特定的代码分支中,编译器能自动将变量的类型从宽泛类型缩小到更具体的类型。

typeof 守卫

function format(value: string | number): string {
  if (typeof value === 'string') {
    return value.toUpperCase(); // 这里 value 被收窄为 string
  }
  return value.toFixed(2); // 这里 value 被收窄为 number
}

instanceof 守卫

class ApiError extends Error {
  code: number;
  constructor(message: string, code: number) {
    super(message);
    this.code = code;
  }
}

function handleError(error: Error) {
  if (error instanceof ApiError) {
    console.log(error.code); // 收窄为 ApiError,可以访问 code
  }
}

in 操作符守卫

interface Bird {
  fly(): void;
  layEggs(): void;
}

interface Fish {
  swim(): void;
  layEggs(): void;
}

function move(animal: Bird | Fish) {
  if ('fly' in animal) {
    animal.fly(); // 收窄为 Bird
  } else {
    animal.swim(); // 收窄为 Fish
  }
}

自定义类型守卫

当内置守卫不够用时,可以自己定义一个类型谓词函数:

interface Cat {
  meow(): void;
}

interface Dog {
  bark(): void;
}

// 返回类型 `pet is Cat` 就是类型谓词
function isCat(pet: Cat | Dog): pet is Cat {
  return (pet as Cat).meow !== undefined;
}

function interact(pet: Cat | Dog) {
  if (isCat(pet)) {
    pet.meow(); // 收窄为 Cat
  } else {
    pet.bark(); // 收窄为 Dog
  }
}

面试追问:类型守卫和类型断言(as)的区别?

类型守卫是编译器验证后的收窄,是安全的;类型断言是开发者告诉编译器"我知道这是什么类型",编译器不做实际检查,如果断言错了就是运行时 bug。能用守卫就别用断言。

枚举:数字枚举、字符串枚举与 const 枚举

数字枚举

enum Direction {
  Up,      // 0
  Down,    // 1
  Left,    // 2
  Right,   // 3
}

// 支持反向映射
console.log(Direction[0]); // 'Up'
console.log(Direction.Up); // 0

数字枚举编译后会生成一个双向映射对象,既能从名字找到值,也能从值找到名字。

字符串枚举

enum EventType {
  Click = 'CLICK',
  Hover = 'HOVER',
  Focus = 'FOCUS',
}

字符串枚举没有反向映射,编译产物更干净。日常开发中更推荐字符串枚举,因为调试时日志里看到 'CLICK' 比看到 0 直观得多。

const 枚举

const enum Status {
  Active = 'ACTIVE',
  Inactive = 'INACTIVE',
}

const currentStatus = Status.Active;
// 编译后直接内联为:const currentStatus = 'ACTIVE';

const enum 在编译时会被完全内联,不生成任何 JavaScript 对象。好处是零运行时开销,坏处是不能在运行时遍历枚举的值。

面试追问:枚举和联合类型常量哪个好?

// 联合类型方案
type Status = 'active' | 'inactive';

// 枚举方案
enum Status { Active = 'active', Inactive = 'inactive' }

越来越多的项目倾向于用联合类型替代枚举,原因是联合类型没有运行时产物、和 Tree Shaking 更友好、写法更简洁。枚举的优势在于可以反向映射和遍历所有值。如果不需要这些特性,联合类型通常是更好的选择。

类型体操入门

"类型体操"是指利用 TypeScript 类型系统的图灵完备性,在类型层面进行复杂的逻辑运算。面试中不会要求手写特别复杂的类型体操,但以下三个核心概念必须掌握。

条件类型

语法类似三元表达式:T extends U ? X : Y

// 判断类型是否为数组
type IsArray<T> = T extends any[] ? true : false;

type A = IsArray<string[]>;  // true
type B = IsArray<number>;    // false

条件类型的一个重要特性是分布式条件类型:当 T 是联合类型时,条件类型会对联合类型的每个成员分别运算。

type ToArray<T> = T extends any ? T[] : never;

type Result = ToArray<string | number>;
// string[] | number[],而不是 (string | number)[]

如果你不想要分布式行为,用方括号包裹:

type ToArrayNonDist<T> = [T] extends [any] ? T[] : never;

type Result = ToArrayNonDist<string | number>;
// (string | number)[]

infer 关键字

infer 用于在条件类型中提取某个位置的类型,相当于"类型层面的模式匹配"。

// 提取数组元素类型
type ElementOf<T> = T extends (infer E)[] ? E : never;

type A = ElementOf<string[]>;  // string
type B = ElementOf<number[]>;  // number

// 提取函数返回值类型(ReturnType 的实现原理)
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

// 提取 Promise 包裹的类型
type Unwrap<T> = T extends Promise<infer U> ? U : T;

type A = Unwrap<Promise<string>>; // string
type B = Unwrap<number>;          // number

映射类型

映射类型用 in 关键字遍历联合类型的每个成员,生成新的对象类型。前面讲的 PartialPick 等工具类型都是映射类型的应用。

// 把所有属性变为只读
type Readonly<T> = {
  readonly [P in keyof T]: T[P];
};

// 把所有属性的值类型改为 boolean
type Flags<T> = {
  [P in keyof T]: boolean;
};

interface User {
  name: string;
  age: number;
}

type UserFlags = Flags<User>;
// { name: boolean; age: boolean; }

映射类型还支持键的重映射(TypeScript 4.1+):

// 给所有属性名加上 get 前缀
type Getters<T> = {
  [P in keyof T as `get${Capitalize<string & P>}`]: () => T[P];
};

type UserGetters = Getters<User>;
// { getName: () => string; getAge: () => number; }

声明文件与三斜线指令

声明文件(.d.ts)

声明文件只包含类型信息,不包含实现。它的作用是为没有类型定义的 JavaScript 库提供类型支持。

// types/lodash.d.ts
declare module 'lodash' {
  export function cloneDeep<T>(value: T): T;
  export function debounce<T extends (...args: any[]) => any>(
    func: T,
    wait?: number
  ): T;
}

当你安装 @types/xxx 包时,拿到的就是社区维护的 .d.ts 文件。如果某个库没有官方类型定义,你可以在项目中自己创建 .d.ts 文件来补充。

declare 关键字

declare 用于告诉 TypeScript 编译器"这个东西存在,但不是我定义的":

// 声明全局变量(比如通过 CDN 引入的库)
declare const jQuery: (selector: string) => any;

// 声明全局函数
declare function ga(command: string, ...args: any[]): void;

// 声明模块
declare module '*.css' {
  const classes: Record<string, string>;
  export default classes;
}

declare module '*.png' {
  const src: string;
  export default src;
}

三斜线指令

三斜线指令是一种特殊的注释,必须写在文件的最顶部,用于声明文件之间的依赖关系:

/// <reference path="./global.d.ts" />
/// <reference types="node" />
/// <reference lib="es2015" />
  • path:引用另一个声明文件,告诉编译器在编译时包含它
  • types:引用 @types 包的类型声明
  • lib:引用内置的 lib 声明文件

在现代项目中,三斜线指令用得越来越少,因为 tsconfig.jsontypestypeRoots 和模块系统已经能处理大部分场景。但在编写 .d.ts 声明文件时,三斜线指令仍然是标准做法。

面试高频追问汇总

Q:TS 的类型检查发生在什么阶段? 编译阶段。类型信息在编译后完全擦除,运行时不存在任何类型检查。

Q:never 类型有什么实际用途? 两个主要用途:标记不可能到达的分支(穷尽检查),和表示永远不会返回的函数。在 switch 的 default 分支中赋值给 never 类型的变量,如果漏处理了某个 case,编译器会报错。

type Shape = 'circle' | 'square' | 'triangle';

function getArea(shape: Shape): number {
  switch (shape) {
    case 'circle': return Math.PI * 10 * 10;
    case 'square': return 10 * 10;
    case 'triangle': return (10 * 8) / 2;
    default:
      const exhaustiveCheck: never = shape; // 如果漏了 case,这里报错
      return exhaustiveCheck;
  }
}

Q:keyoftypeof 在类型层面是什么意思? keyof T 获取类型 T 所有公共属性名的联合类型。typeof variable 获取变量的类型(注意是类型层面的 typeof,和运行时的 typeof 是两码事)。

const user = { name: '南祎', age: 25 };
type UserType = typeof user;      // { name: string; age: number; }
type UserKeys = keyof typeof user; // 'name' | 'age'

Q:协变和逆变是什么? 协变(Covariance):子类型可以赋值给父类型,数组和函数返回值是协变的。逆变(Contravariance):父类型可以赋值给子类型,函数参数在 strictFunctionTypes 开启时是逆变的。这个话题在日常开发中遇到得不多,但在设计复杂泛型库时很关键。

总结

TypeScript 的核心价值在于用编译期的类型检查换取运行时的稳定性和开发时的效率。面试中 TS 相关问题的考察重点可以归纳为五条主线:

  • 基础概念:类型注解、any vs unknown、never 的用途
  • interface vs type:能说清楚声明合并、联合类型等差异,给出选择原则
  • 泛型:理解类型参数化的思想,能写带约束的泛型函数
  • 工具类型:知道常用工具类型的作用,能手写 Partial/Pick 的实现
  • 类型体操:掌握条件类型、infer、映射类型三板斧,能读懂中等难度的类型定义

在实际项目中,TS 的投入产出比最高的地方是接口定义公共函数签名。把 API 返回值、组件 Props、工具函数的入参出参类型写清楚,就能覆盖绝大多数类型安全的收益。至于复杂的类型体操,除非你在写基础库,否则不需要追求炫技——能让团队看懂的类型才是好类型。