概述
在 JavaScript 中,分组和传递数据的基本方式是通过对象。在 TypeScript 中,我们通过对象类型来表示。
正如我们所见,对象类型可以是匿名的:
function greet(person: { name: string; age: number }) {
return "Hello " + person.age;
}
或者通过接口来命名:
interface Person {
name: string;
age: number;
}
function greet(person: Person) {
return "Hello " + person.age;
}
或者是类型别名:
type Person = {
name: string;
age: number;
}
function greet(person: Person) {
return "Hello " + person.age;
}
在上面的三个例子中,我们编写了接受对象的函数,这些对象包含属性 name (必须是字符串)和 age (必须是数字)。
属性修饰词
对象类型中的每个属性都可以指定一些额外的内容:类型、属性是否可选以及属性是否可写。
可选属性
大多数情况下,我们要处理的对象可能有一个属性集。在这种情况下,我们可以通过在属性名称结尾处添加一个问号(?)来将这些属性标记为可选属性。
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 });
在这个例子中,xPos 和 yPos 都是可选的。我们可以选择提供它们中的任何一个,所以上面对 paintShape 的调用都是有效的。可选性说的是,如果设置了属性,它最好有一个特定的类型。
interface PaintOptions {
shape: Shape;
xPos?: number;
yPos?: number;
}
function paintShape(opts: PaintOptions) {
// ...
}
const shape = getShape();
paintShape({ shape });
paintShape({ shape, xPos: 100 });
我们也可以读取这些属性 -- 但当我们使用 strictNullChecks 时,TypeScript 会告诉我们这些属性可能是 undefined。
function paintShape(opts: PaintOptions) {
let xPos = opts.xPos;
// ^ = (property) PaintOptions.xPos?: number | undefined
let yPos = opts.yPos;
// ^ = (property) PaintOptions.yPos?: number | undefined
}
在 JavaScript 中,即使属性从未被设置过,我们仍然可以访问它 -- 它只会给我们未定义的值。我们可以专门处理未定义的情况。
function paintShape(opts: PaintOptions) {
let xPos = opts.xPos === undefined ? 0 : opts.xPos;
// ^ = let xPos: number
let yPos = opts.yPos === undefined ? 0 : opts.yPos;
// ^ = let yPos: number
// ...
}
注意,这种为未指定值设置默认值的模式十分常见,JavaScript 有相应的语法支持。
function paintShape({ shape, xPos = 0, yPos = 0 }: PaintOptions) {
console.log("x coordinate at", xPos);
// ^ = var xPos: number
console.log("y coordinate at", yPos);
// ^ = var yPos: number
// ...
}
这里我们对 paintShape 参数 使用了 解构赋值 并且为 xPos 和 yPos 提供了默认值。现在,xPos 和 yPos 肯定存在于 paintShape 中,但对于任何 paintShape 的调用者来说都是可选的。
注意,目前还没有办法在解构模式中添加类型注释。这是因为下面的语法在 JavaScript 中已经有了不同的含义。
function draw({ shape: Shape, xPos: number = 100 /*...*/ }) {
render(shape);
// Cannot find name 'shape'. Did you mean 'Shape'?
render(xPos);
// Cannot find name 'xPos'.
}
在对象解构模式中,shape: Shape 表示获取属性 shape 并将其重新定义为一个名为 Shape 的变量。同样,xPos: number 创建一个名为 number 的变量,其值基于 xPos 参数。
只读属性
属性也可以被 TypeScript 标记为只读。虽然它不会改变任何运行时行为,但标记为 readonly 的属性在类型检查期间不可写。
interface SomeType {
readonly prop: string;
}
function doSomething(obj: SomeType) {
// We can read from 'obj.prop'.
console.log(`prop has the value '${obj.prop}'.`);
// But we can't re-assign it.
obj.prop = "hello";
// Cannot assign to 'prop' because it is a read-only property.
}
使用 readonly 修饰词并不一定意味着值是完全不可变的 -- 换句话说,它的内在内容不能被更改。这只是意味着属性本身不能被重写。
interface Home {
readonly resident: { name: string; age: number };
}
function visitForBirthday(home: Home) {
// We can read and update properties from 'home.resident'.
console.log(`Happy birthday ${home.resident.name}!`);
home.resident.age++;
}
function evict(home: Home) {
// But we can't write to the 'resident' property itself on a 'Home'.
home.resident = {
// Cannot assign to 'resident' because it is a read-only property.
name: "Victor the Evictor",
age: 42,
};
}
管理 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,
};
// works
let readonlyPerson: ReadonlyPerson = writablePerson;
console.log(readonlyPerson.age); // prints '42'
writablePerson.age++;
console.log(readonlyPerson.age); // prints '43'
扩展类型
通常,类型可能是其他类型的更特定版本。例如,我们可能有一个 BasicAddress 类型用于描述发送信件和包裹所需的字段。
interface BasicAddress {
name?: string;
street: string;
city: string;
country: string;
postalCode: string;
}
在某些情况下,这就足够了,但如果地址上的建筑有多个单元,地址通常会有一个关联的单元号。我们可以通过 AddressWithUnit 描述。
interface AddressWithUnit {
name?: string;
unit: string;
street: string;
city: string;
country: string;
postalCode: string;
}
这样就完成了,但缺点是,当我们的更改纯粹是一种添加行为的时候就必须重复 BasicAddress 中的所有其他字段。相反,我们可以扩展原来的 BasicAddress 类型,只添加 AddressWithUnit 中特有的新字段。
interface BasicAddress {
name?: string;
street: string;
city: string;
country: string;
postalCode: string;
}
interface AddressWithUnit extends BasicAddress {
unit: string;
}
接口上的 extends 关键字允许我们从其他命名类型复制成员,并添加我们想要的任何新成员。这有助于减少我们必须编写的类型声明模板文件的数量,并指明同一属性的不同声明可能是相关的。例如,AddressWithUnit 不需要重复 street 属性,而且由于 street 来源于 BasicAddress,我们就知道这两种类型以某种方式相关联。
接口也可以从多种类型扩展。
interface Colorful {
color: string;
}
interface Circle {
radius: number;
}
interface ColorfulCircle extends Colorful, Circle {}
const cc: ColorfulCircle = {
color: "red",
radius: 42,
};
交集类型
接口允许我们通过扩展其他类型来构建新的类型。TypeScript 提供了另一个称为交集类型的构造,主要用于组合现有的对象类型。
交集类型使用 & 运算符定义。
interface Colorful {
color: string;
}
interface Circle {
radius: number;
}
type ColorfulCircle = Colorful & Circle;
在这里,我们将 Colorful 和 Circle 相交以生成一个新类型,它包含了 Colorful 和 Circle 的所有成员。
function draw(circle: Colorful & Circle) {
console.log(`Color was ${circle.color}`);
console.log(`Radius was ${circle.radius}`);
}
// okay
draw({ color: "blue", radius: 42 });
// oops
draw({ color: "red", raidus: 42 });
// Argument of type '{ color: string; raidus: number; }' is not assignable
// to parameter of type 'Colorful & Circle'.
// Object literal may only specify known properties, but 'raidus' does
// not exist in type 'Colorful & Circle'. Did you mean to write 'radius'?
接口 vs 交集
我们刚刚研究了两种组合类型的方法,这两种类型很相似,但实际上有细微的不同。对于接口,我们可以使用extends 子句来扩展其他类型,通过交集做到类似的事情并且使用类型别名命名结果。两者之间的主要区别是如何处理冲突,而这种区别通常是你在交集类型和接口之间抉择的主要原因之一。
泛型对象类型
让我们想象一个可以包含任何值的 Box 类型 -- strings、numbers、Giraffes 等等。
interface Box {
contents: any;
}
现在,contents 属性的类型是 any,这是可以的,但可能会导致后续的问题。
我们可以使用 unknown,但这意味着在已经知道 contents 类型的情况下,我们需要进行预防性检查,或者使用容易出错的类型断言。
interface Box {
contents: unknown;
}
let x: Box = {
contents: "hello world",
};
// we could check 'x.contents'
if (typeof x.contents === "string") {
console.log(x.contents.toLowerCase());
}
// or we could use a type assertion
console.log((x.contents as string).toLowerCase());
一种确保类型安全的方法是为 contents 的每种类型创建不同的 Box 类型。
interface NumberBox {
contents: number;
}
interface StringBox {
contents: string;
}
interface BooleanBox {
contents: boolean;
}
但这意味着我们必须创建不同的函数,或者重载函数,来操作这些类型。
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;
}
这里有太多模板。而且,我们以后可能需要引入新的类型和重载。这是令人沮丧的,因为我们的 box 类型和重载实际上都是一样的。
然而,我们可以创建一个声明了类型参数的 Box 泛型类型
interface Box<Type> {
contents: Type;
}
稍后,当我们引用 Box 时,我们必须给出一个类型参数来代替 Type 。
let box: Box<string>;
可以将 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;
// ^ = (property) Box<string>.contents: string
let boxB: StringBox = { contents: "world" };
boxB.contents;
// ^ = (property) StringBox.contents: string
Box 是可重复使用的,因为 Type 可以用任何东西代替。这意味着,当我们需要一个新类型的 box 时,根本不需要声明一个新的 Box 类型(如果我们想的话,当然可以声明)。
interface Box<Type> {
contents: Type;
}
interface Apple {
// ....
}
// Same as '{ contents: Apple }'.
type AppleBox = Box<Apple>;
这也意味着我们可以通过使用泛型函数来完全避免重载
function setContents<Type>(box: Box<Type>, newContents: Type) {
box.contents = newContents;
}
值得注意的是,类型别名也可以是泛型的。我们可以定义新的 Box<Type> 接口:
interface Box<Type> {
contents: Type;
}
通过类型别名来代替:
type Box<Type> = {
contents: Type;
};
由于类型别名与接口的不同,它不仅可以描述对象类型,还可以用来编写其他各种各样的泛型助手类型。
type OrNull<Type> = Type | null;
type OneOrMany<Type> = Type | Type[];
type OneOrManyOrNull<Type> = OrNull<OneOrMany<Type>>;
// ^ = type OneOrManyOrNull<Type> = OneOrMany<Type> | null
type OneOrManyOrNullStrings = OneOrManyOrNull<string>;
// ^ = type OneOrManyOrNullStrings = OneOrMany<string> | null
过一会儿我们再回过头来给定别名。
Array 类型
泛型对象类型通常是某种容器类型,它独立于它们所包含的元素类型。以这种方式工作的数据结构是非常美妙的,这样它们就可以跨不同的数据类型重用。事实上,我们在这本手册中一直在使用类似的类型:Array 类型。无论什么时候我们写出像 number[] 或 string[] 这样的类型,都只是 Array<number> 和 Array<string> 的简写。
function doSomething(value: Array<string>) {
// ...
}
let myArray: string[] = ["hello", "world"];
// either of these work!
doSomething(myArray);
doSomething(new Array("hello", "world"));
和前面的 Box 类型很像,Array 本身是一个泛型类型。
interface Array<Type> {
/**
* Gets or sets the length of the array.
*/
length: number;
/**
* Removes the last element from an array and returns it.
*/
pop(): Type | undefined;
/**
* Appends new elements to an array, and returns the new length of the a
*/
push(...items: Type[]): number;
// ...
}
现代 JavaScript 还提供了其他泛型的数据结构,如 Map<K, V>, Set<T> 和 Promise<T> 。由于 Map、Set 和Promise 的行为方式,它们可以处理任何类型的集合。
ReadonlyArray 类型
ReadonlyArray 是一种特殊类型,用于描述不可变更的数组。
function doStuff(values: ReadonlyArray<string>) {
// We can read from 'values'...
const copy = values.slice();
console.log(`The first value is ${values[0]}`);
// ...but we can't mutate 'values'.
values.push("hello!");
// Property 'push' does not exist on type 'readonly string[]'.
}
和属性的 readonly 修饰词十分类似。当我们看到一个返回 ReadonlyArray 类型值的函数,意味着它不打算改变其内容,当我们看到一个使用 ReadonlyArray 类型参数的函数,意味着我们可以传递任意数组给函数而不用担心它会改变其内容。
与 Array 不同,我们没有可以使用的 ReadonlyArray 构造函数。
new ReadonlyArray("red", "green", "blue");
// 'ReadonlyArray' only refers to a type, but is being used as a value here.
然而,我们可以将普通数组赋值给 ReadonlyArray 。
const roArray: ReadonlyArray<string> = ["red", "green", "blue"];
就像 TypeScript 为 Array<Type> 提供的简写语法 Type[] 一样,它也为 ReadonlyArray<Type> 提供了简写语法 readonly Type[] 。
function doStuff(values: readonly string[]) {
// We can read from 'values'...
const copy = values.slice();
console.log(`The first value is ${values[0]}`);
// ...but we can't mutate 'values'.
values.push("hello!");
// Property 'push' does not exist on type 'readonly string[]'.
}
最后要注意的一点是,与 readonly 属性修饰词不同,常规 Array 和 ReadonlyArray 之间是不能双向赋值的。
let x: readonly string[] = [];
let y: string[] = [];
x = y;
y = x;
// The type 'readonly string[]' is 'readonly' and cannot be assigned to
// the mutable type 'string[]'.
元组类型
元组类型是另一种类型的数组,它确切地知道它包含多少个元素,以及它在特定位置包含哪种类型。
type StringNumberPair = [string, number];
这里,StringNumberPair 是字符串和数字的元组类型。和 ReadonlyArray 一样,它在运行时没有表示,但对TypeScript 很重要。对于类型系统,StringNumberPair 描述了其 0 索引包含一个字符串,1 索引包含一个数字的数组。
function doSomething(pair: [string, number]) {
const a = pair[0];
// ^ = const a: string
const b = pair[1];
// ^ = const b: number
// ...
}
doSomething(["hello", 42]);
如果我们试图索引不存在元素,就会报错:
function doSomething(pair: [string, number]) {
// ...
const c = pair[2];
// Tuple type '[string, number]' of length '2' has no element at index '2'.
}
我们也可以使用 JavaScript 的数组解构来解构元组。
function doSomething(stringHash: [string, number]) {
const [inputString, hash] = stringHash;
console.log(inputString);
// ^ = const inputString: string
console.log(hash);
// ^ = const hash: number
}
除了这些长度检查之外,像这样的简单元组类型等价于一种特定版本的数组,它声明了特定索引位置的属性,并且使用数值字面量类型声明了长度。
interface StringNumberPair {
// specialized properties
length: 2;
0: string;
1: number;
// Other 'Array<string | number>' members...
slice(start?: number, end?: number): Array<string | number>;
}
你可能感兴趣的另一件事是,元组可以通过问号(?在元素类型之后)拥有可选元素。可选的元组元素只能出现在末尾,而且还会影响长度的类型。
type Either2dOr3d = [number, number, number?];
function setCoordinate(coord: Either2dOr3d) {
const [x, y, z] = coord;
// ^ = const z: number | undefined
console.log(`Provided coordinates had ${coord.length} dimensions`);
// ^ = (property) length: 2 | 3
}
元组也可以有剩余元素,且必须是数组或是元组类型。
type StringNumberBooleans = [string, number, ...boolean[]];
type StringBooleansNumber = [string, ...boolean[], number];
type BooleansStringNumber = [...boolean[], string, number];
stringnumberboolean描述了一个元组,它的前两个元素分别是string和number,但后面可以有任意数量的boolean。StringBooleansNumber描述一个元组,它的第一个元素是string,然后是任意数量的boolean,并以一个number结尾。BooleansStringNumber描述一个元组,它的起始元素为任意数量的boolean,以一个 string 和一个 number 结束。
带有剩余元素的元组没有设置"length" -- 它只有一组位于不同位置的已知元素。
const a: StringNumberBooleans = ["hello", 1];
const b: StringNumberBooleans = ["beautiful", 2, true];
const c: StringNumberBooleans = ["world", 3, true, false, true, false, true];
为什么可选元素和剩余元素会派的上用场?它允许 TypeScript 将元组与参数列表对应起来。元组类型可以在剩余参数和参数中使用,所以下面的示例:
function readButtonInput(...args: [string, number, ...boolean[]]) {
const [name, version, ...input] = args;
// ...
}
等价于:
function readButtonInput(name: string, version: number, ...input: boolean[])
// ...
}
当你希望使用带有剩余参数的可变数量参数,并且需要最少数量的元素,但又不想引入中间变量时,这是很方便的。
readonly 元组类型
关于元组类型的最后一点注意事项 -- 元组类型有 readonly 变体,可以通过在它们前面加上 readonly 修饰词来指定 -- 就像数组简写语法一样。
function doSomething(pair: readonly [string, number]) {
// ...
}
如你所料,TypeScript 中不允许修改只读元组的任何元素。
function doSomething(pair: readonly [string, number]) {
pair[0] = "hello!";
// Cannot assign to '0' because it is a read-only property.
}
在大多数代码中,创建的元组不会被修改,所以尽可能地将类型注释为只读元组是一个很好的行为。这一点也很重要,因为带有 const 断言的数组字面量将使用只读元组类型进行推断。
let point = [3, 4] as const;
function distanceFromOrigin([x, y]: [number, number]) {
return Math.sqrt(x ** 2 + y ** 2);
}
distanceFromOrigin(point);
// Argument of type 'readonly [3, 4]' is not assignable to parameter of type //'[number,number]'.
// The type 'readonly [3, 4]' is 'readonly' and cannot be assigned to
// the mutable type '[number, number]'.
这里,distanceFromOrigin 从不修改它的元素,但期望接收一个可变元组参数。由于 point 的类型被推断为readonly [3, 4],它与 [number, number] 不匹配,因为该类型不能保证 point 的元素不会发生改变。