1. 先说结论这场争论的本质不是二选一而是你根本没用对代码评审时我见过太多次这样的场景某同事把代码里的interface全改成type理由是既然都能定义对象为什么还要两套写法。再过两周有人因为用了type导致无法自动合并声明回滚重写。又或者反过来所有对象都用interface结果想做一个联合类型跟交叉一下反而要绕个大弯。这不只是个人审美问题。interface和type即 Type Alias在 TypeScript 类型系统里的定位完全不同——一个是用于描述结构化契约的关键字一个是可以做任意类型运算的别名。用错场景轻则风格不统一重则你的类型根本没利用上 TS 的表达能力。这篇我直接把我平时的选择和理由讲透。先看底层机制再看实际项目里该怎么选最后附上我们的团队规范以及我踩过的一些坑。2. 底层机制差异interface 是合同type 是工具箱很多人只知道interface 可以 extendstype 可以 但这两个操作在执行上的机制并不完全等价。理解机制差异就知道什么时候必须换写法。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 NestedT { data: T; meta: Recordstring, 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 ResponseDataT { code: number; message: string; data: T; };这里的ResponseData本身是稳定的理论上也可以定义成interface ResponseDataT。但问题是它作为泛型包装器更多时候会和联合类型、条件类型配合使用type SuccessT { status: ok; data: T }; type Error { status: error; message: string }; type ApiResultT SuccessT | Error;这种情况下全用interface反而很别扭——SuccessT和Error更像是类型计算的分支而不是业务契约。所以让形状用interface计算用type代码读起来自然是有节奏的。3.3 场景对照表一个更贴近真实代码的选择参考场景推荐写法底层原因API 请求/响应 DTOinterface稳定结构、可扩展、可合并组件 Props / Stateinterface对外契约便于继承和合并组件库公共类型interface使用者可能通过声明合并做扩展领域实体模型interface代表业务对象的稳定边界联合类型如Status a | btype只能用 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 HandlerT (payload: T) void; type EventMap { [K in EventName as on${CapitalizeK}]?: Handlerunknown; }; // 这是运算类型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 PickUser, name; type WithoutSecret OmitUser, password; // 4. 任意复杂类型运算、条件类型、映射类型 type HandleableEvent { [K in keyof Group]: () Group[K] };当然如果需求本身是面向外部 API 的稳定数据结构比如返回给前端的 DTO即便是type的 Pick 结果我们也会再包一层 interface 或者定义一个明确的名字比如interface UserSummary extends PickUser, 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 OmitThirdPartyConfig, internalToken { customField: string; internalToken?: never; // 通过 Omit 去掉原字段的可访问性 }这个绕法确实能解决问题但远没有interface声明合并来得直接——如果第三方库用的是 interface 定义扩展点我们根本不需要绕路。7.2 caseinterface 声明合并带来的意外覆盖踩坑反过来也有。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 设计一些约束友好的错误提示。比如通过映射类型和条件类型让错误类型自动展示为人类可读文本。这些场景几乎只能用 typetype RequireExactlyOneT, Keys extends keyof T { [K in Keys]: { [P in K]: T[P] } PartialRecordExcludeKeys, 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。这个规则虽然简单但几乎消除了我们团队里关于类型写法的所有无谓争论。
阅读完成 · 觉得有帮助?