1. 前言
在Typescript
中有两种定义类型的方式:类型(type
)和接口(interface
),但是在实际项目中,我们有时候会比较困惑,我们是使用type
来定义类型呢还是使用interface
来定义类型?很显然,这个问题的答案是——我们需要视情况而定。大多数场景下,这两种方式都是可以的,但是在某些情况下,其中一个会比另外一个具有明显的优点。接下来,我们就来看看type
和interface
的主要区别和相似之处,并且探讨一下什么情况下选择谁更合适。
2. types 和 类型别名
在Typescript
中,包含了这些原始类型:String(字符类型)、Boolean(布尔类型)、Number(数字类型)、Array(数组类型)、Tuple(元祖类型)、Enum(枚举类型)、Any、null等,而type
是Typescript
中的一个关键字,给我们提供了一种为现有类型创建新名称的方法——类型别名。我们通过它引用任何有效的Typscript类型(包括原始类型)来创建一个类型别名,以适用各式场景。需要注意的是,类型别名它不会定义新的类型,它只是为现有类型定义替代名称。例如:
type MyNumber = number
type User = {
id: number
name: string
age: number
address: string
}
在示例中,我们分别定义了两个类型别名:MyNumber
和User
。我们可以使用MyNumber
作为数字类型的别名,并使用User
类型别名来表示用户的类型定义。
当我们说 “类型与接口” 时,其实指的是 “类型别名与接口”。例如,我们可以创建以下类型别名:
type ErrorCode = string | number
type Answer = string | number
上面的两个类型别名代表相同含义的联合类型:string|number
的替代名称。虽然底层类型相同,但不同的名称表达不同的意图,这使得我们的代码更具可读性。
3. 接口(interface)
在 Typescript
中,接口约定了对象的格式规范。例如,下面我们声明了Client
的类型格式:
interface Client {
name: string
address: string
port: number
}
类似的,我们也可以使用type
来表达相同的Client
类型格式:
type Client = {
name: string
address: string
port: number
}
4. type 和 interface 的区别
在上面的例子中,我们使用type
或interface
都可以。但是在某些情况下,使用type
和使用interface
会有不同。下面我们就来探讨这些不同之处。
4.1 原始类型
原始类型是 Typescript
中的内置类型,在上文,我们也说到,Typescript
中的原始类型包括:String(字符类型)、Boolean(布尔类型)、Number(数字类型)、Array(数组类型)、Tuple(元祖类型)、Enum(枚举类型)、Any、null等。我们可以像下面这样为一个元素类型定义一个类型别名:
type Name = string;
type Price = number
但是,我们不能使用interface
来给原始类型定义一个类型别名,它只能用于定义对象类型。因此,当我们需要定义一个原始类型别名时,我们需要使用type
。
4.2 联合类型
在 Typescript
中,联合类型(Union Types)允许我们将多个类型(各种原始类型和复杂类型)组合在一起,从而使变量可以是多种类型之一。使用联合类型时,我们可以使用竖线( | )来分隔不同的类型,同时能够增加代码可读性。 例如:
type NullOrUndefined = null | undefined;
type Transport = 'Bus' | 'Car' | 'Bike' | 'Walk';
联合类型只能使用type
来定义,我们没法使用interface
来定义,但是,我们可以通过interface
创建两个类型之后,再来创建一个新的联合类型,如下所示:
interface Ball {
power: number;
}
interface Computer {
type: string;
}
type MyGift = Ball | Computer;
4.3 函数类型
在Typescript
中,使用type
来定义函数类型别名时,我们需要指定参数类型和返回类型:
type AddFn = (num1: number, num2:number) => number;
我们也可以使用interface
来定义函数类型:
interface IAdd {
(num1: number, num2:number): number;
}
如示例所示,type
和interface
都能定义函数类型,不过在语法上有细微差别,type
定义时使用=>
,而interface
定义时使用:
。一般情况下,我们优先使用type
来定义函数类型,因为它更短更简洁,代码可读性更高,更重要的是interface
缺少一些type
的功能。因此在定义复杂的函数类型时,使用type
会更便捷一些。例如;
type Car = 'ICE' | 'EV';
type CarEV = (kws: number)=> void;
type CarFillPetrol = (type: string, liters: number) => void;
type CarHandler<A extends Car> = A extends 'ICE' ? CarFillPetrol : A extends 'EV' ? CarEV : never;
const chargeTesla: CarHandler<'EV'> = (power) => {
// 约束类型 CarEV
};
const refillToyota: CarHandler<'ICE'> = (fuelType, amount) => {
// 约束类型 CarFillPetrol
};
在这里,我们通过条件类型和联合类型实现定义了一个CarHandler
类型,它提供了一个统一的函数类型定义来进行类型安全检查,会根据传入的类型参数T
是EV
还是ICE
返回不同的函数类型定义。我们不能用接口(interface)来实现相同的功能,因为接口不支持条件类型和联合类型的功能。
4.4 声明合并
接口的声明合并是 Typescript
的一个特性,仅对接口和命名空间有效。如果我们多次定义同名接口,Typescript
编译器会自动将这些分散的定义合并成一个完整的接口定义。这种特性在定义类型的扩展、模块化开发、第三方库类型补充等方面非常有用。
下面例子中,Typescript
将 User
接口的三次声明合并成一个完整的接口,包含了 name、age 和 isAdmin 三个属性。
// 第一次声明
interface User {
name: string;
}
// 第二次声明,会自动合并到之前的User接口声明中
interface User {
age: number;
}
// 第三次声明,继续合并
interface User {
isAdmin: boolean;
}
// 使用合并后的User接口
let user: User = {
name: 'John',
age: 30,
isAdmin: false
};
通过type
定义的类型别名不能通过上面相同的方式进行合并,如果我们类型那样尝试多次定义User
类型,Typescript
就会抛出一个错误,如:
type User = {
name: string;
};
type User = {
age: number;
};
当在正确的场景中使用时,声明合并会非常有用。常见的场景比如扩展第三方库的类型定义,以满足特定项目的需求。因为interface
接口具有自动合并的特性,能方便地在不同地方逐步完善类型定义。而type
类型别名不支持这种合并方式,如果对类型别名重复定义会导致报错,所以利用interface
接口的声明合并,可以在不修改第三方库原始代码的情况下,对其类型定义进行扩展和定制,使其更好地适应项目中的具体应用场景。
4.5 继承(Extends)和交叉类型(intersection)
接口可以继承一个或多个接口。我们通过使用extends关键字,新接口可以继承现有接口的所有属性和方法,同时还能添加新的属性。下面的例子中,我们创建了一个通过继承Client
接口创建了VIPClient
接口:
interface Client {
name: string;
}
interface VIPClient extends Client {
benefits: string[]
}
如果使用type
类型别名来创建VIPClient
,我们需要通过交叉运算符(&
)来创建:
type Client = {
name: string;
};
type VIPClient = Client & {benefits: string[]}; // Client 是一个类型别名
你也可以从具有已知静态成员的类型别名来扩展一个接口。例如:
type Client = {
name: string;
};
interface VIPClient extends Client {
benefits: string[]
}
但是也有例外,如果这个类型别名是一个联合类型,Typescript
将会报错:
type Jobs = 'salary worker' | 'retired';
interface MoreJobs extends Jobs {
description: string;
}
// 这样写会报错,报错的原因是联合类型不是静态已知的,而接口定义需要在编译时静态已知。
类型别名也可以通过交叉运算符来继承一个接口:
interface Client {
name: string;
}
Type VIPClient = Client & { benefits: string[]};
总而言之,接口和类型别名都可以继承。接口可以继承静态已知的类型别名,而类型别名可以使用交叉运算符来继承接口。
4.6 继承时处理冲突
类型别名和接口之间的另一个区别是,它们对具有相同属性名称的类型扩展时处理冲突的方式不一样。在通过extend
继承接口时,不允许使用相同的属性键,如下例所示:
interface Person {
getPermission: () => string;
}
interface Staff extends Person {
getPermission: () => string[];
}
Typescript
检测到Person
接口和Staff
属性有冲突就会报错。
而类型别名在处理冲突时与接口不一样,它继承另一个类型时遇到相同属性名的冲突时,会自动合并而不是报错。例如,交叉运算符合并了两个定义包含getPermission
属性名的类型,并使用typeof
运算符缩小联合类型参数,以便我们可以返回安全类型:
type Sale = {
getPermission: (id: string) => string;
};
type Manager = Sale & {
getPermission: (id: string[]) => string[];
};
const Admin: Manager = {
getPermission: (id: string | string[]) =>{
return (typeof id === 'string'? 'admin' : ['admin']) as string[] & string;
}
}
需要注意的是,两个属性的类型合并可能会产生未知的结果。例如在下例中,扩展类型员工的name
属性将变成never
,因为它不能同时是字符串和数字:
type Person = {
name: string
};
type Staff = Person & {
name: number
};
// error: Type 'string' is not assignable to type 'never'.(2322)
const Harry: Staff = { name: 'Jack' };
总之,接口将在编译时检测属性或方法名冲突并抛出错误,而类型别名交叉时将会合并属性或方法而不会抛出错误。因此,如果我们需要重新加载函数的时候,应该使用类型别名。
虽然类型别名在交叉时可以自动合并冲突命名相同的属性,但是我们还是建议优先使用继承(extend
)而不是交叉类型(&
)。一方面,接口继承的可读性更好,当我们使用接口时,无论你组合或扩展了多少种类型,TypeScript
一般能在错误消息、提示信息以及集成开发环境(IDE)中更好地展示接口的结构形态。而如果我们使用交叉类型,如果我们使用两个或更多类型交叉的类型别名(比如 type A = B & C
,然后又在另一个交叉类型中使用该别名,如 type X = A & D
),TypeScript
可能很难展示组合类型的结构,这不方便我们从错误消息中理解类型的结构。
另一方面,TypeScript
会缓存接口间已检测出关系的结果,比如一个接口是否扩展了另一个接口,或者两个接口是否兼容。当未来引用相同接口关系时,这种方式能提升整体性能。相反,在处理交叉类型时,TypeScript
不会缓存这些关系。每次使用类型交集时,TypeScript
都必须重新检查整个交叉类型,这可能会引发性能方面的问题。
4.7 使用接口或类型别名来实现类(Class)
在 Typescript
中,我们可以通过接口或者类型别名来实现一个类:
interface Person {
name: string;
greet(): void;
}
class Student implements Person {
name: string;
greet() {
console.log('Hello!');
}
}
type Pet = {
name: string;
run(): void;
};
class Dog implements Pet {
name: string;
run() {
console.log('Run!');
}
}
上面的例子中,接口和类型别名都可以实现一个类,但是他们两唯一的区别是,联合类型不能用来实现类。
type privateKey = { key: number; } | { key: string; };
// 报错
// A class can only implement an object type or intersection of object types with statically known members.(2422)
class PasswordKey implements privateKey {
key = 1
}
在上述示例中,TypeScript
编译器会抛出一个错误,因为类代表的是一种特定的数据结构形态,而联合类型可以是多种数据类型中的一种。
4.8 使用元祖类型
在TypeScript
中,元组类型允许我们表达具有固定数量元素的数组,其中每个元素都有其数据类型。当您需要处理具有固定结构的数据数组时,它会很有用:
type TeamMember = [name: string, role: string, age: number];
元组类型具有固定的长度,并且每个位置都被固定分配了一种类型,如果我们尝试以违反此结构的方式添加、删除或修改元素,TypeScript
将会抛出错误。例如:
const member: TeamMember = ['Alice', 'Dev', 28];
member[3];
// Error: Tuple type '[string, string, number]' of length '3' has no element at index '3'.
接口不直接支持元组类型。虽然我们有一些其它解决方法来实现,但它不像使用元组类型那样简洁或易读,例如:
interface ITeamMember extends Array<string | number> {
0: string;
1: string;
2: number
}
const Member1: ITeamMember = ['Jack', 'Dev', 24];
const Member2: ITeamMember = ['John', 30, 'Manager']; //Error: Type 'number' is not assignable to type 'string'.
与元组类型不同,ITeamMember
继承了通用的Array
类型,这使其能够拥有前三个之外的任意数量的元素。这是因为TypeScript
中的数组是动态的,您可以访问接口中明确定义的索引之外的索引或为其赋值:
const Member3: ITeamMember = ['Peter', 'Dev', 24];
console.log(Member3[3]); // No error, even though this element is undefined.
4.9 高级类型特性
TypeScript
提供了一些接口不支持的高级类型特性:
- 类型推断:可以根据变量和函数的使用情况推断其类型。这减少了代码量并提高了可读性。
- 条件类型:允许我们创建具有依赖于其他类型的条件行为的复杂类型表达式
- 类型守卫:可以根据变量的类型编写复杂的控制流
- 类型映射:可以将现有对象类型转换为新类型
- 实用工具类型(Utility types):一些内置的类型,允许我们以新的方式操作和转换其他类,使代码创建更简洁、更具表达力的代码
随着每个新版本的发布,TypeScript
的类型系统也在不断发展,下面是接口无法实现的高级类型特性的示例:
type Client = {
name: string;
address: string;
}
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type clientType = Getters<Client>;
// 等价于
// type clientType = {
// getName: () => string;
// getAddress: () => string;
// }
在这个示例中,我们使用映射类型、模板文字类型和keyof运算符,我们创建了一个自动为任何对象类型生成getter方法的类型。
总结
在本文中,我们讨论了类型别名和接口的相似点和差异,大多数场景下,几乎所有的接口特性都可以在类型中使用或具有等价方法,但在某些场景下,使用其中一个还是比使用另一个要好一些。总的来说,我们需要根据语义和使用场景选择使用interface
还是type
。如果要定义一个对象的结构并且可能涉及继承关系或者与类一起使用,接口通常是更好的选择。如果是定义复杂的类型组合(联合类型、交叉类型)或者对类型推导进行命名,类型别名更合适。
什么时候适合使用interface
:
- 需要声明合并的场景下,通常我们应该优先考虑使用接口,例如扩展现有库或开发新库。由于接口拥有面向对象的继承风格,使用
extend
关键字通常比交叉更具可读性。还有一点,接口的继承比交叉类型性能更好一点。
什么时候适合使用type
:
- 为一种原始类型创建一个类型别名时
- 定义联合类型、元组类型、函数类型或其他更复杂的类型时
- 需要重载函数时
- 使用映射类型、条件类型、类型保护或其他高级类型功能
文中如有错误,敬请指正。