对象类型
在 JavaScript 中,我们分组和传递数据的基本方式是通过对象。 在 TypeScript 中,我们通过对象类型来表示这些。
正如我们已经看到的,它们可以是匿名的:
或者可以通过使用接口来命名:
interface Person {
name: string;
age: number;
}
function greet(person: Person) {
return "Hello " + person.name;
}Try或者使用类型别名:
type Person = {
name: string;
age: number;
};
function greet(person: Person) {
return "Hello " + person.name;
}Try在上面三个例子中,我们都编写了函数,这些函数接受包含属性 name(必须是 string)和 age(必须是 number)的对象。
快速参考
如果您想快速浏览重要的日常语法,我们提供了 type 和 interface 的备忘单。
属性修饰符
对象类型中的每个属性都可以指定几件事:类型、属性是否可选、以及属性是否可以被写入。
可选属性
很多时候,我们会发现自己处理的对象可能设置了某个属性。 在这些情况下,我们可以通过在属性名称末尾添加问号(?)将这些属性标记为可选。
interface PaintOptions {
shape: Shape;
xPos?: number;
// ^
yPos?: number;
// ^
}
function paintShape(opts: PaintOptions) {
// ...
}
const shape = getShape();
paintShape({ shape });
paintShape({ shape, xPos: 100 });
paintShape({ shape, yPos: 100 });
paintShape({ shape, xPos: 100, yPos: 100 });Try在此示例中,xPos 和 yPos 都被视为可选的。 我们可以选择提供其中任何一个,因此上面所有对 paintShape 的调用都是有效的。 可选性实际上只是说:如果属性被设置,那么它最好具有特定的类型。
我们也可以读取这些属性——但是当我们在 strictNullChecks 下这样做时,TypeScript 会告诉我们它们可能是 undefined。
在 JavaScript 中,即使属性从未被设置,我们仍然可以访问它——它只会给我们值 undefined。 我们可以通过检查来专门处理 undefined。
function paintShape(opts: PaintOptions) {
let xPos = opts.xPos === undefined ? 0 : opts.xPos;
let yPos = opts.yPos === undefined ? 0 : opts.yPos;
// ...
}Try请注意,这种为未指定的值设置默认值的模式非常常见,以至于 JavaScript 有语法来支持它。
function paintShape({ shape, xPos = 0, yPos = 0 }: PaintOptions) {
console.log("x coordinate at", xPos);
console.log("y coordinate at", yPos);
// ...
}Try这里我们为 paintShape 的参数使用了解构模式,并为 xPos 和 yPos 提供了默认值。 现在 xPos 和 yPos 在 paintShape 的函数体内都明确存在,但对于 paintShape 的调用者来说是可选的。
请注意,目前无法在解构模式中放置类型注解。 这是因为下面的语法在 JavaScript 中已经有了不同的含义。
在对象解构模式中,
shape: Shape的意思是“获取属性shape并将其在本地重新定义为名为Shape的变量。” 同样,xPos: number创建了一个名为number的变量,其值基于参数的xPos。
readonly 属性
属性也可以在 TypeScript 中被标记为 readonly。 虽然它不会改变运行时的任何行为,但在类型检查期间,标记为 readonly 的属性不能被写入。
interface SomeType {
readonly prop: string;
}
function doSomething(obj: SomeType) {
// 我们可以读取 'obj.prop'。
console.log(`prop has the value '${obj.prop}'.`);
// 但不能重新赋值。
obj.prop = "hello";}Try使用 readonly 修饰符并不一定意味着值是完全不可变的——换句话说,其内部内容不能被更改。 它仅表示属性本身不能被重新写入。
interface Home {
readonly resident: { name: string; age: number };
}
function visitForBirthday(home: Home) {
// 我们可以读取和更新 'home.resident' 的属性。
console.log(`Happy birthday ${home.resident.name}!`);
home.resident.age++;
}
function evict(home: Home) {
// 但不能写入 'Home' 上的 'resident' 属性本身。
home.resident = { name: "Victor the Evictor",
age: 42,
};
}Try管理对 readonly 含义的预期很重要。 在开发时,它对于向 TypeScript 表明对象应如何使用的意图很有用。 TypeScript 在检查两种类型是否兼容时,不会考虑属性是否为 readonly,因此 readonly 属性也可以通过别名更改。
interface Person {
name: string;
age: number;
}
interface ReadonlyPerson {
readonly name: string;
readonly age: number;
}
let writablePerson: Person = {
name: "Person McPersonface",
age: 42,
};
// 可以
let readonlyPerson: ReadonlyPerson = writablePerson;
console.log(readonlyPerson.age); // 打印 '42'
writablePerson.age++;
console.log(readonlyPerson.age); // 打印 '43'Try使用映射修饰符,您可以移除 readonly 属性。
索引签名
有时您无法提前知道类型属性的所有名称,但您知道值的形状。
在这些情况下,您可以使用索引签名来描述可能值的类型,例如:
interface StringArray {
[index: number]: string;
}
const myArray: StringArray = getStringArray();
const secondItem = myArray[1];
Try上面,我们有一个 StringArray 接口,它有一个索引签名。 这个索引签名表明,当用 number 索引 StringArray 时,它将返回一个 string。
索引签名属性只允许使用某些类型:string、number、symbol、模板字符串模式,以及仅由这些类型组成的联合类型。
可以支持多种类型的索引器...
可以支持多种类型的索引器。请注意,当同时使用 `number` 和 `string` 索引器时,数字索引器返回的类型必须是字符串索引器返回的类型的子类型。这是因为当使用 number 索引时,JavaScript 实际上会在索引到对象之前将其转换为 string。这意味着使用 100(一个 number)索引与使用 "100"(一个 string)索引是一样的,因此两者需要保持一致。
interface Animal {
name: string;
}
interface Dog extends Animal {
breed: string;
}
// 错误:用数字字符串索引可能会让你得到完全不同的 Animal 类型!
interface NotOkay {
[x: number]: Animal; [x: string]: Dog;
}Try虽然字符串索引签名是描述“字典”模式的一种强大方式,但它们也强制所有属性匹配其返回类型。 这是因为字符串索引声明 obj.property 也可作为 obj["property"] 使用。 在以下示例中,name 的类型与字符串索引的类型不匹配,类型检查器会给出错误:
但是,如果索引签名是属性类型的联合,则可以接受不同类型的属性:
interface NumberOrStringDictionary {
[index: string]: number | string;
length: number; // 可以,length 是 number
name: string; // 可以,name 是 string
}Try最后,您可以将索引签名设为 readonly,以防止对它们的索引赋值:
interface ReadonlyStringArray {
readonly [index: number]: string;
}
let myArray: ReadonlyStringArray = getReadOnlyStringArray();
myArray[2] = "Mallory";Try您不能设置 myArray[2],因为索引签名是 readonly。
多余属性检查
对象被赋予类型的方式和位置可能会在类型系统中产生影响。 其中一个关键例子是多余属性检查,它在对象被创建并赋值给对象类型时,会更彻底地验证对象。
interface SquareConfig {
color?: string;
width?: number;
}
function createSquare(config: SquareConfig): { color: string; area: number } {
return {
color: config.color || "red",
area: config.width ? config.width * config.width : 20,
};
}
let mySquare = createSquare({ colour: "red", width: 100 });Try注意,传递给 createSquare 的参数拼写为 colour 而不是 color。 在普通 JavaScript 中,这种事情会静默失败。
您可能会认为这个程序是正确类型的,因为 width 属性是兼容的,没有 color 属性存在,而且多余的 colour 属性是无关紧要的。
然而,TypeScript 认为这段代码中可能存在 bug。 对象字面量在赋值给其他变量或作为参数传递时,会得到特殊处理并经历多余属性检查。 如果一个对象字面量有任何“目标类型”没有的属性,你会得到一个错误:
绕过这些检查其实非常简单。 最简单的方法是使用类型断言:
但是,更好的方法可能是添加字符串索引签名,如果您确定对象可以有一些以特殊方式使用的额外属性。 如果 SquareConfig 可以具有上述类型的 color 和 width 属性,但也可以有任何数量的其他属性,那么我们可以这样定义它:
这里我们说 SquareConfig 可以有任意数量的属性,只要它们不是 color 或 width,它们的类型无关紧要。
最后一种绕过这些检查的方法(可能有点令人惊讶)是将对象赋值给另一个变量: 由于赋值给 squareOptions 不会经过多余属性检查,编译器不会给你错误:
只要 squareOptions 和 SquareConfig 之间有一个共同的属性,上述变通方法就能起作用。 在这个例子中,共同的属性是 width。但是,如果变量没有任何共同的对象属性,它将失败。例如:
请记住,对于像上面这样的简单代码,您可能不应该试图“绕过”这些检查。 对于具有方法和状态的更复杂的对象字面量,您可能需要记住这些技巧,但大多数多余属性错误实际上是 bug。
这意味着如果您在选项包之类的东西上遇到多余属性检查问题,您可能需要修改一些类型声明。 在这种情况下,如果允许将同时具有 color 或 colour 属性的对象传递给 createSquare,您应该修正 SquareConfig 的定义来反映这一点。
扩展类型
拥有其他类型的更具体版本的类型是很常见的。 例如,我们可能有一个 BasicAddress 类型,它描述了在美国寄送信件和包裹所需的字段。
interface BasicAddress {
name?: string;
street: string;
city: string;
country: string;
postalCode: string;
}Try在某些情况下这已经足够了,但如果某个地址的建筑物有多个单元,那么地址通常还有一个单元号。 然后我们可以描述一个 AddressWithUnit。
interface AddressWithUnit {
name?: string;
unit: string;
street: string;
city: string;
country: string;
postalCode: string;
}Try这完成了工作,但缺点是当我们的更改纯粹是添加时,我们不得不重复 BasicAddress 中的所有其他字段。 相反,我们可以扩展原始的 BasicAddress 类型,只添加 AddressWithUnit 独有的新字段。
interface BasicAddress {
name?: string;
street: string;
city: string;
country: string;
postalCode: string;
}
interface AddressWithUnit extends BasicAddress {
unit: string;
}Tryinterface 上的 extends 关键字允许我们有效地从其他命名类型复制成员,并添加我们想要的任何新成员。 这对于减少我们必须编写的类型声明样板代码量很有用,并且对于表明同一属性的几个不同声明可能是相关的意图也很有用。 例如,AddressWithUnit 不需要重复 street 属性,并且因为 street 源自 BasicAddress,读者会知道这两种类型以某种方式相关。
interface 也可以从多个类型扩展。
interface Colorful {
color: string;
}
interface Circle {
radius: number;
}
interface ColorfulCircle extends Colorful, Circle {}
const cc: ColorfulCircle = {
color: "red",
radius: 42,
};Try交叉类型
interface 允许我们通过扩展其他类型来构建新类型。 TypeScript 提供了另一种称为交叉类型的结构,主要用于组合现有的对象类型。
交叉类型使用 & 运算符定义。
interface Colorful {
color: string;
}
interface Circle {
radius: number;
}
type ColorfulCircle = Colorful & Circle;Try这里,我们交叉了 Colorful 和 Circle,产生了一个同时拥有 Colorful 和 Circle 所有成员的新类型。
function draw(circle: Colorful & Circle) {
console.log(`Color was ${circle.color}`);
console.log(`Radius was ${circle.radius}`);
}
// 可以
draw({ color: "blue", radius: 42 });
// 哎呀
draw({ color: "red", raidus: 42 });Try接口扩展 vs. 交叉
我们刚刚看到了两种组合类型的方式,它们相似,但实际上有微妙的区别。 对于接口,我们可以使用 extends 子句从其他类型扩展,而对于交叉,我们可以做类似的事情,并用类型别名命名结果。 两者之间的主要区别在于如何处理冲突,而这种区别通常是在接口和交叉类型别名之间选择其中一个的主要原因。
如果使用相同名称定义接口,并且属性兼容,TypeScript 将尝试合并它们。如果属性不兼容(即它们具有相同的属性名称但类型不同),TypeScript 将引发错误。
在交叉类型的情况下,具有不同类型的属性将自动合并。稍后使用该类型时,TypeScript 将期望该属性同时满足两种类型,这可能会产生意想不到的结果。
例如,以下代码将引发错误,因为属性不兼容:
interface Person {
name: string;
}
interface Person {
name: number;
}相比之下,以下代码将编译,但会导致 never 类型:
interface Person1 {
name: string;
}
interface Person2 {
name: number;
}
type Staff = Person1 & Person2
declare const staffer: Staff;
staffer.name;
Try在这种情况下,Staff 要求 name 属性既是 string 又是 number,导致该属性成为 never 类型。
泛型对象类型
让我们想象一个 Box 类型,它可以包含任何值——string、number、Giraffe,等等。
目前,contents 属性的类型是 any,这可行,但可能会导致后续问题。
我们可以改用 unknown,但这意味着在我们已经知道 contents 类型的情况下,我们需要进行预防性检查,或者使用容易出错的类型断言。
interface Box {
contents: unknown;
}
let x: Box = {
contents: "hello world",
};
// 我们可以检查 'x.contents'
if (typeof x.contents === "string") {
console.log(x.contents.toLowerCase());
}
// 或者我们可以使用类型断言
console.log((x.contents as string).toLowerCase());Try一种类型安全的方法是针对每种 contents 类型构建不同的 Box 类型。
interface NumberBox {
contents: number;
}
interface StringBox {
contents: string;
}
interface BooleanBox {
contents: boolean;
}Try但这意味着我们必须创建不同的函数或函数重载来操作这些类型。
function setContents(box: StringBox, newContents: string): void;
function setContents(box: NumberBox, newContents: number): void;
function setContents(box: BooleanBox, newContents: boolean): void;
function setContents(box: { contents: any }, newContents: any) {
box.contents = newContents;
}Try这有很多样板代码。而且,我们以后可能还需要引入新的类型和重载。 这很令人沮丧,因为我们的 box 类型和重载实际上都是相同的。
相反,我们可以创建一个泛型 Box 类型,它声明一个类型参数。
你可以把它理解为“一个 Box of Type 是指其 contents 具有类型 Type 的东西”。 稍后,当我们引用 Box 时,我们必须提供一个类型参数来代替 Type。
将 Box 视为真实类型的模板,其中 Type 是一个占位符,将被其他类型替换。 当 TypeScript 看到 Box<string> 时,它会将 Box<Type> 中每个出现的 Type 替换为 string,最终得到类似 { contents: string } 的东西。 换句话说,Box<string> 和我们之前的 StringBox 的工作方式相同。
interface Box<Type> {
contents: Type;
}
interface StringBox {
contents: string;
}
let boxA: Box<string> = { contents: "hello" };
boxA.contents;
let boxB: StringBox = { contents: "world" };
boxB.contents;
TryBox 是可重用的,因为 Type 可以被任何东西替换。这意味着当我们需要一个新类型的 box 时,根本不需要声明新的 Box 类型(尽管我们当然可以,如果我们想的话)。
interface Box<Type> {
contents: Type;
}
interface Apple {
// ....
}
// 等同于 '{ contents: Apple }'。
type AppleBox = Box<Apple>;Try这也意味着我们可以通过使用泛型函数来完全避免重载。
值得注意的是,类型别名也可以是泛型的。我们可以用类型别名来定义我们新的 Box<Type> 接口,它原来是:
使用类型别名代替:
由于类型别名与接口不同,可以描述不仅仅是对象类型,我们也可以使用它们来编写其他种类的泛型辅助类型。
type OrNull<Type> = Type | null;
type OneOrMany<Type> = Type | Type[];
type OneOrManyOrNull<Type> = OrNull<OneOrMany<Type>>;
type OneOrManyOrNullStrings = OneOrManyOrNull<string>;
Try我们稍后会再回到类型别名。
Array 类型
泛型对象类型通常是某种容器类型,它们独立于所包含元素的类型工作。 数据结构以这种方式工作是很理想的,这样它们就可以在不同的数据类型之间重用。
事实证明,我们在整个手册中一直在处理这样的类型:Array 类型。 每当我们写出像 number[] 或 string[] 这样的类型时,实际上只是 Array<number> 和 Array<string> 的简写。
function doSomething(value: Array<string>) {
// ...
}
let myArray: string[] = ["hello", "world"];
// 这两种方式都可以!
doSomething(myArray);
doSomething(new Array("hello", "world"));Try就像上面的 Box 类型一样,Array 本身就是一个泛型类型。
interface Array<Type> {
/**
* 获取或设置数组的长度。
*/
length: number;
/**
* 从数组中移除最后一个元素并返回它。
*/
pop(): Type | undefined;
/**
* 向数组追加新元素,并返回数组的新长度。
*/
push(...items: Type[]): number;
// ...
}Try现代 JavaScript 还提供了其他泛型数据结构,例如 Map<K, V>、Set<T> 和 Promise<T>。 所有这些真正意味着,由于 Map、Set 和 Promise 的行为方式,它们可以与任何类型的集合一起工作。
ReadonlyArray 类型
ReadonlyArray 是一种特殊类型,它描述了不应更改的数组。
function doStuff(values: ReadonlyArray<string>) {
// 我们可以读取 'values'...
const copy = values.slice();
console.log(`The first value is ${values[0]}`);
// ...但不能改变 'values'。
values.push("hello!");}Try就像属性的 readonly 修饰符一样,它主要是我们可以用来表达意图的工具。 当我们看到一个函数返回 ReadonlyArray 时,它告诉我们我们不应该更改其内容,而当我们看到一个函数接受 ReadonlyArray 时,它告诉我们我们可以将任何数组传递给该函数,而无需担心它会改变其内容。
与 Array 不同,没有我们可以使用的 ReadonlyArray 构造函数。
相反,我们可以将常规的 Array 赋值给 ReadonlyArray。
正如 TypeScript 为 Array<Type> 提供了简写语法 Type[] 一样,它也为 ReadonlyArray<Type> 提供了简写语法 readonly Type[]。
function doStuff(values: readonly string[]) {
// 我们可以读取 'values'...
const copy = values.slice();
console.log(`The first value is ${values[0]}`);
// ...但不能改变 'values'。
values.push("hello!");}Try最后要注意的一点是,与 readonly 属性修饰符不同,常规的 Array 和 ReadonlyArray 之间的可赋值性不是双向的。
元组类型
元组类型是另一种 Array 类型,它确切地知道它包含多少个元素,以及在特定位置包含哪些类型。
这里,StringNumberPair 是一个由 string 和 number 组成的元组类型。 像 ReadonlyArray 一样,它在运行时没有表示,但对 TypeScript 很重要。 对于类型系统,StringNumberPair 描述了这样的数组:索引 0 包含 string,索引 1 包含 number。
function doSomething(pair: [string, number]) {
const a = pair[0];
const b = pair[1];
// ...
}
doSomething(["hello", 42]);Try如果我们尝试索引超出元素数量,将会得到一个错误。
我们也可以使用 JavaScript 的数组解构来解构元组。
function doSomething(stringHash: [string, number]) {
const [inputString, hash] = stringHash;
console.log(inputString);
console.log(hash);
}Try元组类型在高度基于约定的 API 中很有用,其中每个元素的含义是“显而易见的”。 这给了我们在解构时命名变量的灵活性。 在上面的例子中,我们可以将元素
0和1命名为任何我们想要的名字。然而,由于并非每个用户都对“显而易见”持有相同的看法,因此可能值得重新考虑在您的 API 中使用具有描述性属性名称的对象是否更好。
除了这些长度检查之外,像这样的简单元组类型等同于声明了特定索引的属性,并以数字字面量类型声明了 length 的 Array 版本类型。
interface StringNumberPair {
// 专用属性
length: 2;
0: string;
1: number;
// 其他 'Array<string | number>' 成员...
slice(start?: number, end?: number): Array<string | number>;
}Try您可能感兴趣的另一件事是,元组可以通过在元素类型后写一个问号(?)来拥有可选属性。 可选的元组元素只能出现在最后,并且也会影响 length 的类型。
type Either2dOr3d = [number, number, number?];
function setCoordinate(coord: Either2dOr3d) {
const [x, y, z] = coord;
console.log(`Provided coordinates had ${coord.length} dimensions`);
}Try元组也可以有剩余元素,它们必须是数组/元组类型。
type StringNumberBooleans = [string, number, ...boolean[]];
type StringBooleansNumber = [string, ...boolean[], number];
type BooleansStringNumber = [...boolean[], string, number];TryStringNumberBooleans描述了一个元组,其前两个元素分别是string和number,但之后可以有任意数量的boolean。StringBooleansNumber描述了一个元组,其第一个元素是string,然后是任意数量的boolean,并以number结尾。BooleansStringNumber描述了一个元组,其起始元素是任意数量的boolean,并以string然后是number结尾。
带有剩余元素的元组没有固定的“长度”——它只有一组在不同位置上的已知元素。
const a: StringNumberBooleans = ["hello", 1];
const b: StringNumberBooleans = ["beautiful", 2, true];
const c: StringNumberBooleans = ["world", 3, true, false, true, false, true];Try为什么可选元素和剩余元素有用? 嗯,它允许 TypeScript 将元组与参数列表对应起来。 元组类型可以在剩余参数和展开参数中使用,因此以下代码:
function readButtonInput(...args: [string, number, ...boolean[]]) {
const [name, version, ...input] = args;
// ...
}Try基本上等价于:
当您想要使用剩余参数接受可变数量的参数,并且需要最小数量的元素,但又不想引入中间变量时,这很方便。
readonly 元组类型
关于元组类型的最后一点说明——元组类型有 readonly 变体,可以通过在前面加上 readonly 修饰符来指定——就像数组简写语法一样。
正如您所料,在 TypeScript 中不允许写入 readonly 元组的任何属性。
在大多数代码中,元组往往被创建后就不修改,因此在可能的情况下将类型注解为 readonly 元组是一个好的默认做法。 这一点也很重要,因为带有 const 断言的数组字面量将被推断为 readonly 元组类型。
let point = [3, 4] as const;
function distanceFromOrigin([x, y]: [number, number]) {
return Math.sqrt(x ** 2 + y ** 2);
}
distanceFromOrigin(point);Try这里,distanceFromOrigin 从不修改其元素,但期望一个可变的元组。 由于 point 的类型被推断为 readonly [3, 4],它与 [number, number] 不兼容,因为后者不能保证 point 的元素不会被改变。