尧图精选

TypeScript 中 interface extends 与交叉类型 的核心区别与选型指南

🕒 发布时间:2026/9/24 22:46:52 📁 来源:尧图网络
1. 先看一个会被面试官追问的问题extends 和 到底差在哪1.1 伸手就能跑的示例从基础实体改造说起上周代码评审一位同事把“接口扩展”改成了“交叉类型”本地编译没问题推到 CI 红了一大片报错落在一个同名属性上。那是我今年以来第五次见到有人把这两个东西划等号了所以决定专门写一篇把interface extends接口扩展和type里的交叉类型的差异彻底捋清楚。大部分 TypeScript 教程都会告诉你它们能实现类似的效果// 接口扩展用 extends 继承 interface BaseEntity { id: string; createdAt: Date; } interface UserEntity extends BaseEntity { name: string; } // 交叉类型用 组合 type BaseEntity2 { id: string; createdAt: Date; }; type UserEntity2 BaseEntity2 { name: string; };这两种写法得到的UserEntity和UserEntity2从结构上看几乎一致都有id、createdAt、name。于是不少同学就默认它俩“差不多都能组合类型”面试被问“接口扩展和交叉类型有什么区别”时也只会抛这一句。但真实工程里这两个东西从“类型检查规则”到“编辑器鼠标悬浮后的展示效果”到处都不一样。这篇文章就把这些差异一次讲透顺便把我踩过的坑、后来沉淀下来的选型标准一起放出来。1.2 第一层差异这俩连“操作对象”都不一样接口扩展的关键字是extends它只能用在interface上而且要求被继承的目标是一个“对象形状类型”。你没法让一个接口去extends一个联合类型也没法extends一个原始类型别名type ID string; // interface Foo extends ID {} // 报错An interface can only extend an object type // or intersection of object types with statically known members type MaybeError { code: number } | { message: string }; // interface Bar extends MaybeError {} // 报错不能继承联合类型交叉类型就不一样了它是个类型运算符几乎可以作用于任何类型。string { length: 8 }在 TypeScript 的视角里是合法的只是这个类型太窄实际没人会直接赋值。交叉类型最狠的一点是它能把“对象类型”和“约束条件”焊在一起后面讲品牌类型的时候你会看到它的威力。所以第一个判断标准已经浮出水面你想表达的东西本质上是“对象形状”并且希望有明确的继承层次接口扩展更合适你想对任意类型做组合尤其是不局限于 interface 的时候只能用。这不是口味问题是语法边界。2. 同名属性之争哪边会炸哪边会偷偷给你塞一个 never2.1 接口继承撞了同名属性TypeScript 直接甩脸子interface的extends有一条很硬的设计原则子接口的同名属性必须能赋值给父接口的对应属性也就是类型要“兼容”。一旦不兼容编译器不会折中而是直接报错。interface Animal { kind: cat | dog; } interface Cat extends Animal { kind: cat; }上面这段是合法的因为kind: cat是cat | dog的子类型子接口只是在收窄。反过来就不行interface Animal { kind: cat | dog; } // interface WrongCat extends Animal { // kind: string; // } // 报错string 不能赋给 cat | dog这个态度的本质是 interface 在语义上强调“is-a”关系。Cat是Animal的一种所以它的属性范围再宽也不能比父类型更宽。一个范围更大的kind会打破“Cat 一定是 Animal”的保证。这种强约束在建模领域模型时非常舒服编译器会在你写错继承关系的那一刻拦住你而不是等到运行期再出事。2.2 交叉类型撞了同名属性它认为世界可以折叠交叉类型遇到同名属性时走的是集合论的路线A B表示“同时属于 A 也属于 B”的东西那么属性名撞了就取两个属性类型的交集。type A { value: string }; type B { value: number }; type C A B; // C[value] 是 string number而 string number 会归约成 neverstring和number没有交集所以C的value类型是never。这意味着任何字符串都赋不上去任何数字也赋不上去这个属性实际上变成了一扇打不开的门。它不是编译错误而是“合法的死胡同”。我第一次踩到这个坑时困惑了很久编辑器没有任何红色波浪线但我手写的对象字面量就是赋不进去。对象属性的情况会好很多因为对象类型之间是存在交集的。比如两个接口都有meta字段里面各有一些子属性交叉之后meta会继续递归合并type X { meta: { page: number } }; type Y { meta: { size: number } }; type Z X Y; // Z[meta] 是 { page: number } { size: number } // 一个对象可以同时拥有 page 和 size完全没问题这种“递归求交”的行为相当符合直觉两个对象要同时满足两套结构那嵌套的对象当然也得同时满足两套结构。2.3 从集合论角度看为什么会有这种分歧一句话总结上面两节接口扩展是“契约继承”交叉类型是“集合求交”。前者不允许子类比父类更宽后者天然把所有成员压缩成同时满足两边要求的交集如果交集为空就变成never而不是给你一个编译错误。这种分歧决定了使用场景完全不同。建模实体关系、描述继承层级时interface 的报错能在设计期帮你把关组合零散类型、从联合类型里精确筛选某种能力时的“无中生有”才有用武之地。这也是为什么很多老手会说“能用 interface 就用 interface组合需求留给 type”这句话不算全对但有它存在的道理。2.4 顺带一提interface 还能继承类交叉类型想都别想interface的extends还有一个少有人用的能力继承类类型。它会继承类的所有公共成员并且如果被继承的类里有private或protected成员那么这个接口就只允许这个类本身或其子类来实现。class Base { constructor(public id: string) {} save() {} } interface PersistedEntity extends Base { version: number; } class User extends Base implements PersistedEntity { version 1; }这里的PersistedEntity天然拥有id和save所以User只需要补上version。这种“接口 类”的组合可以用来定义半抽象的领域模型比用抽象类更灵活因为一个接口可以同时继承多个东西类只能单继承。交叉类型没有这个能力它只能组合类型碰不到类的私有成员这是语法层面的硬边界。3. 交叉类型的隐藏能力函数重载、品牌类型与条件类型3.1 函数类型做交集其实就是给你生成重载交叉类型不止用于对象属性组合它对函数类型也有一套特殊行为。从集合论角度来说type F ((x: string) void) ((x: number) void)表示“一个函数既满足接受 string 的签名又满足接受 number 的签名”。翻译成 TypeScript 的语言它就成了一个重载函数。type Handler ((id: string) string) ((id: number) number); function run(h: Handler) { const a h(1); // string const b h(2); // number }调用时string和number都能传入返回值也会按照对应签名推断。这个行为在实现“入参类型不同返回类型也不同”的 API 时很省事。但这里有个陷阱交叉顺序会影响重载匹配结果。如果两个签名之间有重叠TypeScript 会按从左到右的顺序尝试匹配一单第一个签名能吃下这个参数后面的签名就不会被考虑。在真实项目里我会优先写明确的function重载而不是靠隐式生成不然同事读代码的时候要靠猜。3.2 用 做品牌类型给 string 加上身份证交叉类型另一个杀手级应用是品牌类型也叫不透明类型、名义类型。本质上就是“原始类型 一个只读的特殊标记字段”declare const userIdBrand: unique symbol; type UserId string { readonly __brand: typeof userIdBrand }; declare const productIdBrand: unique symbol; type ProductId string { readonly __brand: typeof productIdBrand };UserId在运行时的值就是一个普通字符串但在类型层面TypeScript 不允许直接把string赋给UserId必须通过工厂函数或校验函数来“铸造”。这样两个领域里本该严格区分的 ID不会因为底层都是 string 而混用。我用这个方式处理过对接后端接口时的各种 ID 混传问题订单 ID、用户 ID、商品 ID 背后全是字符串传参时经常写错位置。包一层品牌类型之后传错参数直接编译失败。这个方案只有交叉类型能做因为接口必须是一个对象形状不能直接interface UserId extends string。这类手段在前端对接后端接口、微服务边界传参时非常实用。3.3 条件类型中的 extends 才是另一个江湖顺带提一个很容易搞混的点TypeScript 里还有另一个extends出现在泛型约束和条件类型里比如T extends string ? A : B。它和接口扩展的extends不是一回事但名字一样经常把新人绕晕。泛型约束里的extends表达“T 必须满足某个条件结构”它和交叉类型有很强的互补关系用来把约束拼进类型extends用来从类型里做分支判断。理解了这一点再看内置工具类型就会轻松很多比如NonNullableT T extends null | undefined ? never : T它走的不是接口扩展而是条件分支。4. 实操中最容易翻车的三个细节4.1 交叉类型在编辑器里糊成一团可读性怎么办先讲一个开发体验问题。页面组件 props 里写type Props BaseProps WithExtra WithContext鼠标悬浮上去编辑器直接显示成三四个类型表达式的交叉一眼看不到最终形状。遇到复杂的嵌套类型排错非常痛苦。我自己的习惯是给交叉类型包一层“展平工具类型”把交叉结构重新映射成扁平对象type SimplifyT { [K in keyof T]: T[K] } {}; type Props SimplifyBaseProps WithExtra WithContext;这样悬浮提示里显示的就是一个完整扁平对象。这个技巧不改变类型语义纯粹为了开发体验但在大型项目里能省下非常多排查时间。代码评审时看到我写这个工具类型不止一个同事问是干嘛的等他们自己也遇到“一屏装不下一个类型提示”的场景后都真香了。4.2 联合类型与交叉类型的分配律A (B | C)和(A B) | (A C)在集合论上是等价的TypeScript 类型层面也基本遵循这一套。这意味着你可以通过交叉类型给联合类型里的每个成员附加公共能力type ClickEvent { type: click } ({ x: number } | { y: number }); // 等价于 { type: click; x: number } | { type: click; y: number }这是交叉类型在处理事件模型、状态机模型时的核心优势。每种事件本身是联合类型的一个成员再与一个“公共事件上下文”做交叉得到的仍然是联合类型后续用switch收窄时不会丢失任何信息。如果这里误用接口扩展去继承联合类型编译直接不通过。所以遇到这种“给多个形态附加共同属性”的需求第一反应应该是。4.3 别忘了声明合并这是 interface 独有的interface 还有一个 type 别名完全不具备的能力声明合并。同一个 interface 名字可以被声明多次它们会自动合成一个。interface User { name: string; } interface User { age: number; } // User 最终拥有 name 和 age这给了我们扩展第三方库类型、按模块切分领域模型的能力。交叉类型没有这个特性同一个 type 别名重复声明会直接报重复标识符。我见过有项目强行用 type 去组合别人提供的 interface结果发现两个同名 interface 已经自动合并了重新组合时反而带了一堆多余字段。这说明一个工程里要形成统一约定对外发布、可扩展的类型契约用 interface内部临时拼装用 type两条路按边界分开走才不会互相踩脚。5. 项目里怎么选设计契约还是做积木5.1 什么时候闭眼用 interface extends我的结论很直接在定义对外 API、ORM 实体、领域模型或者任何需要明确“谁是父概念、谁是子概念”的地方默认用interface extends。因为它有继承语义编译器会在设计期强制检查同名属性保证子类型能够赋值给父类型这对契约稳定性非常重要。再加上声明合并第三方消费者还能在你定义的接口上做增量扩展不需要改动你的源码。这些都是 interface 在类型生态里的护城河。接口扩展还支持一次继承多个接口这在领域建模里很顺手interface WithId { id: string; } interface WithTimestamps { createdAt: Date; updatedAt: Date; } interface Post extends WithId, WithTimestamps { title: string; }5.2 什么时候放心用 当我在组装局部 props、把多个零散类型拼成临时对象、做品牌类型、或者给现有类型增加一层约束时我用交叉类型。最大的价值是组合任意东西不受对象形状限制也不要求成员之间有任何继承关系。缺点是语义模糊两个同名属性撞在一起可能生成一个never而不是报错所以使用前要确认各成员之间没有冲突属性并用展平类型改善可读性。另外交叉类型无法声明合并如果你需要给别人预留扩展点光靠是不够的得配合 interface 一起用。5.3 混搭起来的经典组合WithProps、状态机与判别联合一个我反复使用的模式是interface 定义主契约type 定义组合插槽最后用拼装。比如 React 组件里经常这么写interface ButtonBaseProps { variant: primary | secondary; disabled?: boolean; } type ButtonProps ButtonBaseProps { icon?: ReactNode; };再比如状态机模型用 interface 定义每个状态的载荷用联合类型列出状态再用交叉类型附加公共元信息interface LoadingState { status: loading; } interface SuccessState { status: success; data: unknown; } interface FailureState { status: failure; error: Error; } type State | (LoadingState { retryCount?: number }) | SuccessState | FailureState;这里LoadingState与{ retryCount?: number }交叉之后仍然保持了判别联合的收窄能力而且每个 loading 状态下可以额外挂上重试次数。这是我在真实项目里对接口扩展和交叉类型最常见的用法也是我认为两者合作得最好的例子。最后说一个我自己总结的习惯遇到一个类型设计问题先问一句“我要表达的是继承关系还是叠加关系”。如果是前者interface extends 会帮你守住边界如果是后者交叉类型能给你最大的灵活度。把这个问题想清楚比硬记一百条语法规则都管用。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →