Effective TypeScript

了解 type 和 interface 之间的区别

核心建议与设计选择

在 TypeScript 中,type(类型别名)和 interface(接口)都可以用来定义命名类型。多年来,两者之间的界限越来越模糊,但在大多数情况下,应该意识到它们剩余的区别并保持一致性。

  • 一般规则: 在定义对象类型时,优先使用 interface。在必须使用或当 type 的语法更简洁时(例如联合类型和函数类型),才使用 type
  • 命名规范: 不建议使用 'I' 或 'T' 作为类型名称的前缀(例如 IStateTState),这种习惯在现代 TypeScript 中被视为不良风格。

共同之处:何时两者可互换

对于简单的对象类型,interfacetype 几乎没有区别,并且它们会受到相同的类型检查(例如严格属性检查)。

  • 它们都可以定义相似的对象结构:
interface IState {
  name: string;
  capital: string;
}

type TState = {
  name: string;
  capital: string;
};
  • 它们都可以使用索引签名:
type TDict = { [key: string]: string };
interface IDict {
  [key: string]: string;
}
  • 它们都可以定义函数类型,但 type 的语法更简洁且更常用:
type TFn = (x: number) => string;

// 可选的 interface 形式,反映了函数是可调用对象的事实
interface IFn {
  (x: number): string;
}
  • 它们都可以是泛型(Generic)的,例如 IBox<T>TBox<T>

  • 它们可以互相扩展,尽管实现方式略有不同:

interface Person {
  firstName: string;
  lastName: string;
}

// 接口扩展类型
interface IStateWithBirthDate extends Person {
  birth: Date;
}

// 类型别名使用交叉类型(&)扩展接口
type TStateWithBirthDate = Person & { birth: Date };
  • 类可以实现(implements)接口或类型别名。

接口(interface)的独特优势

  • 声明合并(Declaration Merging): 只有 interface 支持声明合并(或称增强/扩展)。这意味着可以在同一个作用域内多次定义同名接口,TypeScript 会自动将它们合并为一个单一的定义。
    • 这种机制在类型声明文件(Declaration Files,Item 71)中尤为重要,常用于模型化不断演进的 API(例如在不同的 ES 版本中增强 Array 接口)。
interface IState {
  name: string;
  capital: string;
}

// 在其他地方补充定义
interface IState {
  population: number; // 合并后 IState 包含所有三个属性
}

const wyoming: IState = {
  name: 'Wyoming',
  capital: 'Cheyenne',
  population: 578_000
}; // OK
  • 更严格的类型兼容性检查: 当一个接口尝试扩展另一个类型并修改其属性类型时,interface extends 提供了更严格的检查,能够捕获潜在的类型不兼容错误。而 type & 可能会生成一个结构上看似正确的交叉类型,但该类型在实际使用中却是不可用的。
interface Person {
  name: string;
  age: string;
}

// 接口扩展检查:会报错,因为 number 不能赋值给 string
interface IPerson extends Person {
//      ~~~~~~~~~~~~~~ Interface 'IPerson' incorrectly extends interface 'Person'.
  age: number;
}

// 类型交叉:不会立即报错,但生成了一个不可用的类型
type TPerson = Person & { age: number; }; // no error, unusable type
  • 类型显示的一致性: TypeScript 倾向于在错误消息和类型显示(Item 56)中始终使用接口的名称来指代它,而类型别名可能会被内联(inlining),即被其底层定义取代。

类型别名(type)的独特能力

type 别名在处理复杂类型结构和类型操作时,比 interface 更具表现力。

  • 定义联合类型: type 是定义联合类型(Union Types)的唯一方式。
type AorB = 'a' | 'b';
type Result = number | string | Date;
  • 定义基本结构类型: type 是表达元组(Tuple)类型和数组类型的自然方式。
type Pair = [a: number, b: number];
type StringList = string[];
  • 复杂类型操作: type 必须用于结合了泛型(Generics)和类型操作(Type Operations)的复杂类型,例如映射类型(Mapped Types,Item 15)和条件类型(Conditional Types,Item 52)。
// 结合 keyof 和映射类型:
type PartialOptions<T> = { [K in keyof T]?: T[K] }; 

类型别名内联行为的考量

类型别名的内联(inlining)是它们与接口最显著的运行时区别之一。

  • 如果一个类型别名在一个导出函数中被使用,TypeScript 可能会在生成的 .d.ts 文件中将其定义内联,而不是保留其命名。
// 原始 .ts 文件
export function getHummer() {
  type Hummingbird = { name: string; weightGrams: number; };
  const ruby: Hummingbird = { name: 'Ruby-throated', weightGrams: 3.4 };
  return ruby;
};

// 生成的 .d.ts 文件中,类型被内联,名称 Hummingbird 消失
// get-hummer.d.ts
export declare function getHummer(): {
  name: string;
  weightGrams: number;
};
  • 相比之下,由于接口支持声明合并,如果它未被导出但在公共 API 中使用,TypeScript 会发出警告,因为它无法在类型声明文件中引用该名称。

  • 这种内联行为(有时用于优化类型显示,Item 56)会影响最终的类型输出和错误信息的可读性,特别是在大型且复杂的类型中,应予以注意。

在 GitHub 上编辑

上次更新于