Skip to content

第 13 条:了解 typeinterface 的区别

要点

  • 理解 typeinterface 之间的异同。
  • 知道如何使用两种语法编写相同的类型。
  • 了解 interface 的声明合并和 type 的类型内联。
  • 对于没有既定风格的项目,优先使用 interface 来定义对象类型。

::: Details 相同点和不同点 相同点:

  • 都能描述一个对象结构
  • 都能被 class 实现
  • 都能被扩展
ts
// 接口
interface User {
  name: string
  age: number
  getName: () => string
}

// 自定义类型
type UserType = {
  name: string
  age: number
  getName: () => string
}

// class UserClass implements User {
class UserClass implements UserType {
  name = 'x'
  age = 20
  getName() {
    return this.name
  }
}

不同点:

  • type 可以声明基础类型
  • type 有联合类型和交差类型
  • type 可以被 typeof 赋值
ts
// type 基础类型
type name = string
type list = Array<string>

// type 联合类型
type info = string | number

type T1 = { name: string }
type T2 = { age: number }
// interface T2 { age: number  } // 联合,还可以是 interface ,乱吧...
type T3 = T1 | T2
const a: T3 = { name: 'x' }
type T4 = T1 & T2
const b: T4 = { age: 20, name: 'x' }

// typeof 获取
type T5 = typeof b

//【补充】还有个 keyof ,它和 typeof 完全不同,它是获取 key 类型的
type K1 = keyof T5
const k: K1 = 'name'

:::

正文

如果你想在 TypeScript 中定义一个具名类型,有两种选择。你可以使用类型别名 type,如下所示:

ts
type TState = {
  name: string
  capital: string
}

💻 playground

或者使用接口 interface:

ts
interface IState {
  name: string
  capital: string
}

💻 playground

你也可以使用 class,但它是一个 JavaScript 运行时概念,同时还会引入一个值。详见第 8 条。

那么,在 TypeScript 中应该使用 type 还是 interface?这两者的界限相当越模糊,大多数情况下你可以使用任意一种。你需要了解它们之间仍然存在的区别,并在适当的场景中保持一致。但同时,你也应该学会如何用两种方式编写相同的类型,这样在阅读不同风格的 TypeScript 代码时会更加轻松。

如果是在新代码中选择风格,一般的经验法则是:尽可能使用 interface,只有在 type 是必须的(比如定义联合类型)或 type 语法更简洁(比如定义函数类型)时才使用 type。我们会在本条目末尾讨论具体的理由,但现在先来看这两种方式的相似点和不同点。

TIP

在本节的示例中,在类型名前加上 IT,只是为了区分它们是用 interface 还是 type 定义的。在实际开发中,不应该这样做!

在 C# 中,给 interface 类型加 I 是常见做法,这种习惯在 TypeScript 早期也曾流行过。但现在它被认为是糟糕的风格,因为它没有必要,几乎没有价值,而且 TypeScript 的标准库中也没有一致地遵循这个命名方式。

首先来看相似之处:这两种 State 类型几乎没有区别。如果你定义一个 IStateTState 值,并添加一个额外的属性,因为存在 多余属性检查(详见第 11 条),你会得到完全相同的错误提示,甚至连字符都一模一样。

ts
const wyoming: TState = {
  name: 'Wyoming',
  capital: 'Cheyenne',
  population: 578_000,
  // ~~~~~~~ Object literal may only specify known properties,
  //         and 'population' does not exist in type 'TState'
}

💻 playground

interfacetype 中都可以使用索引签名:

ts
type TDict = { [key: string]: string }
interface IDict {
  [key: string]: string
}

💻 playground

你也可以使用下面任一一种方式来定义一个函数类型:

ts
type TFn = (x: number) => string
interface IFn {
  (x: number): string
}
type TFnAlt = {
  (x: number): string
}

const toStrT: TFn = (x) => '' + x // OK
const toStrI: IFn = (x) => '' + x // OK
const toStrTAlt: TFnAlt = (x) => '' + x // OK

💻 playground

第一种类型别名的写法(TFn)在表示函数类型时更自然,也更简洁。这种写法是推荐的方式,并且在类型声明中最常见。

后两种写法反映了 JavaScript 中函数本质上是可调用对象的事实。在某些情况下(比如 重载函数签名,详见第 52 条),它们可能会更有用。

类型别名和接口都可以是 泛型

ts
type TBox<T> = {
  value: T
}
interface IBox<T> {
  value: T
}

💻 playground

interface 可以扩展 type(不过有一些注意事项,我们稍后会讨论),而 type 也可以扩展 interface

ts
// IStateWithPop 继承 TState,并额外添加 {population: number}。
// 注意: 这里的 extends 关键字只能用于对象类型,即 {} 形式的结构。
interface IStateWithPop extends TState {
  population: number
}

type TStateWithPop = IState & { population: number }

💻 playground

这些俩种类型也完全相同。但需要注意的是,interface 只能扩展可以用 interface 定义的对象类型(即使它们实际上是用 type 定义的)。例如,interface 不能 直接扩展一个 联合类型。如果你需要这样做,就必须使用 type 并用 & 交叉类型来组合。

错误 ❎ 使用:

ts
type TState = { name: string } | { code: number } // 联合类型

// 拓展联合类型会报错:
interface IStateWithPop extends TState {
  population: number
}

// 使用 type 进行拓展是正确的
type TStateWithPop = TState & { population: number } // ✅ 正确

为什么interface 不能直接扩展联合类型?

interface 主要用于对象类型的扩展,它默认假设基类型是单一对象结构,而联合类型可能是多个结构之一,interface 无法处理这种情况。

type& 交叉类型可以用于联合类型,因为它会创建新的组合类型。

此外,class 既可以实现 interface,也可以实现一个简单的 type

ts
class StateT implements TState {
  name: string = ''
  capital: string = ''
}
class StateI implements IState {
  name: string = ''
  capital: string = ''
}

💻 playground

最后,typeinterface 都可以是递归的(详见第 57 条)。

这些都是相似之处。那么它们的区别是什么呢?你已经看到其中一个区别——它们都有联合 type,但没有联合接口

ts
type AorB = 'a' | 'b'

💻 playground

interface 可以扩展一些类型,但不能扩展联合类型。扩展联合类型有时会很有用。如果你有分别表示输入和输出变量的类型,并且有一个从名称到变量的映射:

ts
type Input = {
  /* ... */
}
type Output = {
  /* ... */
}
interface VariableMap {
  [name: string]: Input | Output
}

💻 playground

那么你可能希望定义一个 type,将名称与变量关联起来。这可以是:

ts
type NamedVariable = (Input | Output) & { name: string }

💻 playground

这个 type 无法通过 interface 来表达。一般来说,type 比 interface 更强大。它可以是联合类型,还可以利用一些高级的类型特性,比如映射类型(第 15 条)和条件类型(第 52 条)。

interfaceextendstype& 提供了更多的错误检查。

ts
interface Person {
  name: string
  age: string
}

type TPerson = Person & { age: number } // no error, unusable type

interface IPerson extends Person {
  //      ~~~~~~~ Interface 'IPerson' incorrectly extends interface 'Person'.
  //                Types of property 'age' are incompatible.
  //                  Type 'number' is not assignable to type 'string'.
  age: number
}

💻 playground

在子类型中更改属性的类型是可以的,只要它与基类型兼容(见第 7 条)。通常,你会希望有更严格的安全检查,因此这是使用 extends 继承接口的一个重要原因。

类型别名更适合用于表示元组和数组类型:

ts
type Pair = [a: number, b: number]
type StringList = string[]
type NamedNums = [string, ...number[]]

💻 playground

不过,接口确实有一些类型别名没有的能力。其中之一就是接口可以被扩展。回到 State 的例子,你还可以通过另一种方式添加一个 population 字段:

ts
interface IState {
  name: string
  capital: string
}
interface IState {
  population: number
}
const wyoming: IState = {
  name: 'Wyoming',
  capital: 'Cheyenne',
  population: 578_000,
} // OK

💻 playground

这被称为“声明合并”,如果你以前没见过,可能会觉得很意外。它主要用于类型声明文件(第 8 章)。如果你在编写类型声明文件,应该遵循规范,使用 interface 来支持这种特性。其核心思想是,你的类型声明可能存在一些空缺,而用户可以通过这种方式补充它。(第 71 条会详细介绍这个过程。)

要理解这个特殊功能为何有用,可以看看 TypeScript 是如何利用声明合并来适配不同版本的 JavaScript 标准库的。例如,Array 接口的定义就存在于 lib.es5.d.ts 文件中:

ts
// lib.es5.d.ts
interface Array<T> {
  /** Gets or sets the length of the array. */
  length: number
  // ...
  [n: number]: T
}

💻 playground

如果你在 tsconfig.json(见第 2 条)中将 target 设为 ES5,那么你获得定义较少。这些定义只包含了 ES5 规范发布(2009 年)时数组的所有属性和方法。但如果你将 target 设为 ES2015,TypeScript 还会引入 lib.es2015.core.d.ts,其中包含 Array 接口的另一份声明:

ts
// lib.es2015.core.d.ts
interface Array<T> {
  /** Returns the index of the first element in the array where predicate... */
  findIndex(
    predicate: (value: T, index: number, obj: T[]) => unknown,
    thisArg?: any
  ): number

  // ... also find, fill, copyWithin
}

💻 playground

这个声明只包含了 ES2015 新增的四个数组方法:findfindIndexfillcopyWithin。这些方法通过声明合并被添加到 ES5 的 Array 接口中。最终的效果是,你会得到一个 Array 类型,它的可用方法恰好匹配你的目标 JavaScript 版本。

顾名思义,声明合并最适用于声明文件。它也可以在用户代码中发生,但前提是两个接口必须定义在同一个模块(即同一个 .ts 文件)。这样可以避免与全局接口(如 LocationFormData 这种通用名称)意外冲突。

另一个区别是,TypeScript 总是尽量使用接口的名称,而对类型别名的处理则更灵活,可能会直接用它的具体定义替换。在错误消息和类型展示中(见第 56 条)有时会看到这种区别,但它也会影响具体的输出,例如当你在 tsconfig.json 中设置 declaration: true 时,TypeScript 生成的 .d.ts 文件。

举个例子,看看这个返回静态类型对象的函数,它使用了一个作用域受限的类型别名:

ts
export function getHummer() {
  type Hummingbird = { name: string; weightGrams: number }
  const ruby: Hummingbird = { name: 'Ruby-throated', weightGrams: 3.4 }
  return ruby
}

const rubyThroat = getHummer()
//    ^? const rubyThroat: Hummingbird

💻 playground

有趣的是,TypeScript 在这里报告的类型使用了一个超出作用域的类型名称(Hummingbird)。更有意思的是,当你为这段代码生成 .d.ts 文件时,会发生什么:

ts
// get-hummer.d.ts
export declare function getHummer(): {
  name: string
  weightGrams: number
}

💻 playground

由于 .d.ts 文件中没有函数体来定义类型别名(因为这是一个类型声明文件),TypeScript 选择了内联这个类型别名。也就是说,类型名称被移除,只保留了类型结构。

由于 TypeScript 的类型系统是结构化的(见第 4 条),这对可以赋值给该类型的值没有影响。但它会影响类型的显示方式,并改变生成的 .d.ts 文件。在某些情况下,这种内联行为可能会导致类型重复,甚至严重到影响编译器的性能(见第 78 条)。

如果你改用 interface,情况会有所不同:

ts
export function getHummer() {
  //            ~~~~~~~~~
  // Return type of exported function has or is using private name 'Hummingbird'.
  interface Hummingbird {
    name: string
    weightGrams: number
  }
  const bee: Hummingbird = { name: 'Bee Hummingbird', weightGrams: 2.3 }
  return bee
}

💻 playground

因为 Hummingbird 是一个接口,TypeScript 需要通过名称引用它。但在类型声明文件中,这个名称不可用,因此会报错。虽然内联行为在这个例子中看起来更方便,但它可能导致类型过于庞大。而且,正如第 67 条所述,通常最好将你的类型导出。更好的解决方案是保留 interface,并将其作为顶层导出。

回到最初的问题:应该使用 type 还是 interface

对于复杂类型,type 是唯一的选择,因为 interface 无法完成复杂类型的定义。例如,函数类型、元组类型和数组类型,使用 type 语法会更简洁、自然。但对于可以用两种方式表示的简单对象类型,该如何选择呢?

如果你在一个已有风格的代码库中工作,最好遵循已有的风格。这样你通常不会出错。

对于一个没有既定风格的新项目,建议使用 interface。这样你在错误信息和类型展示中会更一致,并且你会得到更多关于正确扩展其他接口的检查。以下是 TypeScript 官方手册中的建议:

大多数情况下,你可以根据个人喜好来选择,TypeScript 会告诉你何时需要使用另一种声明。如果你需要一个经验法则,那就优先使用 interface,直到你需要使用 type 的特性。

换句话说,尽可能使用 interface,而当必须使用或更符合开发习惯时再使用 type。但也不用太过担心。

你可以使用 typescript-eslintconsistent-type-definitions 规则来强制统一使用 typeinterface,这是该工具的风格预设之一(默认偏好使用 interface)。

关键点总结

  • 理解 typeinterface 之间的差异和相似之处。
  • interface 不能拓展联合类型,而 type 可以。
  • 对于没有既定风格的项目,优先使用 interface 来定义对象类型。

Released under the MIT License.