这个问题在技术社区里被问到的频率,我估计仅次于闭包和事件循环。面试官喜欢问,代码评审里也经常吵:有人坚持“能用 interface 就不用 type”,有人反过来觉得 type 写起来更简洁。但多数人能列出来的区别,翻来覆去就是“interface 能合并声明,type 能定义联合类型”这两条。实际上,这两者的差异远比表面看到的深,而且理解不到位会在项目里埋下不少难排查的类型问题。这篇文章不打算做概念罗列,我从两者的设计定位说起,把真正的差异掰开,最后给出我在项目里实际沿用的选型规则。1. 两者到底差在哪:先看本质,再看表面1.1 出身不同:interface 是“接口”,type 是“别名”很多人以为 interface 和 type 只是定义对象类型的两种语法,随便换着写没区别。这句话在前 80% 的场景下是成立的,但剩下的 20% 恰恰是坑最多的地方。关键要先理解它们的本质。interface 的语义更接近传统面向对象语言里的“接口”:它描述的是一个对象应该长成什么样,强调的是“契约”。你可以把 interface 理解成一个有名字、可以被继承、可以被合并、可以被类实现的类型声明。而 type 的全称是 type alias,也就是类型别名,它的本质是“给某个类型起个名字”。它不一定非要指向对象类型,它可以指向一个字符串字面量类型、一个联合类型、一个元组、一个函数签名,甚至是一个条件类型的计算结果。这个出身差异决定了非常多行为差异。interface 是有“实体感”的,编译器会把它当作一个新的、可追踪的具名类型;type 则在很多情况下是被“展开”的——编译器遇到 type 别名时,往往需要把它解析成底层类型再做进一步判断。这也是为什么在某些复杂类型场景下,interface 的报错信息比 type 更容易读。1.2 一个典型的“看起来一样”的例子先看一个最常见的写法:interface User { id: number; name: string; } type UserType = { id: number; name: string; };这两种写法在绝大多数普通对象场景里确实完全等价。你可以用接口的方式定义一个变量,也可以用 type 的方式定义,赋值时的结构检查行为也一致。甚至在大多数工具类型里,两者都能作为参数传入。但也就在这个“看起来一样”的节点上,分岔开始了。我见过不少项目因为前期图省事,全部用 type 定义对象类型,等到后面需要扩展第三方库的类型声明、或者想用声明合并给全局接口追加字段时,发现 type 做不到,只能回头大规模重构。这种问题在项目初期完全看不出来,往往等到类型体系复杂到一定程度才爆发。所以我的建议是:先吃透两者在扩展机制、声明行为、类型表达能力三个维度上的差异,再决定你的默认选择,而不是凭个人手感。2. interface 的独家能力:extends、声明合并与实现契约2.1 extends:一个 interface 可以继承多个类型,而 type 只能交叉interface 的继承机制非常成熟,一个接口可以 extends 另一个接口,也可以 extends 一个 type 定义的对象类型:type Person = { name: string; age: number; }; interface Developer extends Person { skills: string[]; }这个例子里,Developer 会完整继承 Person 的属性,同时又增加自己的属性。extends 是真正的“继承”语义,子接口和父接口之间存在清晰的声明关系,编译器能准确知道字段来源。type 之间当然也可以做类似的事情,但用的是交叉类型(intersection type):type Animal = { name: string; }; type Dog = Animal & { bark(): void; };看起来效果差不多,但交叉类型有一个非常经典的坑:当交叉的两个类型中存在同名但类型不兼容的属性时,结果字段会变成 never,而不是报错或让你覆盖。我用一个实际例子来说明:type A = { value: string }; type B = { value: number }; type C = A & B; // C 里 value 实际是 string & number,也就是 never你定义一个const c: C = { value: 'x' },编译器会告诉你类型不兼容。但问题在于,C这个类型本身并没有在定义时报错,它是“合法”的,只是不可用。这在联合类型和大型交叉组合中尤其隐蔽。而 interface extends 则相反,如果你试图继承一个属性类型不兼容的父接口,定义处直接报错,问题暴露得更早。2.2 声明合并:同名声明是扩展还是报错,直接决定库的扩展方式这是 interface 和 type 在行为上最明显的分水岭。interface Window { title: string; } interface Window { ts: TypeScriptAPI; }两个同名 interface 会被合并成一个,所有字段叠加。这被称为声明合并(declaration merging)。这对扩展全局类型、第三方库类型、甚至是给window对象追加自定义字段来说,几乎是不可替代的能力。比如你在做前端项目时,想在Window上挂一个全局方法:interface Window { mySDK: { init(config: Record<string, unknown>): void; }; }这段代码写在任何模块里,整个项目后续访问window.mySDK都会正常拿到类型。如果用 type 重复声明同名类型,会直接报 Duplicate identifier 错误。type 虽然可以用交叉类型模拟类似的效果,但那是“重新生成一个新类型”,没有办法把定义拆分到多个文件中,多个模块间也想不出一个优雅的方式来“增量添加”类型字段。所以凡是涉及全局类型扩展、API 类型设计的场景,interface 基本是唯一正解。我自己维护的几个工具库,对外暴露的类型入口一律用 interface,为的就是让使用方可以通过声明合并来补充自定义配置。2.3 implements:谁更适合作为类的实现契约interface 可以很自然地配合 implements 使用:interface ILogger { log(message: string): void; } class ConsoleLogger implements ILogger { log(message: string) { console.log(message); } }type 定义的“对象类型”也可以被类 implements,这一点并不被禁止。但 type 一旦涉及联合类型、原始类型别名这类非对象结构,implements 就会报错。更关键的是语义上的问题:type 本质上是一个别名,用别名去描述一台类的实现契约,表达能力有限,而且后续阅读代码的人很容易困惑——“这个 type 到底是个类形状,还是一个自定义类型工具?”从我个人的项目经验看,凡是需要被类实现、被第三方扩展、被分层继承的类型骨架,我都会选择 interface。它不仅仅是能用,更重要的是它的“意图”清清楚楚:看到 interface,读者就知道这是一个开放的类型契约;看到 type,读者更应该期待它是一个封闭的类型计算产物。3. type 的主场:联合、元组、映射与条件类型3.1 联合类型与字面量类型:interface 完全无能为力interface 只能描述“对象形状”,它的一切能力都建立在对象结构之上。而 type 没有任何这个限制,它可以表达一个值的所有可能类型。type Status = 'idle' | 'loading' | 'success' | 'error'; type Result<T> = | { state: 'pending' } | { state: 'fulfilled'; data: T } | { state: 'rejected'; error: Error };这种带判别字段的联合类型,是 Redux 状态、请求状态、流程状态建模里最常见又最好用的模式。interface 无论怎么抽象都无法表达“这个值可能是 A 形状,也可能是 B 形状,取决于 tag 字段”这类需求,这是 type 不可替代的核心理由之一。有一些说法提到“接口也可以模拟联合类型”,严格来说不可能。你可以定义多个 interface,再用一个 type 去联合它们:interface PendingState { state: 'pending' } interface FulfilledState<T> { state: 'fulfilled'; data: T } type State<T> = PendingState | FulfilledState<T>;你看,最终仍然需要 type 来承担联合这一层。interface 可以当联合的组成部件,却无法成为联合本身。3.2 元组、函数类型与原始类型别名:type 的轻量表达元组类型是 type 的看家本领之一:type Point3D = [x: number, y: number, z: number]; type HttpRequest = [method: 'GET' | 'POST', url: string, body?: unknown];interface 拿元组这种“按位置而非按名字访问”的结构毫无办法,想表达也只能绕道。函数类型签名同理:type Handler = (event: MouseEvent, context: Record<string, unknown>) => void; type StringTransformer = (input: string) => string;用 interface 也可以表达函数类型,你得写成:interface Handler { (event: MouseEvent, context: Record<string, unknown>): void; }虽然能跑,但明显多一层包裹,读起来也比 type 版本别扭。更别说type UserId = string这种给基础类型起别名的操作,interface 从设计上就不支持——它只能描述有属性的对象。3.3 映射与条件类型:真正拉开差距的“类型运算”场景这一块是 interface 和 type 能力差距最大的地方。type 配合 keyof、in、extends、infer 等关键字,可以写出表现力极强的类型工具。type DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K]; }; type ReturnTypeOf<T extends (...args: any[]) => any> = T extends (...args: any[]) => infer R ? R : never; type PickKeys<T, K extends keyof T> = { [P in K]: T[P]; };这些例子里的DeepPartial是递归映射类型,ReturnTypeOf用到了 infer 做类型推断,PickKeys本质上就是内置工具类型 Pick 的实现思路。它们都依赖“对类型本身做计算”的能力,interface 在这种场景下没有任何参与空间。我给这类 type 的定义方式一个通俗的类比:如果说 interface 是在画图纸——规定一个东西长什么样,那么 type 更像是在写“类型的函数”——输入一些类型,经过计算,输出新的类型。前者关注形状,后者关注变换。4. 编译性能、错误提示与声明文件输出的细微差异4.1 编译器的缓存优化:interface 更“轻”这一节聊一些不太被关注、但实际会影响开发体验的差异。TypeScript 编译器在处理 interface 时,因为它是一个具名的、稳定的对象类型,可以做缓存优化;而 type 别名在每次被引用时,往往需要被展开成底层类型再做处理。尤其是当你用 type 做了大量交叉类型的组合时,编译器可能需要反复合并、检查这些交叉结构,项目规模一大,编辑器的类型检查速度会有可感知的下降。我之前处理过一个中后台项目的类型代码:有一整套用 type 和交叉类型拼出来的配置类型,单文件类型定义很长,每次保存后 VSCode 的红色波浪线和类型检查都要卡一两秒。后来把纯对象形状那部分改成 interface,几个层级关系用 extends 表达,体感明显改善。团队里也有同事反馈编辑器没有以前那么容易“转圈”了。这背后就是刚才说的缓存优化在起作用。4.2 交叉类型的报错有多难啃:一个真实示例如果你常规用 type 做交叉组合,大概率会遇到这种报错:type Config = { timeout?: number; retry?: number; endpoint: string; }; type MockConfig = { endpoint: string; timeout: 'none'; mockData: Record<string, unknown>; }; type TestConfig = Config & MockConfig;在这段代码里,timeout在两个源类型中分别是number | undefined和'none',交叉之后就成了一个几乎不可能满足的联合约束。当你去给一个TestConfig赋值时,编译器的报错会带着大段交叉类型的展开信息,把包含timeout的整个结构都吐出来,阅读成本非常高。同样的问题如果换成 interface extends,编译器会直接说“这里的属性类型不兼容”,指向明确、信息集中。所以如果你特别在意类型报错的可读性——尤其当这个库会被其他人使用——优先用 interface 做对象结构之间的关系表达,只在确实需要交叉语义时才用 &。4.3 .d.ts 输出的可读性差异如果你写过 npm 包或者公共类型声明文件,应该能感觉到 interface 和 type 在产物可读性上的差别。interface 输出到 .d.ts 里,基本保持原来的样子,读者能直接看到继承层级。type 输出时,有时会保留别名引用,有时会被展开成一个冗长的结构,复杂交叉类型往往在声明文件里堆成一大片。对使用方来说,interface 的体验更好:鼠标悬停到类型上时,编译器能清楚地展示它“从哪里来、到底定义了什么”;复杂 type 的展开则经常只看到一片复杂的嵌套结构,光是理解字段来源都要花时间。对公共类型层的设计来说,这一点值得重视。5. 项目里的选型规则与混合使用禁忌5.1 一套可复用的选型标准我在代码评审里有一套相对强硬的建议,已经稳定用了一段时间,分享给大家:判断维度默认选 interface默认选 type描述对象形状、契约、API 数据模型、类的实现约束是否需要被其他类型继承,存在明确的父子层级关系是否需要向第三方库或全局命名空间追加字段(声明合并)是否定义联合类型、交叉组合、元组、字面量联合、函数签名否是使用映射类型、条件类型、infer 等类型运算否是给基础类型或复杂结构起一个语义化别名否是把一个类型工具的结果导出给外部使用否是这套标准的核心逻辑是:interface 承担“公开、稳定、可扩展”的类型契约职责;type 承担“表达力、组合、计算”的职责。两者不是谁替代谁的关系,而是分工关系。在实际项目里我会这么落地:前端业务那边,API 请求的响应体、请求参数、组件 Props 里的固定结构,用 interface;表单状态这种经常需要拆分字段并做部分更新映射的类型,用 type 配合工具类型处理。组件库的公开类型,几乎只用 interface,方便使用方扩展;内部实现里的各种联合状态、配置变换,全部用 type。5.2 interface 和 type 混用时的三个坑混用本身没有语法障碍,但有几个很容易踩的坑,我逐个说一下。第一个是“type 重名即报错”带来的扩展陷阱。如果第三方类型是用 type 定义的,你没有办法在项目里“追加”这个类型的字段。唯一的办法是另外起一个名字,再用交叉类型去组合,但这就意味着使用方必须同时引用两个名字,体验很差。反过来,第三方类型如果是 interface,你可以直接在全局范围内进行声明合并,几乎是无痛的扩展方式。之前有个系统想给路由配置对象增加自定义权限字段,结果路由类型是 type 定义的,只能在调用入口处做一次 as 强转,绕来绕去才把类型补上。第二个是交叉类型里同名属性变成 never 的坑。我上面已经举过例子。这里再强调一遍:两个 type 交叉时,同名但类型不同的属性不会报错,也不会保留一个覆盖另一个,而是会变成一个无法赋值的 never 类型。这个坑在多人协作时特别容易发生,因为只要没人实际去赋值,编译器不会主动提示这里有问题。第三个坑是“type 过度包装”。有人喜欢给 interface 再包一层 type 别名:interface User { ... } type UserAlias = User;然后到处引用 UserAlias。这样做了之后,一旦 interface 的定义需要调整,所有引用处的类型提示都会经过一层展开,可读性和追踪性都下降,多包这一层并不会有任何类型安全收益。我对团队的要求是:除非需要一个“新的、经过变换的类型”,否则不要为了统一风格去包 type。5.3 给团队规范的建议如果团队正在起草 TypeScript 规范,我的建议是不要只写“优先使用 interface”或“全部使用 type”这种一刀切规则,而是把上面那张表的核心意思转成几条有实际判断标准的条款。另一方面,在代码评审里与其争论“这里该用 interface 还是 type”,不如先判断这个类型是“契约”还是“计算产物”。前者交给 interface,后者交给 type,大部分争议会自然消失。关于“React 组件 Props 到底用哪种”这种争论,我觉得其实不必太较真。用 interface 的好处是方便继承和扩展基础 Props,用 type 的好处是写起来短、可以方便地和联合类型打交道。社区两种用法都不少。关键是在一个仓库内保持一致,或者干脆在 eslint 规则里固定下来,别让每段代码都有自己的风格。我个人的默认规则会保守一点:只要类型结构是对象,且存在需要扩展的可能,第一反应就是 interface;只有当我要表达的对象确实是一个联合、一个元组、一个函数签名,或者需要通过类型运算生成新类型时,才使用 type。在这个基础之上再补充一句:永远不要和声明合并能力对着干,你在扩展第三方类型时,如果发现对方用的是 interface,那是一件值得庆幸的事。这些年写过不少类型代码,也重构过不少同事留下的类型定义,最大的体会是:interface 和 type 不是“二选一”的关系,而是你对类型系统理解深度的试金石。知道它们各自的设计边界,分场景使用,比背下一百条差异清单都来得有用。下一回再遇到有人争论这个问题,你可以先把“这个类型要被谁使用、还需要怎样扩展”问清楚,答案往往就自己浮出来了。
这个问题在技术社区里被问到的频率,我估计仅次于闭包和事件循环。面试官喜欢问,代码评审里也经常吵:有人坚持“能用 interface 就不用 type”,有人反过来觉得 type 写起来更简洁。但多数人能列出来的区别,翻来覆去就是“interface 能合并声明,type 能定义联合类型”这两条。实际上,这两者的差异远比表面看到的深,而且理解不到位会在项目里埋下不少难排查的类型问题。这篇文章不打算做概念罗列,我从两者的设计定位说起,把真正的差异掰开,最后给出我在项目里实际沿用的选型规则。1. 两者到底差在哪:先看本质,再看表面1.1 出身不同:interface 是“接口”,type 是“别名”很多人以为 interface 和 type 只是定义对象类型的两种语法,随便换着写没区别。这句话在前 80% 的场景下是成立的,但剩下的 20% 恰恰是坑最多的地方。关键要先理解它们的本质。interface 的语义更接近传统面向对象语言里的“接口”:它描述的是一个对象应该长成什么样,强调的是“契约”。你可以把 interface 理解成一个有名字、可以被继承、可以被合并、可以被类实现的类型声明。而 type 的全称是 type alias,也就是类型别名,它的本质是“给某个类型起个名字”。它不一定非要指向对象类型,它可以指向一个字符串字面量类型、一个联合类型、一个元组、一个函数签名,甚至是一个条件类型的计算结果。这个出身差异决定了非常多行为差异。interface 是有“实体感”的,编译器会把它当作一个新的、可追踪的具名类型;type 则在很多情况下是被“展开”的——编译器遇到 type 别名时,往往需要把它解析成底层类型再做进一步判断。这也是为什么在某些复杂类型场景下,interface 的报错信息比 type 更容易读。1.2 一个典型的“看起来一样”的例子先看一个最常见的写法:interface User { id: number; name: string; } type UserType = { id: number; name: string; };这两种写法在绝大多数普通对象场景里确实完全等价。你可以用接口的方式定义一个变量,也可以用 type 的方式定义,赋值时的结构检查行为也一致。甚至在大多数工具类型里,两者都能作为参数传入。但也就在这个“看起来一样”的节点上,分岔开始了。我见过不少项目因为前期图省事,全部用 type 定义对象类型,等到后面需要扩展第三方库的类型声明、或者想用声明合并给全局接口追加字段时,发现 type 做不到,只能回头大规模重构。这种问题在项目初期完全看不出来,往往等到类型体系复杂到一定程度才爆发。所以我的建议是:先吃透两者在扩展机制、声明行为、类型表达能力三个维度上的差异,再决定你的默认选择,而不是凭个人手感。2. interface 的独家能力:extends、声明合并与实现契约2.1 extends:一个 interface 可以继承多个类型,而 type 只能交叉interface 的继承机制非常成熟,一个接口可以 extends 另一个接口,也可以 extends 一个 type 定义的对象类型:type Person = { name: string; age: number; }; interface Developer extends Person { skills: string[]; }这个例子里,Developer 会完整继承 Person 的属性,同时又增加自己的属性。extends 是真正的“继承”语义,子接口和父接口之间存在清晰的声明关系,编译器能准确知道字段来源。type 之间当然也可以做类似的事情,但用的是交叉类型(intersection type):type Animal = { name: string; }; type Dog = Animal & { bark(): void; };看起来效果差不多,但交叉类型有一个非常经典的坑:当交叉的两个类型中存在同名但类型不兼容的属性时,结果字段会变成 never,而不是报错或让你覆盖。我用一个实际例子来说明:type A = { value: string }; type B = { value: number }; type C = A & B; // C 里 value 实际是 string & number,也就是 never你定义一个const c: C = { value: 'x' },编译器会告诉你类型不兼容。但问题在于,C这个类型本身并没有在定义时报错,它是“合法”的,只是不可用。这在联合类型和大型交叉组合中尤其隐蔽。而 interface extends 则相反,如果你试图继承一个属性类型不兼容的父接口,定义处直接报错,问题暴露得更早。2.2 声明合并:同名声明是扩展还是报错,直接决定库的扩展方式这是 interface 和 type 在行为上最明显的分水岭。interface Window { title: string; } interface Window { ts: TypeScriptAPI; }两个同名 interface 会被合并成一个,所有字段叠加。这被称为声明合并(declaration merging)。这对扩展全局类型、第三方库类型、甚至是给window对象追加自定义字段来说,几乎是不可替代的能力。比如你在做前端项目时,想在Window上挂一个全局方法:interface Window { mySDK: { init(config: Record<string, unknown>): void; }; }这段代码写在任何模块里,整个项目后续访问window.mySDK都会正常拿到类型。如果用 type 重复声明同名类型,会直接报 Duplicate identifier 错误。type 虽然可以用交叉类型模拟类似的效果,但那是“重新生成一个新类型”,没有办法把定义拆分到多个文件中,多个模块间也想不出一个优雅的方式来“增量添加”类型字段。所以凡是涉及全局类型扩展、API 类型设计的场景,interface 基本是唯一正解。我自己维护的几个工具库,对外暴露的类型入口一律用 interface,为的就是让使用方可以通过声明合并来补充自定义配置。2.3 implements:谁更适合作为类的实现契约interface 可以很自然地配合 implements 使用:interface ILogger { log(message: string): void; } class ConsoleLogger implements ILogger { log(message: string) { console.log(message); } }type 定义的“对象类型”也可以被类 implements,这一点并不被禁止。但 type 一旦涉及联合类型、原始类型别名这类非对象结构,implements 就会报错。更关键的是语义上的问题:type 本质上是一个别名,用别名去描述一台类的实现契约,表达能力有限,而且后续阅读代码的人很容易困惑——“这个 type 到底是个类形状,还是一个自定义类型工具?”从我个人的项目经验看,凡是需要被类实现、被第三方扩展、被分层继承的类型骨架,我都会选择 interface。它不仅仅是能用,更重要的是它的“意图”清清楚楚:看到 interface,读者就知道这是一个开放的类型契约;看到 type,读者更应该期待它是一个封闭的类型计算产物。3. type 的主场:联合、元组、映射与条件类型3.1 联合类型与字面量类型:interface 完全无能为力interface 只能描述“对象形状”,它的一切能力都建立在对象结构之上。而 type 没有任何这个限制,它可以表达一个值的所有可能类型。type Status = 'idle' | 'loading' | 'success' | 'error'; type Result<T> = | { state: 'pending' } | { state: 'fulfilled'; data: T } | { state: 'rejected'; error: Error };这种带判别字段的联合类型,是 Redux 状态、请求状态、流程状态建模里最常见又最好用的模式。interface 无论怎么抽象都无法表达“这个值可能是 A 形状,也可能是 B 形状,取决于 tag 字段”这类需求,这是 type 不可替代的核心理由之一。有一些说法提到“接口也可以模拟联合类型”,严格来说不可能。你可以定义多个 interface,再用一个 type 去联合它们:interface PendingState { state: 'pending' } interface FulfilledState<T> { state: 'fulfilled'; data: T } type State<T> = PendingState | FulfilledState<T>;你看,最终仍然需要 type 来承担联合这一层。interface 可以当联合的组成部件,却无法成为联合本身。3.2 元组、函数类型与原始类型别名:type 的轻量表达元组类型是 type 的看家本领之一:type Point3D = [x: number, y: number, z: number]; type HttpRequest = [method: 'GET' | 'POST', url: string, body?: unknown];interface 拿元组这种“按位置而非按名字访问”的结构毫无办法,想表达也只能绕道。函数类型签名同理:type Handler = (event: MouseEvent, context: Record<string, unknown>) => void; type StringTransformer = (input: string) => string;用 interface 也可以表达函数类型,你得写成:interface Handler { (event: MouseEvent, context: Record<string, unknown>): void; }虽然能跑,但明显多一层包裹,读起来也比 type 版本别扭。更别说type UserId = string这种给基础类型起别名的操作,interface 从设计上就不支持——它只能描述有属性的对象。3.3 映射与条件类型:真正拉开差距的“类型运算”场景这一块是 interface 和 type 能力差距最大的地方。type 配合 keyof、in、extends、infer 等关键字,可以写出表现力极强的类型工具。type DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K]; }; type ReturnTypeOf<T extends (...args: any[]) => any> = T extends (...args: any[]) => infer R ? R : never; type PickKeys<T, K extends keyof T> = { [P in K]: T[P]; };这些例子里的DeepPartial是递归映射类型,ReturnTypeOf用到了 infer 做类型推断,PickKeys本质上就是内置工具类型 Pick 的实现思路。它们都依赖“对类型本身做计算”的能力,interface 在这种场景下没有任何参与空间。我给这类 type 的定义方式一个通俗的类比:如果说 interface 是在画图纸——规定一个东西长什么样,那么 type 更像是在写“类型的函数”——输入一些类型,经过计算,输出新的类型。前者关注形状,后者关注变换。4. 编译性能、错误提示与声明文件输出的细微差异4.1 编译器的缓存优化:interface 更“轻”这一节聊一些不太被关注、但实际会影响开发体验的差异。TypeScript 编译器在处理 interface 时,因为它是一个具名的、稳定的对象类型,可以做缓存优化;而 type 别名在每次被引用时,往往需要被展开成底层类型再做处理。尤其是当你用 type 做了大量交叉类型的组合时,编译器可能需要反复合并、检查这些交叉结构,项目规模一大,编辑器的类型检查速度会有可感知的下降。我之前处理过一个中后台项目的类型代码:有一整套用 type 和交叉类型拼出来的配置类型,单文件类型定义很长,每次保存后 VSCode 的红色波浪线和类型检查都要卡一两秒。后来把纯对象形状那部分改成 interface,几个层级关系用 extends 表达,体感明显改善。团队里也有同事反馈编辑器没有以前那么容易“转圈”了。这背后就是刚才说的缓存优化在起作用。4.2 交叉类型的报错有多难啃:一个真实示例如果你常规用 type 做交叉组合,大概率会遇到这种报错:type Config = { timeout?: number; retry?: number; endpoint: string; }; type MockConfig = { endpoint: string; timeout: 'none'; mockData: Record<string, unknown>; }; type TestConfig = Config & MockConfig;在这段代码里,timeout在两个源类型中分别是number | undefined和'none',交叉之后就成了一个几乎不可能满足的联合约束。当你去给一个TestConfig赋值时,编译器的报错会带着大段交叉类型的展开信息,把包含timeout的整个结构都吐出来,阅读成本非常高。同样的问题如果换成 interface extends,编译器会直接说“这里的属性类型不兼容”,指向明确、信息集中。所以如果你特别在意类型报错的可读性——尤其当这个库会被其他人使用——优先用 interface 做对象结构之间的关系表达,只在确实需要交叉语义时才用 &。4.3 .d.ts 输出的可读性差异如果你写过 npm 包或者公共类型声明文件,应该能感觉到 interface 和 type 在产物可读性上的差别。interface 输出到 .d.ts 里,基本保持原来的样子,读者能直接看到继承层级。type 输出时,有时会保留别名引用,有时会被展开成一个冗长的结构,复杂交叉类型往往在声明文件里堆成一大片。对使用方来说,interface 的体验更好:鼠标悬停到类型上时,编译器能清楚地展示它“从哪里来、到底定义了什么”;复杂 type 的展开则经常只看到一片复杂的嵌套结构,光是理解字段来源都要花时间。对公共类型层的设计来说,这一点值得重视。5. 项目里的选型规则与混合使用禁忌5.1 一套可复用的选型标准我在代码评审里有一套相对强硬的建议,已经稳定用了一段时间,分享给大家:判断维度默认选 interface默认选 type描述对象形状、契约、API 数据模型、类的实现约束是否需要被其他类型继承,存在明确的父子层级关系是否需要向第三方库或全局命名空间追加字段(声明合并)是否定义联合类型、交叉组合、元组、字面量联合、函数签名否是使用映射类型、条件类型、infer 等类型运算否是给基础类型或复杂结构起一个语义化别名否是把一个类型工具的结果导出给外部使用否是这套标准的核心逻辑是:interface 承担“公开、稳定、可扩展”的类型契约职责;type 承担“表达力、组合、计算”的职责。两者不是谁替代谁的关系,而是分工关系。在实际项目里我会这么落地:前端业务那边,API 请求的响应体、请求参数、组件 Props 里的固定结构,用 interface;表单状态这种经常需要拆分字段并做部分更新映射的类型,用 type 配合工具类型处理。组件库的公开类型,几乎只用 interface,方便使用方扩展;内部实现里的各种联合状态、配置变换,全部用 type。5.2 interface 和 type 混用时的三个坑混用本身没有语法障碍,但有几个很容易踩的坑,我逐个说一下。第一个是“type 重名即报错”带来的扩展陷阱。如果第三方类型是用 type 定义的,你没有办法在项目里“追加”这个类型的字段。唯一的办法是另外起一个名字,再用交叉类型去组合,但这就意味着使用方必须同时引用两个名字,体验很差。反过来,第三方类型如果是 interface,你可以直接在全局范围内进行声明合并,几乎是无痛的扩展方式。之前有个系统想给路由配置对象增加自定义权限字段,结果路由类型是 type 定义的,只能在调用入口处做一次 as 强转,绕来绕去才把类型补上。第二个是交叉类型里同名属性变成 never 的坑。我上面已经举过例子。这里再强调一遍:两个 type 交叉时,同名但类型不同的属性不会报错,也不会保留一个覆盖另一个,而是会变成一个无法赋值的 never 类型。这个坑在多人协作时特别容易发生,因为只要没人实际去赋值,编译器不会主动提示这里有问题。第三个坑是“type 过度包装”。有人喜欢给 interface 再包一层 type 别名:interface User { ... } type UserAlias = User;然后到处引用 UserAlias。这样做了之后,一旦 interface 的定义需要调整,所有引用处的类型提示都会经过一层展开,可读性和追踪性都下降,多包这一层并不会有任何类型安全收益。我对团队的要求是:除非需要一个“新的、经过变换的类型”,否则不要为了统一风格去包 type。5.3 给团队规范的建议如果团队正在起草 TypeScript 规范,我的建议是不要只写“优先使用 interface”或“全部使用 type”这种一刀切规则,而是把上面那张表的核心意思转成几条有实际判断标准的条款。另一方面,在代码评审里与其争论“这里该用 interface 还是 type”,不如先判断这个类型是“契约”还是“计算产物”。前者交给 interface,后者交给 type,大部分争议会自然消失。关于“React 组件 Props 到底用哪种”这种争论,我觉得其实不必太较真。用 interface 的好处是方便继承和扩展基础 Props,用 type 的好处是写起来短、可以方便地和联合类型打交道。社区两种用法都不少。关键是在一个仓库内保持一致,或者干脆在 eslint 规则里固定下来,别让每段代码都有自己的风格。我个人的默认规则会保守一点:只要类型结构是对象,且存在需要扩展的可能,第一反应就是 interface;只有当我要表达的对象确实是一个联合、一个元组、一个函数签名,或者需要通过类型运算生成新类型时,才使用 type。在这个基础之上再补充一句:永远不要和声明合并能力对着干,你在扩展第三方类型时,如果发现对方用的是 interface,那是一件值得庆幸的事。这些年写过不少类型代码,也重构过不少同事留下的类型定义,最大的体会是:interface 和 type 不是“二选一”的关系,而是你对类型系统理解深度的试金石。知道它们各自的设计边界,分场景使用,比背下一百条差异清单都来得有用。下一回再遇到有人争论这个问题,你可以先把“这个类型要被谁使用、还需要怎样扩展”问清楚,答案往往就自己浮出来了。