TypeScript 中 interface 与 type 的区别和选型建议
interface 和 type 到底该用哪个,是 TypeScript 项目里最容易起争执的问题之一。网上大部分回答停在「差不多,看团队规范」,但真到了写 Record<string, unknown> 报错、两个类型交叉之后属性静默变成 never、或者要给第三方库补类型这几个场景,选错了是要返工的。
这篇把两者真正有差别的地方一条条列出来,每条都在 TypeScript 5.9.3 上验证过,最后给一份可以直接照着执行的选型规则。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 两者在描述对象和函数上的写法差异,以及为什么可以互相
extends type独有的能力,联合类型、元组、映射类型、条件类型、模板字面量类型interface独有的声明合并,以及它在给第三方库补类型时的不可替代性extends和交叉类型在遇到属性冲突时的行为差异,这条最容易踩- 隐式索引签名,为什么
interface赋值给Record会报错而type不会 - 报错信息、编译性能,以及最终的选型建议
# 一、相同之处
# 1.1 都可以描述一个对象或者函数
interface
interface User {
name: string
age: number
}
interface SetUser {
(name: string, age: number): void;
}
type
type User = {
name: string
age: number
};
type SetUser = (name: string, age: number)=> void;
这里补一个原文没提的细节。上面 SetUser 的两种写法虽然等价,但 interface 那种「调用签名」的形式支持给函数类型追加静态属性,type 要做到同样的事得写交叉类型。日常业务里用不到,写库的时候偶尔会遇到。
# 1.2 都允许拓展(extends)
interface 和 type 都可以拓展,并且两者并不是相互独立的,也就是说 interface 可以 extends type,type 也可以 extends interface。虽然效果差不多,但是两者语法不同。
interface extends interface
interface Name {
name: string;
}
interface User extends Name {
age: number;
}
type extends type
type Name = {
name: string;
}
type User = Name & { age: number };
interface extends type
type Name = {
name: string;
}
interface User extends Name {
age: number;
}
type extends interface
interface Name {
name: string;
}
type User = Name & {
age: number;
}
四种组合都合法,写法上一个用 extends 一个用 &。这里说「效果差不多」是有前提的,前提是两边没有同名属性冲突。冲突的时候差别巨大,第四节专门讲这个。
另外注意 interface extends 的右边只能是对象类型或者由对象类型组成的交叉类型,你没法 interface Foo extends (string | number),因为联合类型不是对象类型。
# 二、type 可以而 interface 不行
type 可以声明基本类型别名、联合类型、元组等类型:
// 基本类型别名
type Name = string
// 联合类型
interface Dog {
wong(): void;
}
interface Cat {
miao(): void;
}
type Pet = Dog | Cat
// 具体定义数组每个位置的类型
type PetList = [Dog, Pet]
原文这里的 wong(); 和 miao(); 没写返回类型,在 strict 模式下会直接报 TS7010: 'wong', which lacks return-type annotation, implicitly has an 'any' return type。我在 TypeScript 5.9.3 上确认过,所以上面补上了 : void。老文里这类省略很常见,因为当年很多项目不开 strict。
type 语句中还可以使用 typeof 获取实例的类型进行赋值:
// 当你想获取一个变量的类型时,使用 typeof
let div = document.createElement('div');
type B = typeof div
其他骚操作:
type StringOrNumber = string | number;
type Text = string | { text: string };
type NameLookup = Dictionary<string, Person>;
type Callback<T> = (data: T) => void;
type Pair<T> = [T, T];
type Coordinates = Pair<number>;
type Tree<T> = T | { left: Tree<T>, right: Tree<T> };
# 2.1 真正拉开差距的是类型运算
上面这些还只是「别名」层面的能力。type 和 interface 真正的分水岭在于,TypeScript 后来加进来的类型运算能力全部只挂在 type 上。
映射类型,遍历一个类型的所有 key 生成新类型:
type Readonly<T> = { readonly [K in keyof T]: T[K] }
type Partial<T> = { [K in keyof T]?: T[K] }
条件类型,根据类型关系分支:
type NonNullable<T> = T extends null | undefined ? never : T
type ElementOf<T> = T extends (infer U)[] ? U : never
模板字面量类型,用字符串拼接构造类型:
type EventName<T extends string> = `on${Capitalize<T>}`
type Click = EventName<'click'> // 'onClick'
内置的工具类型 Partial、Pick、Omit、Record、ReturnType 全部是用 type 实现的,因为它们的实现依赖映射类型和条件类型。你想自己写一个通用的类型工具,只有 type 这一条路。
所以「两者差不多」这个说法在 2019 年勉强成立,放到今天已经不成立了。
# 三、interface 可以而 type 不行
interface 能够声明合并:
interface User {
name: string
age: number
}
interface User {
sex: string
}
/*
User 接口为 {
name: string
age: number
sex: string
}
*/
同名的 type 会直接报 TS2300: Duplicate identifier,我在 5.9.3 上跑过,两行都会标红。
# 3.1 声明合并不是玩具特性
第一次看到声明合并,我的反应是这玩意儿听着像个陷阱,同名的东西自动合并,出问题很难查。后来才发现它在一个场景里完全不可替代:给别人的类型补东西。
给全局对象加字段:
declare global {
interface Window {
__APP_VERSION__: string
}
}
给 Express 的 Request 挂上自己的用户信息:
declare module 'express-serve-static-core' {
interface Request {
user?: { id: string; role: string }
}
}
这两种写法都依赖声明合并,用 type 做不到。你不可能去改 @types/express 的源码,也不可能重新声明一个同名 type。所以只要你的项目需要扩展第三方库或者全局类型,interface 就是必选项。
反过来说,声明合并在业务类型上确实是个隐患。团队里两个人各自在不同文件写了 interface Config,TypeScript 不会报错,只会安静地合并,最后拿到的是一个谁也没预期的类型。这是很多团队规范里写「业务类型统一用 type」的理由。
# 四、extends 和交叉类型,冲突时行为完全不同
这一节是整篇最实用的部分。
同名属性类型冲突时,interface extends 会立刻报错:
interface A { x: string }
interface B extends A { x: number }
// TS2430: Interface 'B' incorrectly extends interface 'A'.
// Types of property 'x' are incompatible.
// Type 'number' is not assignable to type 'string'.
交叉类型不报错,它会把两个类型求交集:
type C = { x: string } & { x: number }
declare const c: C
const cx: never = c.x // 编译通过,c.x 的类型就是 never
string 和 number 没有交集,所以 c.x 静默变成了 never。你把 C 传给别的函数,报错会出现在很远的地方,提示大概是「never 类型的参数不能赋给 string」,然后你要顺着调用链一路往回找,才能找到这个交叉类型。
这个我在 TypeScript 5.9.3 上验证过,两段的行为完全如上。
先说结论,继承关系用 interface extends 比用 & 安全,因为错误会在定义处就暴露,而不是延后到使用处。
# 4.1 隐式索引签名
这条也很容易撞上。同样是描述一个只有 name 的对象,type 声明的能赋值给 Record<string, unknown>,interface 声明的不能: