☰
TypeScript中interface与type怎么选?底层机制、声明合并与实战规范
2026/10/21 9:37:20 网站建设 项目流程

1. 先说结论:这场争论的本质不是"二选一",而是你根本没用对

代码评审时我见过太多次这样的场景:某同事把代码里的interface全改成type,理由是"既然都能定义对象,为什么还要两套写法"。再过两周,有人因为用了type导致无法自动合并声明,回滚重写。又或者反过来,所有对象都用interface,结果想做一个联合类型跟&交叉一下,反而要绕个大弯。

这不只是"个人审美"问题。interface和type(即 Type Alias)在 TypeScript 类型系统里的定位完全不同——一个是用于描述结构化契约的关键字,一个是可以做任意类型运算的"别名"。用错场景,轻则风格不统一,重则你的类型根本没利用上 TS 的表达能力。

这篇我直接把我平时的选择和理由讲透。先看底层机制,再看实际项目里该怎么选,最后附上我们的团队规范,以及我踩过的一些坑。

2. 底层机制差异:interface 是"合同",type 是"工具箱"

很多人只知道"interface 可以 extends,type 可以 &",但这两个操作在执行上的机制并不完全等价。理解机制差异,就知道什么时候必须换写法。

2.1 interface 描述"这个对象应该长什么样"

interface从直观语义上就是给对象"定形状"。它对应的是一种结构化契约:我有属性a、方法b,实现方必须遵守这些约束。

// 定义契约 interface User { name: string; age: number; login(): void; } // 使用契约,作为函数参数类型 function register(user: User) { // user.name / user.age / user.login 都有类型提示 }

这个非常好理解。我们读代码时看到interface User,第一反应就是:这是一个用户对象的模型定义。它的继承机制也是围绕对象结构展开的:

interface Admin extends User { permissions: string[]; }

对象形状、类的契约、第三方库的 d.ts 结构,这些都是interface的主场。

2.2 type 可以做任意类型运算,"交叉"不是简单合并

type叫做类型别名,它可以代表的不只是对象。联合类型、交叉类型、字面量类型、元组、函数签名、条件类型提取出的复杂类型,都可以给它一个名字:

type ID = string | number; // 联合 type Point = { x: number } & { y: number }; // 交叉 type Tuple = [string, number, boolean]; // 元组 type Status = 'pending' | 'success' | 'error'; // 字面量联合 type Nested<T> = { data: T; meta: Record<string, unknown> }; // 泛型

最重要的是,type的交叉类型&和interface的extends在语义上不是一个层面的东西。extends是"继承并声明扩展",&是"把两个类型的所有成员合并成一个新结构"。这两者在冲突处理、重写行为上都有区别。

比如同名属性,extends时如果基类与子类属性类型不一致,TS 会报错;而交叉类型则可能产生一个"属性类型也进行交叉"的诡异结果(下面会细说)。所以,把type A = { x: 1 } & { x: 2 }当extends的替代,是一个常见的误区。

2.3 两者不是非黑即白:同名属性和声明合并的"分水岭"

我建议每个团队都做一次内部技术分享,只讲一件事:声明合并(Declaration Merging)。因为这是interface独有的能力,也是它不会被type完全替代的根本原因。

interface Window { title: string; } interface Window { ts: any; } // 两个同名的 interface 会自动合并, // 等价于 interface Window { title: string; ts: any; }

这在改动第三方库类型、给全局对象补字段、拆分模块再扩展类型时,非常关键。而type一旦重名,直接就报"Duplicate identifier"。如果你负责维护一个库,用户的扩展方式经常需要依赖这种合并能力,那么公共 API 的类型尽量多用interface。

3. 实际选择标准:先看需求,再决定"形状"还是"运算"

进入团队落地层面,我不建议用"之前怎么写就怎么写"来绑架规则。也不建议死记"React Props 用 interface、API 数据用 type"这种零散的惯例。我自己的选择逻辑是这样一个判断顺序:

3.1 第一条线:这个类型是"稳定的对象形状",还是"需要计算得出的类型"?

这条可以覆盖掉绝大多数场景。

  • API 返回的 DTO、请求参数结构、ORM 实体模型、组件 Props、Context 的数据结构——这些是"稳定的对象形状",默认用interface。
  • 联合类型的范围、从一个类型里Pick/Omit提取出的子集、多个对象交叉合成的临时类型、根据条件变换出来的类型——这是"类型运算",默认用type。

为什么?因为稳定形状更有可能被复用、被扩展、被 merge。例如一个后台管理系统的表格列配置,我们经常需要在一个基础配置上扩展特定页面的额外字段:

interface TableColumn { key: string; title: string; width?: number; } // 扩展列配置 interface SpecialColumn extends TableColumn { // 可以安全地在团队内部或第三方库中继续扩字段 render?: (value: unknown) => React.ReactNode; }

如果一开始用type TableColumn = {...}定义,后续想扩展就只能靠&,而且重名扩展这条路直接断掉——很多依赖声明合并的中间件或插件的使用场景会被堵死。

3.2 第二条线:代码的可读性和"自我解释"能力

说实话,我读代码的时候,看到interface会自动认为这是一个核心业务模型,是项目中的关键类型结构;看到type则更多认为这是一段类型运算的结果或简化别名。这种心理暗示不是玄学,而是 TS 社区多年形成的共同习惯。

例如:

type ResponseData<T> = { code: number; message: string; data: T; };

这里的ResponseData本身是稳定的,理论上也可以定义成interface ResponseData<T>。但问题是,它作为泛型包装器,更多时候会和联合类型、条件类型配合使用:

type Success<T> = { status: 'ok'; data: T }; type Error = { status: 'error'; message: string }; type ApiResult<T> = Success<T> | Error;

这种情况下全用interface反而很别扭——Success<T>和Error更像是类型计算的分支,而不是业务契约。所以让"形状"用interface,"计算"用type,代码读起来自然是有节奏的。

3.3 场景对照表:一个更贴近真实代码的选择参考

场景推荐写法底层原因
API 请求/响应 DTOinterface稳定结构、可扩展、可合并
组件 Props / Stateinterface对外契约,便于继承和合并
组件库公共类型interface使用者可能通过声明合并做扩展
领域实体模型interface代表业务对象的稳定边界
联合类型(如Status = 'a' | 'b')type只能用 type 表达
Pick/Omit/Record 等工具类型结果type本质是类型运算结果
交叉类型(&)type运算符产生的类型,不是声明
函数重载签名type联合函数签名只能 type
条件类型/递归类型type只能 type 表达
元组类型type只能 type 表达
混合多种类型构造typetype 更接近"计算"语义

这个表不是拿来当死规矩背的,而是让你从"底层能力 + 语义表达"两个维度自己判断。只要结构是稳定、可扩展、面向外部使用的对象定义,interface都不会错。剩下的类型运算需求,直接交给type。

4. 深入对比:在 class、交叉类型、泛型这几条线上彻底拉开差距

很多人纠结 interface 和 type 选谁,是因为没见过它们在边界情况下的行为差异。我挑几个最容易踩雷的案例,完整跑一遍。

4.1 class implements 时,interface 和 type 的"软约束"差异

class implements既支持 interface 也支持 type,这是很多人的第一层误解。第二层误解是觉得"应该都能用",其实有一些偏差:

interface Person { name: string; greet(): void; } type Animal = { name: string; makeSound(): void; }; class Human implements Person { name = ''; greet() {} } class Dog implements Animal { name = ''; makeSound() {} }

两种都能过编译。但是要注意,type一旦包含联合类型、工具类型运算后的结果,implements就可能报错。比如:

type StringOrNumber = string | number; // 这是一个联合类型 // class Foo implements StringOrNumber {} // ❌ 报错

因为implements要求右侧类型必须是一个"有效描述对象形状的接口或类型别名"。这告诉我们,在用class实现契约时,对象形状越明确越好——这也正是interface适合大量使用的场景。

还有一点:当type涉及交叉类型时,implements可以支持简单交叉,但一旦属性类型冲突,交叉出来的类型可能"自动合并出"一个实际并不存在的属性类型,很难排查。而interface extends会在编译期明确报错。比如:

interface A { id: string } interface B { id: number } // interface C extends A, B {} // ❌ 报错:后续属性声明必须属于同一类型 type C = A & B; // type C = { id: string } & { id: number } // 这样居然能通过编译,但 id 实际上是什么类型?很难直接用。

这种场景下,interface的报错反而是在保护你,type的"宽容"则在制造隐性问题。

4.2 声明合并的正向应用:一种很香的模式

我参加过一个某后台管理系统的设计评审,当时组里有人希望通过一个全局 UserContext 扩展,在多个业务模块里注入不同额外属性。如果用type实现,这个需求会变成"谁改谁死"的重构。但换成interface的声明合并,就自然多了:

// 基础模块定义 interface CurrentUser { id: string; name: string; } // 某个业务模块,可以在自己文件里声明扩展 interface CurrentUser { roles: string[]; } // 另一个模块,又能继续扩展 interface CurrentUser { avatarUrl?: string; } // 最终 CurrentUser 是三者合并后的结构 function showProfile(user: CurrentUser) { console.log(user.name, user.roles, user.avatarUrl); }

这种跨文件扩展能力,在类型安全的前提下为大型项目提供了一种很好的解耦方式。type做不到这一点。如果你正在设计一个"会被业务方不断扩展类型"的公共层,请优先使用interface。

4.3 type 的杀手锏:复杂类型运算与 API 类型收窄

反过来说,interface也有很多做不了的事。最典型的是联合类型、条件类型、递归类型、模板字面量类型等。例如:

type EventName = 'click' | 'hover' | 'scroll'; type Handler<T> = (payload: T) => void; type EventMap = { [K in EventName as `on${Capitalize<K>}`]?: Handler<unknown>; }; // 这是"运算类型",interface 无法这样声明

这个EventMap不是具体的某个业务对象,而是基于EventName字面量联合类型生成的一个映射。用interface写不出来,写出来也很怪。凡是要在类型空间里做"变换、映射、条件选择",type都是唯一选择。

我自己做状态管理类型时,经常要用type把 action、state、dispatch 之间的关系压缩成一行可复用类型:

type State = { value: string; status: 'idle' | 'loading' | 'done' }; type Action = | { type: 'setValue'; payload: string } | { type: 'setStatus'; payload: State['status'] }; function reducer(state: State, action: Action) { // ... }

这种联合分支动作类型,如果你只抱着interface不放,就写不出来。所以,正确思路是"两个都用,各管一摊"。

5. 宏观看两种风格对整个代码库的影响:可维护性 vs 类型编程玄学

跑通上面的案例之后,再回到"整个项目到底怎么统一"这个问题。日积月累,interface 和 type 的选择不只是单点技术决策,还会影响别人读代码时的判断路径。

5.1 对新人友好的代码库,通常"形状优先 interface"

某公司前端团队的一个内部约定是:凡是直接出现"业务实体"字样的类型,默认使用 interface。例如 User、Order、Product、TeamMember。原因很简单,新同学接手代码时,读到interface Product就知道这是一个业务模型,可以去对应的实体定义文件里查它的属性;而读到type ProductStatus = 'active' | 'disabled'就知道这是状态的联合分支。

这个"信号强不强"的价值被很多人低估了。在代码里,类型的名字很重要,但我们日常很难做到每次定义都起一个能一眼看出类型的名字。interface和type本身就是在用语言结构传递信号。

5.2 过度使用 type 导致的"类型玄学"灾难

我接手过一个模拟项目,里面大量使用type交叉和条件类型去定义业务模型。结果到了深层的嵌套类型,IDE 上 hover 显示出来的类型层层缠绕,完全看不出这个对象结构本身长什么样。排查一个简单 bug 时,你还得顺着类型运算思路去倒推。

这不是说type不好,而是说当业务模型本可以用稳定的interface表达时,不应该刻意用type的运算能力去实现。类型系统的强项是描述真实结构,而不是炫技。

5.3 过度使用 interface 的另一个极端

反过来,我也见过一个团队,几乎万物皆interface,连type Status = 'pending' | 'success'都写成interface Status { value: 'pending' | 'success' },反而把简单的联合类型复杂化了。更常见的问题是,遇到必须用 type 的联合类型、条件类型、工具类型时,因为团队"禁止 type"的硬性规定,只能想方设法绕过,绕得代码极其别扭。

这说明:硬性规定"只能用 interface"和"只能用 type"都是错的。正确做法是定一个"默认值 + 例外规则",让代码在 80% 场景下形成一致选择,同时允许特殊情况走 type。

6. 我们团队最终落地的规范:写法优先,还是语义优先?

我这里不是要给你一个放之四海而皆准的答案。因为不同团队、不同库、不同 AGV(自动生成类型)策略,答案会不一样。我只分享我们用下来比较顺的默认规则。

6.1 默认规则:能 interface 就 interface,除非必须 type

这个听起来像是废话,但执行起来很有效。因为绝大多数项目的业务类型定义都是"对象形状",而interface的声明合并、extends、class implements 兼容性,都是对这个场景的补强。

实际操作中,我们还会配合 ESLint 规则来做约束。比如对"定义了但没使用的 type"做提示,保证类型命名和实际用途一致。不过更有效的是 code review 环节:一旦发现一个类型用type定义,却完全没有任何联合、交叉、映射运算,而是在单纯描述业务对象结构,Reviewer 就会提出来"这个是不是改成 interface 更合适"。

6.2 例外列表:可以无脑使用 type 的四种场景

对于需要高频使用 TS 类型运算的代码,直接放开限制。以下四种场景我们默认用type,不用纠结:

// 1. 联合类型 type RequestStatus = 'pending' | 'loading' | 'success' | 'error'; // 2. 交叉类型(合并不存在继承关系、且来自不同模块的对象) type FullEntity = BasicEntity & ExtendedEntity; // 3. 工具类型结果 type UserName = Pick<User, 'name'>; type WithoutSecret = Omit<User, 'password'>; // 4. 任意复杂类型运算、条件类型、映射类型 type HandleableEvent = { [K in keyof Group]: () => Group[K] };

当然,如果需求本身是面向外部 API 的稳定数据结构,比如返回给前端的 DTO,即便是type的 Pick 结果,我们也会再包一层 interface 或者定义一个明确的名字,比如interface UserSummary extends Pick<User, 'id' | 'name'> {}。这样既保留 type 的计算能力,又在对外边界上形成稳定契约。

6.3 组件库作者视角:类型导出结构比内部实现更关键

如果你要维护一个组件库或者公共工具库,我建议对对外导出的类型多考虑 interface,原因有二:一是使用方可以对 interface 做声明合并扩展,二是 interface 的继承关系在 IDE 里的 hover 展开更清晰,不会让使用者看到一堆交叉类型的传染链。

内部私有类型则怎么方便怎么来,优先 type 运算。这两者分开处理,可以兼顾对外稳定性和内部开发效率。我们之前在某个跨平台系统里就是这么做的,后来反馈确实比早期"全 type"方案少了很多类型扩展的吐槽。

7. 实际踩坑记录:从"interface 改成 type 一时爽"到"改回 interface 两行泪"

除了规范,我也想把一次真实踩坑过程完整写出来。这个 case 很典型,适合每个团队内部做一次分享。

7.1 case:一个第三方配置对象的扩展危机

模拟项目里,我们收到一个第三方库的类型定义,库内部大量使用type交叉类型定义配置对象。我们的业务需要在这个配置对象里加一个扩展字段。当时想着"直接再写一个 type 扩展它不就行了",于是:

import { ThirdPartyConfig } from 'some-library'; // 这里的 ThirdPartyConfig 是 type 定义, // 无法直接使用声明合并扩展 type ExtendedConfig = ThirdPartyConfig & { customField: string; };

表面看扩展成功,但第三方库内部某些函数接受的参数类型仍然是原始ThirdPartyConfig,扩展字段传不进去,类型提示乱掉了。我们最后只能改用法:不扩展库内部类型,而是自己包一层强类型:

interface OurConfig extends Omit<ThirdPartyConfig, 'internalToken'> { customField: string; internalToken?: never; // 通过 Omit 去掉原字段的可访问性 }

这个绕法确实能解决问题,但远没有interface声明合并来得直接——如果第三方库用的是 interface 定义扩展点,我们根本不需要绕路。

7.2 case:interface 声明合并带来的意外覆盖

踩坑反过来也有。interface声明合并虽然好用,但如果没有做好文件层级管理,很容易在不同业务模块里意外地给同一个全局 interface 添加字段,导致项目里出现"幽灵合并"。我曾在一个大型代码库里排查过某个字段莫名其妙被带上 undefined,查了半天,发现是有个业务文件里对同一个接口做了声明合并。

所以,如果你的项目已经使用声明合并来扩展公共类型,请务必在文件名、注释、检查规则上做好约定,防止混乱。否则"合并"这个优势反过来会成为维护噩梦。

8. 如果还要更精细一点:把类型当作设计工具的几个进阶思路

最后再把视角抬上去一点。interface 和 type 本身不是终点,重点是你如何通过类型设计让项目更清晰、更不易出错。我整理几个进阶方向,大家可以根据自己项目阶段尝试。

8.1 用 interface 做稳定边界,用 type 做内部变换

这是一个很实用的层级模型。当你的代码面临"外部 API / 内部状态 / UI 层"三层结构时,每一层之间的数据结构用 interface 定义,层与层之间的转换逻辑则用 type 定义运算。例如:

// 外部 API interface ApiOrder { orderId: string; items: Array<{ sku: string; price: number }>; createdAt: string; } // 内部状态(从 API 形状变换到 UI 形状) type OrderViewModel = { id: string; totalPrice: number; formattedDate: string; };

两者分开定义,中间写转换函数。类型系统负责帮你保证"外部数据不会漏掉字段、不会出现计算失误",同时 UI 层面对的是更干净的内部结构。这里 interface 和 type 的协作价值,远比"二选一"重要。

8.2 类型测试与约束:让使用者"正确地犯错"

如果你想提高团队的上限,可以用 type 设计一些"约束友好的错误提示"。比如通过映射类型和条件类型让错误类型自动展示为人类可读文本。这些场景几乎只能用 type:

type RequireExactlyOne<T, Keys extends keyof T> = { [K in Keys]: { [P in K]: T[P] } & Partial<Record<Exclude<Keys, K>, never>> }[Keys];

这种能力没有办法用 interface 表达。因此,凡是涉及"对类型做约束、对输入做限定"的地方,type 是必须的。interface 负责稳定,type 负责变化和约束,两者缺一不可。

8.3 结合团队 Code Review 落地

具体到团队执行层面,我们最后沉淀成一套简单的 Review Checklist:

  • 这个类型是否描述了一个真实对象结构? -> 优先 interface。
  • 这个类型是否包含联合、交叉、映射、工具类型等运算? -> 用 type。
  • 这个类型是否需要跨文件扩展? -> 用 interface。
  • 这个类型是否只是某一运算的中间结果? -> 用 type,并尽量命名清晰。
  • 这个类型是公共 API 导出吗? -> 优先 interface,并考虑是否需要声明合并能力。
  • 这个类型是否会被 class implements? -> 默认 interface,除非特殊需求。

有了这套清单,讨论时就不再是"我更喜欢 X"这种审美之争,而是"当前场景对类型能力的需求是什么"这个单一问题。

我在实际项目里逐渐形成的个人习惯是这样的:前沿业务模型、对外 API、组件 Props、可扩展契约统一用 interface;联合类型、工具类型、交叉计算、类型约束统一用 type。遇到二者都可行的情况,我问自己一个问题:"这个类型未来五年内,谁会去扩展它?"如果答案是"外部使用者或未来同事",那就老老实实写 interface。这个规则虽然简单,但几乎消除了我们团队里关于类型写法的所有无谓争论。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询