Menu

TypeScript 接口 interface:语法、可选属性、方法与示例

接口为对象的结构命名:它有哪些属性,每个属性是什么类型。学习如何声明接口、可选和只读属性、方法、索引签名、继承、在类中实现、声明合并、泛型接口,以及如何给接口设置默认值。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

接口为对象的结构起一个名字:对象必须有哪些属性,每个属性是什么类型。声明之后,你就可以把这个名字当作类型使用,编译器会拿它检查你传入、返回或赋值的每个对象。

最后一次调用是编译错误 TS2741。代码编译时接口会被擦除:JavaScript 输出中没有 User 的任何痕迹,运行时也没有东西检查结构。

声明接口

语法是关键字 interface、一个名字(按惯例使用 PascalCase),以及列出成员的主体。成员之间可以用分号、逗号或直接换行分隔;常见的风格是分号。

interface Product {
  sku: string;               // required property
  price: number;
  tags: string[];            // array property
  dimensions: {              // nested object type
    width: number;
    height: number;
  };
  discount?: number;         // optional property
  readonly createdAt: Date;  // cannot be reassigned
  label(): string;           // method
}

接口是类型,不是值。它不能用 new 实例化,没有默认值,而 obj instanceof Product 会报错 TS2693('Product' only refers to a type, but is being used as a value here)。要在运行时检查结构,就写一个 类型守卫。

结构化类型与多余属性检查

TypeScript 比较的是结构,而不是名字。任何具有所需属性的对象都符合接口,不管它声明时有没有用这个接口。多出来的属性没问题,只有一个例外:直接写在需要该接口的位置上的对象字面量,会受到多余属性检查,因为那里出现未知的键几乎总是拼写错误。

这个错误(TS2353)能为 User 抓出 { id: 1, name: "a", emial: "x" }:编译器甚至会提示 Did you mean to write 'email'?(TS2561)。

可选属性与只读属性

名字后面加 ? 让属性变成可选的:对象可以省略它,读取它得到的是 T | undefined。readonly 禁止在对象创建后重新给该属性赋值。

这里体现了两个局限。第一,readonly 只在编译时有效:按下“运行”时,标了 @ts-expect-error 的两行照样会执行,而且会成功;最后几行则通过一个没有 readonly 的引用修改了 apiUrl。第二,它是浅层的:readonly hosts: string[] 只能阻止给 hosts 重新赋值,仍然允许 hosts.push(...),所以数组本身被声明为 readonly string[]。它在有类型的代码中记录并强制执行意图,但不会冻结任何东西。工具类型 Readonly<T> 可以一次性把现有接口的所有属性变成只读。

方法与函数属性

方法可以写成方法签名 name(params): ReturnType,也可以写成持有函数的属性 name: (params) => ReturnType。调用方对两者的用法完全一样。

区别很细微:在 strictFunctionTypes(属于 strict 的一部分)下,函数类型属性的参数会被严格检查,而方法签名的检查更宽松(双向协变),所以属性写法能多抓出几个错误。方法语法更短,也是更常见的风格;两种都可以。

接口还可以用调用签名或构造签名,描述可调用或可构造的东西:

interface Formatter {
  (value: number): string; // call signature: the object is a function
  locale: string;          // and it also has a property
}

interface PointConstructor {
  new (x: number, y: number): { x: number; y: number }; // construct signature
}

索引签名

如果属性名事先不知道,可以用索引签名一次性描述所有属性:[key: string]: T 的意思是“任意字符串键,每个都存放一个 T”。

最后几行揭示了其中的陷阱:读取一个不存在的键,类型却是 number,而不是 number | undefined。编译器选项 noUncheckedIndexedAccess 会给每次这样的读取加上 | undefined。

具名属性可以与索引签名并存,但必须符合它。interface Dict { [key: string]: number; name: string } 会报错 TS2411:Property 'name' of type 'string' is not assignable to 'string' index type 'number'。可以放宽索引类型([key: string]: number | string),或者把动态的部分移到单独的属性中。对于简单的键值映射,Record<string, number> 一行就能表达同样的意思。

继承接口

extends 基于一个或多个已有接口构建新接口。子接口拥有父接口的全部成员,再加上它自己的成员:

interface Animal {
  name: string;
}

interface Pet extends Animal {
  owner: string;
}

interface Trained {
  commands: string[];
}

interface ServiceDog extends Pet, Trained {
  certifiedUntil: Date;
}

// ServiceDog requires: name, owner, commands, certifiedUntil

子接口只能用兼容的(更窄的)类型重新声明父接口的属性,比如父接口写的是 kind: string,子接口写 kind: "dog"。具体规则以及如何继承类型别名,见 extends 页面。

在类中实现接口

class X implements Shape 让编译器检查这个类是否具备接口要求的一切。缺少成员会在类声明处报错:

index.ts(7,7): error TS2420: Class 'Circle' incorrectly implements interface 'Shape'.
  Property 'area' is missing in type 'Circle' but required in type 'Shape'.

加上 area() 之后,多个类甚至一个普通对象都可以当作 Shape 使用:

implements 只是一项检查。它不会给类添加成员,也不会替你给类的方法参数加上类型:在一个实现了 greet(name: string): string 的类中写 greet(name) {},仍然会报错 TS7006:Parameter 'name' implicitly has an 'any' type。一个类可以实现多个接口:class A implements B, C。

声明合并

在同一作用域中两次声明同名的接口,会把两者合并成一个。这是类型别名做不到的(第二个同名的 type 会报标识符重复的错误)。

interface Settings {
  theme: string;
}

interface Settings {
  fontSize: number;
}

// Settings now requires both properties
const s: Settings = { theme: "dark", fontSize: 14 };

在应用代码中,这很少是你想要的,意外的合并还可能让人困惑。它真正的用途是给不属于你的类型添加成员:某个库的选项,或者 Window 这样的全局对象。在模块内部,要把声明包在 declare global 里:

declare global {
  interface Window {
    analytics: { track(event: string): void };
  }
}

export {};

这样之后,window.analytics.track("signup") 在项目中的任何地方都能通过类型检查。类型定义包依靠的也是同样的机制;见 声明文件。

接口属性的默认值

接口不能包含默认值,因为它描述的是类型,并且在运行时会被擦除。size?: "sm" | "md" = "md" 会报错 TS1246:An interface property cannot have an initializer。把属性设为可选,然后在使用对象的地方填入默认值:

解构默认值是更安全的选择:只要值是 undefined 就会生效,包括显式写出的 size: undefined。展开的写法会把那个显式的 undefined 复制到默认值之上,而结果的类型仍然表现得好像 size 一定有值。如果你需要类型层面的这种保证,就开启 exactOptionalPropertyTypes,它会让可选的 size?: ... 遇到 size: undefined 时报编译错误。如果对象还需要行为,另一个选择是使用带有初始化字段的类。

泛型接口

接口可以接收类型参数,让一个声明适用于多种数据类型:

interface ApiResponse<T> {
  ok: boolean;
  data: T;
  error?: string;
}

interface Page<T> {
  items: T[];
  nextCursor?: string;
}

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

const res: ApiResponse<Page<User>> = {
  ok: true,
  data: { items: [{ id: 1, name: "Ada" }], nextCursor: "abc" },
};

ApiResponse<Page<User>> 读作“一个 data 是用户分页的响应”。标准库里到处都是这样的接口:Array<T>、Promise<T>、Map<K, V> 都是泛型接口。

接口与类型别名

type 别名也能描述同样的对象结构,对普通的对象类型来说两者可以互换。只有接口能合并;只有类型别名能为联合类型、元组、映射类型或条件类型命名。TypeScript 手册的经验法则是:在需要只有 type 才有的特性之前,都用 interface。interface 与 type 页面有完整的对比,包括让大多数人意外的 Record<string, ...> 区别。

常见问题

TypeScript 中的接口是什么?

接口是对对象结构的命名描述:属性名、属性类型、哪些是可选或只读的,以及它的方法。编译器会检查作为该接口使用的值是否具有这种结构。接口只存在于编译时,不会生成任何 JavaScript。

如何给 TypeScript 接口设置默认值?

做不到:接口描述的是类型而不是值,所以 size: "md" = ... 不是合法的语法。把属性标为可选(size?: "sm" | "md"),然后在使用对象的地方应用默认值,通常是在函数参数中用解构默认值:function render({ size = "md" }: Options)。展开一个默认值对象({ ...DEFAULTS, ...options })也可以,但 options 中显式的 undefined 会覆盖默认值。

如何在运行时检查一个对象是否实现了某个接口?

没有内置的方法,因为接口在编译时就被擦除了:obj instanceof User 会报错 TS2693('User' only refers to a type, but is being used as a value here)。写一个检查属性的类型守卫函数 function isUser(x: unknown): x is User { ... },或者用 schema 校验库来验证。

一个接口可以继承多个接口吗?

可以。在 extends 后面列出它们,用逗号分隔:interface ServiceDog extends Pet, Trained { ... }。新接口拥有每个父接口的全部成员,再加上它自己的成员。如果两个父接口以不兼容的类型声明了同一个属性,这个声明就会报错。

TypeScript 中接口和类有什么区别?

类在运行时存在:它有构造函数和方法实现,new 会用它创建对象。接口只是为编译器描述一种结构,会从 JavaScript 输出中擦除。类可以声明 implements SomeInterface,让编译器检查它是否符合;任何结构正确的普通对象也同样符合这个接口。

Coddy 编程语言插图

和 Coddy 一起学编程

开始