为什么只使用
无聊的内置类型形状
当我们可以自己创建的时候?
我在第 4 章“对象”中提到,尽管 { ... }
对象类型的类型别名是描述对象形状的一种方式,TypeScript 还包括许多开发人员喜欢的“接口”功能。接口是另一种声明具有关联名称的对象形状的方法。接口在许多方面类似于别名对象类型,但通常更喜欢它们更可读的错误消息,更快的编译器性能以及与类的更好的互操作性。
类型别名与接口
下面简要回顾一下别名对象类型如何描述具有 born: number
和 name: string
的对象:
type Poet = {
born: number;
name: string;
};
下面是接口的等效语法:
interface Poet {
born: number;
name: string;
}
这两种语法几乎相同。
喜欢分号的 TypeScript 开发人员通常把分号放在类型别名之后,而不是接口之后。这种偏好反映了使用分号声明变量与不使用分号声明类或函数之间的区别。
TypeScript 的接口可分配性检查和错误消息与对象类型的检查和消息几乎一样。如果 Poet
是一个接口或类型别名,下面对 valueLater
变量的可分配性错误也会大致相同:
let valueLater: Poet;
// Ok
valueLater = {
born: 1935,
name: 'Sara Teasdale',
};
valueLater = "Emily Dickinson";
// 错误:不能将类型“string”分配给类型“Poet”.
valueLater = {
born: true,
// 错误:不能将类型“boolean”分配给类型“number”。
name: 'Sappho'
};
然而,接口和类型别名之间存在一些关键区别:
- 正如你将在本章后面看到的那样,接口可以“合并”在一起进行扩充——这是一种特别有用的功能,特别是在使用第三方代码(如内置全局对象或 npm 包)时。
- 正如你将在下一章“类”中看到的那样,接口可用于对类声明的结构进行类型检查,而类型别名则无法实现。
- 对于 TypeScript 类型检查器来说,接口通常更快:它们声明了一个可以更容易地在内部缓存的命名类型,而不是像类型别名那样动态复制和粘贴一个新的对象字面量。
- 因为接口被视为具有命名对象,而不是未命名对象字面量的别名,所以它们的错误消息在一些困难的情况下更容易阅读。
出于后两个原因和保持一致性的考虑,本书及其相关项目默认使用接口而不是别名对象形状。我一般建议在可能的情况下使用接口(即,直到你需要使用类型别名的联合类型等功能)。
属性类型
JavaScript 对象在实际使用中可能非常古怪,包括 getter 和 setter,有时只存在的属性或接受任何任意属性名称。TypeScript 为接口提供了一组类型系统工具,以帮助我们对这种古怪的东西建模。
由于接口和类型别名的行为非常相似,因此本章中介绍的以下类型的属性也都可用于别名对象类型。
可选属性
与对象类型一样,接口属性不一定都必须在对象中提供。可以通过在类型注解中在 :
前面加上 ?
来表示接口属性是可选的。
这个 Book
接口只要求一个 required
属性,可以选择性地提供一个 optional
属性:符合它的对象可以提供 optional
也可以不提供,只要提供 required
即可:
interface Book {
author?: string;
pages: number;
};
// Ok
const ok: Book = {
author: "Rita Dove",
pages: 80,
};
const missing: Book = {
pages: 80
};
关于可选属性和类型联合中包含 undefined 的属性之间的差异,相同的警告适用于接口和对象类型。第 13 章“配置选项”将描述 TypeScript 在可选属性方面的严格设置。
只读属性
有时你可能希望阻止使用接口的用户重新分配符合接口的对象的属性。TypeScript 允许你在属性名称之前添加 readonly
修饰符,表示一旦设置,该属性不应设置为其他值。这些 readonly
属性可以正常读取,但不能重新分配给任何新值。
例如,下面的 Page
接口中的 text
属性在访问时返回 string
,但如果分配一个新值则会引发类型错误:
interface Page {
readonly text: string;
}
function read(page: Page) {
// Ok:读取 Text 属性不会尝试修改它
console.log(page.text);
page.text += "!";
// ~~~~
// 错误:无法为“text”赋值,因为它是只读属性。
}
请注意,readonly
修饰符仅存在于类型系统中,并且仅适用于该接口的使用。 除非在声明该对象为该接口的位置中使用该对象,否则不会应用于该对象。
在 exclaim
示例的延续中,text
属性可以在函数外部修改,因为在函数内部之前,它的父对象没有明确用作 text
。pageIsh
可以用作 Page
,因为可写属性可分配给 readonly
属性(可以从中读取可变属性,这都是 readonly
属性所需要的):
// 译者注:原示例变量名有误
const pageIsh = {
text: "Hello, world!",
};
// Ok: pageIsh 是一种带有文本的推断对象类型,而不是页面
pageIsh.text += "!";
// Ok: read 接受 Page 作为参数,
// 而 Page 恰好是 pageIsh 类型的一个更具体的版本
read(pageIsh);
将变量 pageIsh
的显式类型注解声明为 Page
可以告诉 TypeScript 表示其 text
属性为 readonly
。但是,它的推断类型不是 readonly
。
只读接口成员是一种方便的方式,可以确保代码中的某些区域不会意外地修改它们不应该修改的对象。然而,需要记住它们仅仅是类型系统的构造,不会存在于编译后的 JavaScript 代码中。它们只能在开发过程中通过 TypeScript 类型检查器来保护对象不被修改。
函数和方法
在 JavaScript 中,对象成员往往是函数。因此 TypeScript 允许将接口成员声明为前面在第 5 章 “函数” 中介绍的函数类型。
TypeScript 提供了两种将接口成员声明为函数的方法:
- 方法语法:声明接口的成员是旨在作为对象成员调用的函数,如
member(): void
- 属性语法:声明接口的成员等于独立函数,如
member: () => void
这两种声明形式是将 JavaScript 对象声明为具有函数的两种方式的类比。
这里展示的 method
和 property
成员都是函数,可以不带参数调用,并返回一个 string
。
interface HasBothFunctionTypes {
property: () => string;
method(): string;
}
const hasBoth: HasBothFunctionTypes = {
property: () => "",
method() {
return "";
}
};
hasBoth.property(); // Ok
hasBoth.method(); // Ok
这两种形式都可以接受 ?
可选修饰符,表示它们不需要提供:
interface OptionalReadonlyFunctions {
optionalProperty?: () => string;
optionalMethod?(): string;
}
方法和属性声明大多可以互换使用。我将在本书中介绍它们之间的主要区别有:
- 方法不能声明为
readonly
;属性可以。 - 接口合并(在本章后面介绍)以不同的方式处理它们。
- 对第15章“类型操作”中涉及的类型执行的一些操作对它们的处理方式不同。
TypeScript 的未来版本可能会添加一个选项,以更加严格地区分方法和属性函数。
现在,我推荐的一般风格指南是:
- 如果你知道底层函数可能引用此函数,请使用方法函数,最常见的是类的实例(在第 8 章 “类”中介绍)。
- 否则使用属性函数。
如果你把这两者混为一谈,或者不明白其中的区别,不要担心。它很少会影响你的代码,除非你有意识地处理 this
作用域和选择哪种形式,否则它们很少会影响你的代码。
调用签名
接口和对象类型可以声明调用签名,它是对一个值可以像函数一样被调用的类型系统描述。只有按照调用签名声明的方式调用的值才可以被分配给接口,即具有可分配参数和返回类型的函数。调用签名看起来类似于函数类型,但使用 :
冒号而不是 =>
箭头。
以下 FunctionAlias
和 CallSignature
类型都描述相同的函数参数和返回类型:
type FunctionAlias = (input: string) => number;
interface CallSignature {
(input: string): number;
}
// 类型:(input: string) => number
const typedFunctionAlias: FunctionAlias = (input) => input.length; // Ok
// 类型:(input: string) => number
const typedCallSignature: CallSignature = (input) => input.length; // Ok
调用签名可用于描述带有一些用户定义属性的函数。TypeScript 将会识别添加到函数声明中的属性,将其视为该函数类型的一部分。
下面的 keepsTrackOfCalls
函数声明添加了一个类型为 number
的 count
属性,使其可分配给 FunctionWithCount
接口。因此,它可以赋值给类型为 FunctionWithCount
的 hasCallCount
参数。而最后的函数未被分配 count
:
interface FunctionWithCount {
count: number;
(): void;
}
let hasCallCount: FunctionWithCount;
function keepsTrackOfCalls() {
keepsTrackOfCalls.count += 1;
console.log(`I've been called ${keepsTrackOfCalls.count} times!`);
}
keepsTrackOfCalls.count = 0;
hasCallCount = keepsTrackOfCalls; // Ok
function doesNotHaveCount() {
console.log("No idea!");
}
hasCallCount = doesNotHaveCount;
// 错误:类型 "() => void" 中缺少属性 "count",
// 但类型 "FunctionWithCount" 中需要该属性。
索引签名
一些 JavaScript 项目会创建用于在任意“字符串”键下存储值的对象。对于这些“容器”对象,为每个可能的键声明一个带有字段的接口是不切实际或不可能的。
TypeScript 提供了一种语法,称为索引签名,用于指示接口的对象可以接受任何键,并在该键下返回特定类型。它们最常与字符串键一起使用,因为 JavaScript 对象属性查找会隐式地将键转换为字符串。索引签名看起来像普通的属性定义,但在键后有一个类型,用方括号括起来,如 {[key: string]:string}
。
下面 WordCounts
接口声明为允许任何 string
键与 number
值。该类型的对象不受限于接收任何特定键——只要值为 number
即可:
interface WordCounts {
[i: string]: number;
}
const counts: WordCounts = {};
counts.apple = 0; // Ok
counts.banana = 1; // Ok
counts.cherry = false;
// 错误:不能将类型“boolean”分配给类型“number”。
索引签名便于将值分配给对象,但不完全是类型安全的。它们表明,无论访问哪个属性,对象都应该返回一个值。
这个 publishDates
值安全地将 Frankenstein
作为 Date
返回,但会使 TypeScript 误认为它的 Beloved
已定义,即使它是 undefined
:
interface DatesByName {
[i: string]: Date;
}
const publishDates: DatesByName = {
Frankenstein: new Date("1 January 1818"),
};
publishDates.Frankenstein; // 类型:日期
console.log(publishDates.Frankenstein.toString()); // Ok
publishDates.Beloved; // 类型:日期,但运行时值未定义!
console.log(publishDates.Beloved.toString()); // 在类型系统中还可以,但是...
// 运行时错误:无法读取未定义的属性“toString”
//(读取publishDates.Beloved)
如果可能的话,如果你要存储键值对,而且键在提前不知道,通常使用 Map
更安全。它的 .get
方法总是返回一个类型,其中包含 | undefined
,以表示可能不存在该键。第9章“类型修饰符”将讨论如何使用泛型容器类,例如 Map
和 Set
。
混合属性和索引签名
接口可以包含明确命名的属性和一个“捕获所有”(string
)索引签名,但有一个限制:每个明确命名的属性的类型必须可赋值给捕获所有索引签名的类型。你可以将它们的混合视为告诉 TypeScript 命名属性提供了更具体的类型,而其他任何属性都会退回到索引签名的类型。
下面的 HistoricalNovels
声明所有属性都是 number
类型,此外,Oroonoko
属性必须首先存在:
interface HistoricalNovels {
Oroonoko: number;
[i: string]: number;
}
// Ok
const novels: HistoricalNovels = {
Outlander: 1991,
Oroonoko: 1688,
};
const missingOroonoko: HistoricalNovels = {
Outlander: 1991,
};
// 错误:类型 "{ Outlander: number; }" 中缺少属性 "Oroonoko",
// 但类型 "HistoricalNovels" 中需要该属性。
混合属性和索引签名的一个常见类型系统技巧是对命名属性使用比索引签名的基本类型更具体的属性类型字面量。只要命名属性的类型可以赋值给索引签名的类型(对于字面量和基本类型说都是成立的),TypeScript 就会允许这种做法。
这里,ChapterStarts
声明 preface
的属性值必须是 0
,所有其他属性都具有更一般的 number
类型。这意味着任何符合 ChapterStarts
的对象都必须具有一个等于 0
的 preface
属性:
interface ChapterStarts {
preface: 0;
[i: string]: number;
}
const correctPreface: ChapterStarts = {
preface: 0,
night: 1,
shopping: 5
};
const wrongPreface: ChapterStarts = {
preface: 1,
// 错误:不能将类型“1”分配给类型“0”。
};
数字索引签名
尽管 JavaScript 会隐式地将对象属性查找键转换为字符串,但有时只允许对象使用数字作为键。TypeScript 索引签名可以使用 number
类型而不是 string
,但有一个与命名属性相同的限制,即它们的类型必须可分配给通用 string
索引签名。
以下 MoreNarrowNumbers
接口是允许的,因为字符串可分配给 string | undefined
,但 MoreNarrowStrings
是不允许的,因为 string | undefined
不能分配给 string
:
// Ok
interface MoreNarrowNumbers {
[i: number]: string;
[i: string]: string | undefined;
}
// Ok
const mixesNumbersAndStrings: MoreNarrowNumbers = {
0: '',
key1: '',
key2: undefined,
}
interface MoreNarrowStrings {
[i: number]: string | undefined;
// 错误: “number”索引类型“string | undefined”
// 不能分配给“string”索引类型“string”。
[i: string]: string;
}
嵌套接口
就像对象类型可以嵌套为其他对象类型的属性一样,接口类型也可以具有本身就是接口类型(或对象类型)的属性。
下面的 Novel
接口包含一个必须满足内联对象类型的 author
属性和一个必须满足 Setting
接口的 setting
属性:
interface Novel {
author: {
name: string;
};
setting: Setting;
}
interface Setting {
place: string;
year: number;
}
let myNovel: Novel;
// Ok
myNovel = {
author: {
name: 'Jane Austen',
},
setting: {
place: 'England',
year: 1812,
}
};
myNovel = {
author: {
name: 'Emily Brontë',
},
setting: {
place: 'West Yorkshire',
},
// 错误:类型 "{ place: string; }" 中缺少属性 "year",
// 但类型 "Setting" 中需要该属性。
};
接口继承
有时候你可能会遇到多个接口看起来很相似的情况,其中一个接口可能包含了另一个接口的所有成员,并额外添加了一些成员。
TypeScript 允许一个接口继承另一个接口,这将声明它复制了另一个接口的所有成员。通过在接口名称后添加 extends
关键字(“派生”接口),后跟要扩展的接口的名称(“基础”接口),可以标记接口继承另一个接口。这样做告诉 TypeScript,所有符合派生接口的对象必须具有基础接口的所有成员。
在下面的示例中,Novella
接口继承自 Writing
接口,因此要求对象至少同时具有 Novella
的 pages
和 Writing
的 title
成员:
interface Writing {
title: string;
}
interface Novella extends Writing {
pages: number;
}
// Ok
let myNovella: Novella = {
pages: 195,
title: "Ethan Frome",
};
let missingPages: Novella = {
// ~~~~
// 错误:类型 "{ title: string; }" 中缺少属性 "pages",
// 但类型 "Novella" 中需要该属性。
title: "The Awakening",
}
let extraProperty: Novella = {
// ~~~~
// 错误:不能将类型“{ pages: number; strategy: string; style: string; }”并且“strategy”不在类型“Novella”中。
// 分配给类型“Novella”。
// 对象字面量只能指定已知属性,
pages: 300,
strategy: "baseline",
style: "Naturalism"
};
接口继承是一种很好的方法,用于表示项目中的一种类型的实体是另一个实体的超集(它包括另一个实体的所有成员)。它们允许你避免在多个接口中重复键入相同的代码来表示该关系。
被重写的属性
派生接口可以通过再次声明具有不同类型的属性来重写或替换其基础接口中的属性。TypeScript 的类型检查器将强制执行重写的属性必须可分配给其基本属性。它这样做是为了确保派生接口类型的实例仍可分配给基础接口类型。
大多数重新声明属性的派生接口这样做,要么是为了使这些属性成为类型联合的更具体子集,要么是为了使属性成为扩展基本接口类型的类型。
例如,此 WithNullableName
类型在 WithNonNullableName
中被正确地设置为非空。但是,WithNumericName
不允许作为 number | string
,且不可分配给 string | null
:
interface WithNullableName {
name: string | null;
}
interface WithNonNullableName extends WithNullableName {
name: string;
}
interface WithNumericName extends WithNullableName {
name: number | string;
}
// 错误:接口“WithNumericName”错误扩展接口“WithNullableName”。
// 属性“name”的类型不兼容。
// 不能将类型“string | number”分配给类型“string | null”。
// 不能将类型“number”分配给类型“string”。
扩展多个接口
TypeScript 中允许将接口声明为扩展多个其他接口。可以在派生接口名称后面的 extends
关键字之后可以使用任意数量的接口名称,用逗号分隔。派生接口派生接口将接收所有基础接口的成员。
下面的 GivesBothAndEither
有三个方法:一个是自己的方法,一个来自 GivesNumber
,一个来自 GivesString
:
interface GivesNumber {
giveNumber(): number;
}
interface GivesString {
giveString(): string;
}
interface GivesBothAndEither extends GivesNumber, GivesString {
giveEither(): number | string;
}
function useGivesBoth(instance: GivesBothAndEither) {
instance.giveEither(); // Type: number | string
instance.giveNumber(); // Type: number
instance.giveString(); // Type: string
}
通过将接口标记为扩展多个其他接口,既可以减少代码重复,又可以更轻松地在不同代码区域中重用对象形状。
接口合并
接口的重要特征之一是它们能够相互合并。接口合并意味着,如果在同一作用域中声明了两个具有相同名称的接口,则它们将加入到该名称下的一个更大的包含所有声明字段的接口中。
此代码段声明了一个具有两个属性的 Merged
接口:fromFirst
和 fromSecond
:
interface Merged {
fromFirst: string;
}
interface Merged {
Interface Merging {
fromSecond: number;
}
// 相当于:
// interface Merged {
// fromFirst: string;
// fromSecond: number;
// }
接口合并在日常 TypeScript 开发中并不经常使用。建议尽可能避免使用它,因为在多个地方声明接口的代码可能难以理解。
但是,接口合并对于扩展来自外部包或内置全局接口(如 Window
)的接口特别有用。例如,当使用默认的 TypeScript 编译选项时,在具有 myEnvironmentVariable
属性的文件中声明 Window
接口会使 window.myEnvironmentVariable
可用:
interface Window {
myEnvironmentVariable: string;
}
window.myEnvironmentVariable; // Type: string
我将在第 11 章 “声明文件”和第 13 章 “配置选项”中更深入地介绍类型定义和类型脚本全局类型选项。
成员命名冲突
请注意,合并的接口可能不会使用不同的类型多次声明相同的属性名称。如果已在接口中声明属性,则以后合并的接口必须使用相同的类型。
在此 MergedProperties
接口中,允许使用 same
属性,因为它在两个声明中相同,但 different
会报不同类型的错误:
interface MergedProperties {
same: (input: boolean) => string;
different: (input: string) => string;
}
interface MergedProperties {
same: (input: boolean) => string; // Ok
different: (input: number) => string;
// Error: 后续属性声明必须属于同一类型。
// 属性“different”的类型必须为“(input: string) => string”,
// 但此处却为类型“(input: number) => string”。
}
但是,合并的接口可以定义具有相同名称和不同签名的方法。这样做会为该方法创建函数重载。
下面这个 MergedMethods
接口创建了一个 different
方法,并且它有两个重载:
interface MergedMethods {
different(input: string): string;
}
interface MergedMethods {
different(input: number): string; // Ok
}
总结
本章介绍了如何通过接口描述对象类型:
- 使用接口而不是类型别名声明对象类型
- 各种接口属性类型:可选、只读、函数和方法
- 使用索引签名描述捕获所有对象属性
- 使用嵌套接口和
extends
继承重用接口 - 具有相同名称的接口如何合并
接下来将是一个原生的 JavaScript 语法,用于设置多个对象以具有相同的属性:类。
现在你已经读完了这一章,你最好练习一下学到的东西 [https://learningtypescript.com/interfaces](https://learningtypescript.com/interfaces
为什么接口是好的驱动程序?
它们在合并方面很棒。
评论 (0)