在描述对象结构这件事上,interface 和 type 做的是同一份工作,只要结构一致,两者的值就可以互相赋值。区别都在边缘地带:type 能为接口无法表达的东西命名(联合类型、元组、计算出来的类型),而接口也能做几件类型别名做不到的事(合并、会做检查的 extends)。
TypeScript 按结构比较对象类型,所以对于能否赋值,声明叫什么名字并不重要。下面列出的是选择真正会产生影响的地方。
对比表
| 特性 | interface | type |
|---|---|---|
| 对象结构 | 支持 | 支持 |
| 可选、只读、方法、索引签名 | 支持 | 支持 |
| 泛型 | 支持 | 支持 |
联合类型(A | B) | 不支持 | 支持 |
| 单独的元组、原始类型、函数类型 | 不支持(函数类型只能写成调用签名) | 支持 |
| 映射类型和条件类型 | 不支持 | 支持 |
| 扩展 | extends,冲突会报错 | &,冲突会变成 never |
| 声明合并 | 支持 | 不支持(标识符重复) |
可赋值给 Record<string, T> | 不可以 | 可以,只要属性符合 |
在类中 implements | 支持 | 支持,前提是对象类型 |
| 递归定义 | 支持 | 支持 |
只有 type 能做的事
凡是不止一个对象结构的东西,都需要类型别名:
这些都无法用 interface 写出来(最后两种分别在 映射类型 和 条件类型 中介绍)。这就是为什么不管对象结构用的是什么,每个代码库最终都会在某些地方用到 type。
只有 interface 能做的事:声明合并
同一作用域中两个同名的 interface 声明会合并成一个。两个同名的 type 声明则会报错 TS2300:Duplicate identifier。
interface Settings {
theme: string;
}
interface Settings {
fontSize: number;
}
const s: Settings = { theme: "dark", fontSize: 14 }; // needs both
type Options = { a: number };
type Options = { b: number }; // error TS2300: Duplicate identifier 'Options'.
库的类型正是靠合并从外部扩展的:给全局的 Window、Express 的 Request 或某个库的主题类型添加属性。如果你发布的类型可能需要被使用者扩充,就用接口。在你自己的应用代码中,意外的合并(两个脚本文件声明了同名的全局接口)会悄无声息地发生,除非两者以不同的类型声明了同一个属性;这也是一些团队偏爱 type 的理由之一。
extends 与交叉类型
接口用 extends 扩展,类型别名用 &。两者的结果大多相同,但处理冲突属性的方式不同。extends 会在声明处报告冲突:
index.ts(7,11): error TS2430: Interface 'Broken' incorrectly extends interface 'Base'.
Types of property 'id' are incompatible.
Type 'number' is not assignable to type 'string'.
交叉类型会默默接受同样的冲突,把这个属性变成 never(一个同时是 number 的 string)。错误要到后面你尝试创建值时才出现:
错误指向的是那个对象,离引发它的声明很远。在用其他对象类型构建对象类型时,extends 给出的提示更好。
索引签名:一个容易忽略的区别
对象类型的类型别名会得到一个隐式的索引签名,所以可以传给需要 Record<string, unknown> 的地方。接口则不会。这是有意为之:TypeScript 团队的解释(TypeScript 仓库的 issue #15300)是,接口可以被后续的声明扩充,因此为它推断索引签名不够安全,而现在改变这条规则又会破坏太多代码。
标了 @ts-expect-error 的那次调用照样会运行,并输出 Alan 的字段:这条规则只在编译时生效。把接口类型的值传给参数类型为 Record<string, ...> 的日志、序列化或查询辅助函数时,出现令人困惑的错误,通常就是这个原因。可以把那一个声明改成 type,展开这个值,或者把辅助函数的参数改用接口或泛型来声明。
性能与错误提示
TypeScript wiki 的 Performance 页面(“Preferring Interfaces Over Intersections”一节)建议,在组合对象类型时用 interface Foo extends Bar, Baz { ... },而不是 type Foo = Bar & Baz & { ... }。理由是:接口是一个能检测属性冲突的扁平对象类型;接口之间的类型关系会被缓存(交叉类型整体则不会);而检查一个值是否符合交叉类型时,会先逐个检查每个组成部分,再检查扁平化后的类型。在有大量组合类型的大型代码库中,这个差别才有意义;对于几个普通的对象结构,你根本测不出来。
同一页面还指出,接口的显示效果更好。在悬停提示和错误信息中,接口显示为它的名字,而交叉类型别名常常被展开成各个部分打印出来,让长长的信息更难读。
该用哪一个
一条行得通的规则:
- 对象结构:用
interface。它提供会做检查的extends,在大型组合上错误更清晰,还允许库的使用者扩充它。这与 TypeScript 手册的经验法则一致:“在需要用到type的特性之前,都用interface”。 - 其他一切:用
type。联合类型、元组、函数类型、字面量类型,以及任何用映射类型、条件类型或模板字面量类型构建的东西。 - 例外:如果对象结构需要符合
Record<string, ...>参数,或者你有意阻止合并,就用type。
全部使用 type 也是一种自洽的选择,很多代码库就是这么做的。唯一要避免的是随意混用两者:这会让读代码的人疑惑,这里的差别是不是有意为之。
常见问题
TypeScript 中 type 和 interface 有什么区别?
两者都能描述对象结构,在这方面可以互换。type 还能为联合类型、元组、原始类型以及映射类型或条件类型命名,而 interface 做不到。interface 支持声明合并和会检查属性冲突的 extends。此外,用 type 写的对象类型可以赋值给 Record<string, unknown> 这类带索引签名的类型,接口则不行。
应该用 type 还是 interface?
TypeScript 手册给出的经验法则是:在需要只有 type 才有的特性之前,都用 interface。实践中就是对象结构用接口,联合类型、元组、函数类型和计算出来的类型用 type。全部使用 type 的团队也没问题;关键在于有一条统一的规则。
TypeScript 中 interface 比 type 更快吗?
在组合对象类型时,某些情况下是的。TypeScript 团队的性能指南推荐用 interface ... extends,而不是大型交叉类型(A & B & { ... }),因为接口之间的关系会被缓存,而且接口是一个扁平的类型。对于简单的对象结构,两者没有实际差别。
类可以实现类型别名吗?
可以,只要这个别名是对象类型(或对象类型的交叉):class Point implements PointType { ... } 是有效的。类不能实现联合类型,那会报错 TS2422。
接口可以继承类型别名吗?
可以,只要别名是对象类型:type Base = { id: string }; interface User extends Base { name: string } 是合法的。它不能继承联合类型的别名。反过来,类型别名可以用 & 在接口的基础上构建。