Skip to main content

type 与 interface 的区别

对象形状用 interface 还是 type,在能表达的范围上大部分重叠,但声明合并、报错信息和交叉类型三处并不一样。按场景定,不要按团队口号定。

1. 谁能表达什么​

真正的区别只有一条是硬的:interface 同名会自动合并,type 重复声明会报错。其余是能力侧重:type 能做联合、交叉、映射、条件类型、元组;interface 能做 extends、implements、声明合并。除声明合并外,两边在大多数场景可以互换,所谓「必须用它」的场合其实只有扩展第三方或全局类型这一种。

一个容易被忽略的硬差别是原始类型别名:type ID = string 这种给原始值起别名只能用 type,interface 描述不了原始值。所以「组件 Props 用 interface、内部运算类型用 type」这条规则还有一层含义——凡是联合、元组、映射、条件类型,直接 type,不用犹豫。

// 这些 interface 都写不了,只能用 type
type ID = string | number
type Point = [number, number]
type Readonly<T> = { readonly [K in keyof T]: T[K] }
// 声明合并:给第三方库的类型补一个字段,只能用 interface
interface Window {
myAppConfig: Record<string, unknown>
}
// 同名再声明一次会自动合并,而不是报错
interface Window {
appVersion: string
}
能力typeinterface联合类型与交叉组合可以不可以映射类型与条件类型可以不可以原始类型别名可以不可以extends 继承对象类型用 & 组合可以类 implements可以可以同名自动合并会报错可以两列里只有「合并」这一行是真正的结构性差别,其余都是表达能力上的互补。
图:能力对照——先看能不能表达,再看要不要合并

2. extends 与交叉类型​

两者都能被 class implements。但 implements 一个联合类型是不合法的——类只能实现一个具体的形状,不能同时是「A 或 B」。interface 用 extends 继承、type 用交叉类型组合,效果类似,只是同名冲突时行为不同(见第五节)。interface 还支持 extends 多个父接口、一次组合多份契约;type 用 & 也能叠加,二者在「合并多个来源的类型」时语义一致,区别只在冲突处理。

2.1 报错信息与编译开销​

interface 的报错通常更贴近命名实体,读起来更友好;type 的交叉类型一旦展开,错误信息可能非常长,定位困难。性能方面要诚实:早期文档提到的差异在现代版本里已基本不值得作为选型依据,真正影响编译速度的是超大交叉类型与深层递归——这和「用 type 还是 interface」关系不大。在 React 里组件 Props 也优先用自定义 interface 而不是 React.FC——后者的 IntelliSense 体验更差,且在 React 18 之前还会隐式追加一个 children 属性(@types/react 18 起已移除该隐式行为)。

按场景定而非按喜好

给一条能直接执行的规则:对外的、可能被业务方扩展的类型(组件 Props、库的配置项)用 interface;组合出来的、内部的、需要运算的类型用 type。这条规则的价值不在性能,而在后续维护——想扩展别人的 interface 只要再声明一次,想扩展 type 只能去改源码。

团队层面建议用一条 lint 规则锁定风格——@typescript-eslint/consistent-type-definitions 可以强制对象类型统一用 interface(或反过来),少数例外用注释豁免,比在每次 review 里口头争论更省事。性能上无需纠结:现代版本里 type 与 interface 的检查速度差异已可忽略,选型应看维护性与扩展方式,而非这点微乎其微的编译开销。

// 组合类型:联合 + 映射,interface 表达不了,用 type
type Status = 'idle' | 'loading' | 'done'
type ApiResult<T> = { code: number; data: T }

声明合并最常出事的地方是第三方库的类型被意外扩展:

// 某个文件里随手写了这么一段,全项目的 Express Request 都被加了字段
declare global {
namespace Express {
interface Request { user?: User }
}
}

// 后果:类型上看起来人人都有 user,运行时却只有鉴权中间件之后才有
// 排查时很难想到是另一个文件的 interface 在起作用

声明合并的意外

三个常见坑:一是同名 interface 意外合并,导致字段凭空多出来却找不到定义处;二是 type 重复声明直接报错;三是交叉类型遇到同名字段会变成 never(而不是后写的覆盖先写的),这个 never 往往要等到被用到时才暴露。

// 交叉类型同名字段冲突:类型变成 never
type A = { id: string }
type B = { id: number }
type C = A & B // id: string & number => never

两者的声明合并差异可以参考 TypeScript 手册:Everyday Types,日常选型时够用了。