DevDailyNews

TypeScript 中的类型与接口:选择与应用

TypeScript 中的类型与接口:选择与应用

在 TypeScript 中,我们有两个选项来定义类型:typeinterface。关于 TypeScript 最常被问到的问题之一就是我们应该使用 interface 还是 type

与许多编程问题一样,答案取决于具体情况。在某些情况下,一种方式明显优于另一种,但在许多情况下,它们是可以互换的。

本文将讨论 typeinterface 之间的关键差异和相似之处,并探讨何时适合使用每一种。

类型与类型别名

type 是 TypeScript 中的一个关键字,用于定义数据的形状。TypeScript 的基本类型包括:

每种类型都有其独特的特性和用途,允许开发者根据具体用例选择合适的类型。

类型别名在 TypeScript 中意味着“任何类型的名称”。它们提供了一种为现有类型创建新名称的方式。类型别名不会定义新类型,而是为现有类型提供一个替代名称。类型别名可以使用 type 关键字创建,并引用任何有效的 TypeScript 类型,包括原始类型。

type MyNumber = number;
type User = {
  id: number;
  name: string;
  email: string;
}

在上面的例子中,我们创建了两个类型别名:MyNumberUser。我们可以使用 MyNumber 作为数字类型的简写,并使用 User 类型别名来表示用户的类型定义。

当我们说“类型与接口”时,我们指的是“类型别名与接口”。例如,你可以创建以下别名:

type ErrorCode = string | number;
type Answer = string | number;

这两个类型别名代表相同的联合类型:string | number。虽然底层类型相同,但不同的名称表达了不同的意图,这使得代码更具可读性。

接口在 TypeScript 中

在 TypeScript 中,接口定义了一个对象必须遵守的契约。以下是一个示例:

interface Client { 
    name: string; 
    address: string;
}

我们也可以使用类型注释来表达相同的 Client 契约定义:

type Client = {
    name: string;
    address: string;
};

类型与接口的差异

对于上述情况,我们可以使用 typeinterface。但在某些场景中,使用 type 而不是 interface 会有所不同。

原始类型

原始类型是 TypeScript 中的内置类型,包括 numberstringbooleannullundefined 类型。我们可以为原始类型定义类型别名,如下所示:

type Address = string;

我们通常将原始类型与联合类型结合使用,以定义类型别名,使代码更具可读性:

type NullOrUndefined = null | undefined;

但是,我们不能使用接口来为原始类型创建别名。接口只能用于对象类型。因此,当我们需要定义原始类型别名时,我们使用 type

联合类型

联合类型允许我们描述可以是几种类型之一的值,并创建各种原始、字面量或复杂类型的联合:

type Transport = 'Bus' | 'Car' | 'Bike' | 'Walk';

联合类型只能使用 type 定义。接口中没有与联合类型等价的功能。但是,可以从两个接口创建新的联合类型,如下所示:

interface CarBattery {
  power: number;
}
interface Engine {
  type: string;
}
type HybridCar = Engine | CarBattery;
函数类型

在 TypeScript 中,函数类型表示函数的类型签名。使用类型别名时,我们需要指定参数和返回类型来定义函数类型:

type AddFn =  (num1: number, num2:number) => number;

我们也可以使用接口来表示函数类型:

interface IAdd {
   (num1: number, num2:number): number;
}

类型和接口在定义函数类型时类似,除了接口使用 : 而不是 =>。在这种情况下,类型更受欢迎,因为它更短,更容易阅读。

另一个使用类型定义函数类型的原因是其功能是接口所缺乏的。当函数变得更复杂时,我们可以利用高级类型特性,如条件类型、映射类型等。以下是一个示例:

type Car = 'ICE' | 'EV';
type ChargeEV = (kws: number)=> void;
type FillPetrol = (type: string, liters: number) => void;
type RefillHandler<A extends Car> = A extends 'ICE' ? FillPetrol : A extends 'EV' ? ChargeEV : never;
const chargeTesla: RefillHandler<'EV'> = (power) => {
    // Implementation for charging electric cars (EV)
};
const refillToyota: RefillHandler<'ICE'> = (fuelType, amount) => {
    // Implementation for refilling internal combustion engine cars (ICE)
};

在这里,我们定义了一个带有条件类型和联合类型的 RefillHandler 类型。它以类型安全的方式为 EV 和 ICE 处理程序提供统一的函数签名。接口无法实现相同的功能,因为它没有条件类型和联合类型的等价物。

声明合并

声明合并是接口独有的功能。通过声明合并,我们可以多次定义一个接口,TypeScript 编译器会自动将这些定义合并为一个接口定义。

在以下示例中,两个 Client 接口定义被 TypeScript 编译器合并为一个,当我们使用 Client 接口时,我们有两个属性:

interface Client { 
    name: string; 
}

interface Client {
    age: number;
}

const harry: Client = {
    name: 'Harry',
    age: 41
}

类型别名不能以相同的方式合并。如果你尝试多次定义 Client 类型,如下例所示,将会抛出错误:

type Client = {
    name: string;
};

type Client = {
    age: number;
};
// Error: Duplicate identifier 'Client'.

在正确的地方使用声明合并非常有用。一个常见的用例是扩展第三方库的类型定义以适应特定项目的需求。如果你需要合并声明,接口是最佳选择。

扩展与交叉

接口可以扩展一个或多个接口。使用 extends 关键字,新接口可以继承现有接口的所有属性和方法,同时添加新属性。

例如,我们可以通过扩展 Client 接口创建 VIPClient 接口:

interface VIPClient extends Client {
    benefits: string[]
}

要为类型实现类似的结果,我们需要使用交叉运算符:

type VIPClient = Client & {benefits: string[]}; // Client 是一个类型

你还可以从具有静态已知成员的类型别名扩展接口:

type Client = {
    name: string;
};

interface VIPClient extends Client {
    benefits: string[]
}

例外是联合类型。如果你尝试从联合类型扩展接口,你将收到以下错误:

type Jobs = 'salary worker' | 'retired';

interface MoreJobs extends Jobs {
  description: string;
}
// Error: An interface can only extend an object type or intersection of object types with statically known members.

此错误发生是因为联合类型不是静态已知的。接口定义需要在编译时静态已知。

类型别名可以使用交叉运算符扩展接口,如下所示:

interface Client {
    name: string;
}
type VIPClient = Client & { benefits: string[]};

简而言之,接口和类型别名都可以扩展。接口可以扩展静态已知的类型别名,而类型别名可以使用交叉运算符扩展接口。

处理扩展时的冲突

类型和接口的另一个区别是当你尝试从具有相同属性名称的接口扩展时如何处理冲突。

当扩展接口时,不允许相同的属性键,如下例所示:

interface Person {
  getPermission: () => string;
}

interface Staff extends Person {
   getPermission: () => string[];
}
// Error: Subsequent property declarations must have the same type.  Property 'getPermission' must be of type '() => string', but here has type '() => string[]'.

当检测到冲突时,会抛出错误。

类型别名处理冲突的方式不同。在类型别名扩展另一个具有相同属性键的类型的情况下,它会自动合并所有属性,而不是抛出错误。

在以下示例中,交叉运算符合并了两个 getPermission 声明的方法签名,并使用 typeof 运算符缩小联合类型参数的范围,以便我们可以以类型安全的方式获取返回值:

type Person = {
  getPermission: (id: string) => string;
};

type Staff = Person & {
   getPermission: (id: string[]) => string[];
};

const AdminStaff: Staff = {
  getPermission: (id: string | string[]) =>{
    return (typeof id === 'string'?  'admin' : ['admin']) as string[] & string;
  }
}

需要注意的是,两个属性的类型交叉可能会产生意外结果。在下面的示例中,扩展类型 Staffname 属性变为 never,因为它不能同时是 stringnumber

type Person = {
    name: string
};

type Staff = person & {
    name: number
};
// error: Type 'string' is not assignable to type 'never'.(2322)
const Harry: Staff = { name: 'Harry' };

总之,接口会在编译时检测属性或方法名称冲突并生成错误,而类型交叉会合并属性或方法而不抛出错误。因此,如果我们需要重载函数,应该使用类型别名。

优先使用扩展而不是交叉

通常,当使用接口时,TypeScript 在错误消息、工具提示和 IDE 中更好地显示接口的形状。无论你组合或扩展多少类型,它都更容易阅读。

相比之下,使用交叉运算符的类型别名(如 type A = B & C;)在另一个交叉中使用时(如 type X = A & D;),TypeScript 可能难以显示组合类型的结构,使得从错误消息中理解类型的形状更加困难。

TypeScript 会缓存接口之间的关系结果,例如一个接口是否扩展另一个接口或两个接口是否兼容。这种方法在将来引用相同关系时提高了整体性能。

相比之下,当使用交叉运算符时,TypeScript 不会缓存这些关系。每次使用类型交叉时,TypeScript 都必须重新评估整个交叉,这可能会导致效率问题。

因此,建议使用接口扩展而不是依赖类型交叉。

使用接口或类型别名实现类

在 TypeScript 中,我们可以使用接口或类型别名来实现类:

interface Person {
  name: string;
  greet(): void;
}

class Student implements Person {
  name: string;
  greet() {
    console.log('hello');
  }
}

type Pet = {
  name: string;
  run(): void;
};

class Cat implements Pet {
  name: string;
  run() {
    console.log('run');
  }
}

如上所示,接口和类型别名都可以类似地用于实现类;唯一的区别是我们不能实现联合类型。

type primaryKey = { key: number; } | { key: string; };

// can not implement a union type
class RealKey implements primaryKey {
  key = 1
}
// Error: A class can only implement an object type or intersection of object types with statically known members.

在上面的示例中,TypeScript 编译器抛出错误,因为类代表特定的数据形状,但联合类型可以是几种数据类型之一。

使用元组类型

在 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 peter: ITeamMember = ['Harry', 'Dev', 24];
const Tom: ITeamMember = ['Tom', 30, 'Manager']; //Error: Type 'number' is not assignable to type 'string'.

与元组不同,此接口扩展了泛型数组类型,这使得它可以拥有超过前三个元素的任意数量的元素。这是因为 TypeScript 中的数组是动态的,你可以访问或分配超出接口中显式定义的索引的值:

const peter: ITeamMember = [’Peter’, 'Dev', 24];
console.log(peter[3]); // No error, even though this element is undefined.
高级类型特性

TypeScript 提供了广泛的先进类型特性,这些特性在接口中找不到。TypeScript 中的一些独特特性包括:

TypeScript 的类型系统随着每个新版本的发布不断发展,使其成为一个复杂而强大的工具箱。强大的类型系统是许多开发者更喜欢使用 TypeScript 的主要原因之一。

何时使用类型与接口

类型别名和接口相似,但有细微的差异,如前文所述。

虽然几乎所有接口功能在类型中都有等价物,但有一个例外是声明合并。接口通常应在需要声明合并时使用,例如扩展现有库或编写新库。此外,如果你更喜欢面向对象的继承风格,使用 extends 关键字的接口通常比使用交叉运算符的类型别名更具可读性。

接口的 extends 使编译器更具性能,相比之下,类型别名的交叉运算符则不然。

然而,类型中的许多特性在接口中难以或无法实现。例如,TypeScript 提供了丰富的特性,如条件类型、泛型类型、类型守卫、高级类型等。你可以使用它们构建一个约束良好的类型系统,使你的应用程序具有强类型。接口无法实现这一点。

在许多情况下,它们可以根据个人偏好互换使用。但是,我们应该在以下用例中使用类型别名:

与接口相比,类型更具表现力。许多高级类型特性在接口中不可用,并且随着 TypeScript 的发展,这些特性不断增加。

以下是一个接口无法实现的高级类型特性示例:

sitemap