TypeScript 对象类型(Object Types)

481 阅读12分钟

WechatIMG186.png

封面为 Bing AI 画图:熊猫在端午节赛龙舟

在 JavaScript 中,我们分类数据、传递数据的基本方式是通过对象。在 TypeScript 中,通过对象类型来表示对象的类型。

对象类型可以是匿名的:

function greet(person: { name: string; age: number }) {
  //                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  return "Hello " + person.name;
}

也可以通过接口来命名:

interface Person {
  //      ^^^^^^
  name: string;
  age: number;
}

function greet(person: Person) {
  return "Hello " + person.name;
}

或类型别名:

type Person = {
  // ^^^^^^
  name: string;
  age: number;
};

function greet(person: Person) {
  return "Hello " + person.name;
}

以上三个示例中,我们编写了接受包含属性 name (必须是 string )和 age (必须是 number )的对象的函数。

快速参考

如果您想快速浏览一下重要的日常语法,可以浏览  type  和  interface  的备忘录

属性修改器

对象类型中的每个属性都可以指定:属性类型、属性是否可选以及属性是否可以写入。

可选属性

在属性后面加上?代表此属性是可选的。

interface Shape {}
declare function getShape(): Shape;

// ---cut---
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 的每个调用都是有效的。

但是当我们在存在 strictNullChecks 配置下读取时,TypeScript 会告诉我们它们可能是 undefined 。

image.png

在 JavaScript 中,即使没有设置该属性,我们仍然可以访问它,但它是 undefined 。

image.png

这种为未指定值设置默认值的模式非常普遍,JavaScript 有语法来支持它。

image.png

这里,我们为  paintShape  的参数使用了解构模式,并为  xPos  和  yPos  提供了默认值。现在  xPos  和  yPos  都肯定存在于  paintShape  的主体中,但对于调用  paintShape  函数的人,xPos  和  yPos都是可选的

请注意,目前无法在解构模式中放置类型注释。因为以下语法在 JavaScript 中意味着其他内容。

image.png

在这里,Shape  表示获取属性  shape  并将其在本地重新定义为名为  Shape  的变量。同样, xPos: number  创建了一个名为  number  的变量,其值基于参数的  xPos 。

只读属性

TypeScript 中,属性也可以标记为  readonly 。虽然它不会在运行时更改任何行为,但在类型检查期间不能写入标记为  readonly  的属性。

image.png

使用  readonly  修饰符不一定意味着一个值深层完全不可变:

image.png

如上 resident 是只读的,直接更改 resident 会报错提示,但是更改 resident.age 就不会报错。

管理 readonly 的预期很重要。属性是 readonly 并不代表它不能被改变,类型之间的判断主要是形状上的, TypeScript 不会检查 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'

虽然 readonlyPerson 属性有 readonly 修饰,但是他的属性还是可以被改变。

索引签名

有时你并不能提前知道类型属性的所有名称,但你知道值的模糊形状。

在此情况下,你可以使用索引签名来描述可能值的类型,例如:

image.png

上面,我们有一个  StringArray  接口,它有一个索引签名。 这个索引签名表明当一个  StringArray  被一个  number  索引时,它将返回一个  string

索引签名属性只允许使用这些类型: stringnumbersymbol、模板字符串,或仅由这些组成的联合类型。(因为索引就只能是这几种类型,没有其他类型了)

可以支持两种类型的索引器,但从数字索引器返回的类型必须是从字符串索引器返回的类型的子类型。 这是因为当使用  number  进行索引时,JavaScript 实际上会在索引到对象之前将其转换为  string。 这意味着使用  100(一个  number)进行索引与使用  "100"(一个  string)进行索引是一样的.

image.png

[x: number] 的类型范围比 [x: string] 大,所以报错了,如果 [x: number]Dog 并且 [x: string]Animal,那么就不报错了。

你写的索引签名要保证符合所有属性类型,否则会报错:

image.png

当然,索引签名也可以是属性类型的联合:

interface NumberOrStringDictionary {
  [index: string]: number | string;
  length: number; // ok, length is a number
  name: string; // ok, name is a string
}

索引签名也可以是 readonly,防止重新赋值:

image.png

溢出属性检查

在何处以及如何为对象分配类型会在类型系统中产生差异。 这方面的一个标准示例是溢出属性检查

image.png

调用 createSquare 函数时,传入的 colour 是错别字,所以报错了。

那你可能认为这不合理,因为如果想传入另一个属性 opacity 呢?也会报错,为了解决报错,可使用类型断言:

let mySquare = createSquare({ width: 100, opacity: 0.5 } as SquareConfig);

但是,如果你确定该对象具有一些额外属性,则更好的方法是添加字符串索引签名。 如果  SquareConfig  可以除了上述的  color  和  width  属性,也可以具有任意数量的其他属性,那么可以这样定义:

interface SquareConfig {
  color?: string;
  width?: number;
  [propName: string]: any;
}

绕过溢出属性检查的最后一种方法(可能有点令人惊讶)是将对象分配给另一个变量: 由于分配  squareOptions  不会进行过多的属性检查,因此编译器不会报错:

let squareOptions = { colour: "red", width: 100 };
let mySquare = createSquare(squareOptions);

只要你  squareOptions  和  SquareConfig  之间具有共同属性,这种方法就行得通。在上述示例中,共同的是属性  width。 但是,如果变量没有任何公共对象属性,它将报错。

对于上面这样的简单代码,建议不要试图“绕过”检查。对于更复杂的对象字面量,你需要牢记这些技巧,因为大多情况下溢出属性实际上真的是出现错误了。为了解决错误,你可以修改对象类型或传入的对象。

继承类型

有一个  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 中的所有其他字段。其实还有更好的方式:

interface BasicAddress {
  name?: string;
  street: string;
  city: string;
  country: string;
  postalCode: string;
}

interface AddressWithUnit extends BasicAddress {
  unit: string;
}

interface  上使用  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,
};

交集类型

TypeScript 还提供了另一种称为交集类型的构造,主要用于组合现有的对象类型。

交集类型是使用  &  运算符定义。

interface Colorful {
  color: string;
}
interface Circle {
  radius: number;
}

type ColorfulCircle = Colorful & Circle;

上面代码,我们将  Colorful  和  Circle  相交,生成一个具有  Colorful  和  Circle  的所有成员的新类型。

继承类型 vs 交集类型

上面介绍了两种类型组合方式,继承类型和交集类型,他们很相似但略有不同。两者之间的主要区别在于如何处理冲突属性。

泛型对象类型

我们有一个可以包含任何值的  Box  类型:string 、 number 、 Giraffe  等等。

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;
}

这要写很多模板代码,如果还要增加类型,就要写更多的模板代码。

幸好,我们可以使用泛型解决这个问题:

interface Box<Type> {
  contents: Type;
}

Type 是一个类型变量,当我们使用  Box  时,必须给出类型参数的值。

let box: Box<string>;

这里 string 就是 Type 的值,又因为 Type 是类型变量,那么 contents 的值也是 string。也就是说, Box<string>  和我们之前的  StringBox  的工作方式相同。

image.png

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;
}

当然,类型别名也可以是泛型的。

interface Box<Type> {
  contents: Type;
}

type Box<Type> = {
  contents: Type;
};

// 以上两种是相同的

由于类型别名与接口不同,它可以描述的不仅仅是对象类型,我们还可以使用它们来编写通用工具类型。

image.png

Array 类型

泛型对象类型通常是某种类型容器,其作用独立于包含的元素类型。数据结构能以这种方式工作是很好的,有助于它们跨不同的数据类型重用。

我们之前已经在使用类似的类型了:Array  类型。每当我们写出像  number[]  或  string[]  这样的类型时,实际上是  Array<number>  和  Array<string>  的简写。

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 array.
   */
  push(...items: Type[]): number;

  // ...
}

现代 JavaScript 还提供了其他泛型数据结构,例如  Map<K, V> 、 Set<T>  和  Promise<T> 。这些意味着,由于  Map 、 Set  和  Promise  的行为方式,它们可以与任何类型一起使用。

ReadonlyArray 类型

ReadonlyArray  是一种特殊类型,代表不应更改的数组。

image.png

就像属性的  readonly  修饰符一样,代表数据是只读的。当我们看到一个返回  ReadonlyArray  的函数时,它告诉我们不能更改数组内容,而当我们看到一个输入  ReadonlyArray  参数的函数时,它告诉我们可以将任何数组传递到该函数中,而不必担心它会被更改。

可以将常规  Array  分配给  ReadonlyArray 。

const roArray: ReadonlyArray<string> = ["red", "green", "blue"];

正如 TypeScript 为  Array<Type>  提供简写语法  Type[]  一样,它也为  ReadonlyArray<Type>  提供简写语法 readonly Type[] 。

image.png

最后要注意的一点是,与  readonly  属性修饰符不同,可分配性在常规  Array  和  ReadonlyArray  之间不是双向的。

image.png

元组类型

元组类型是另一种  Array  类型,它确切地知道包含多少个元素,以及在特定位置元素的类型。

type StringNumberPair = [string, number];

这里, StringNumberPair  是  string  和  number  的元组类型。与  ReadonlyArray  一样,它在 JavaScript 运行时没有表示形式,但对 TypeScript 很重要。对于类型系统而言, StringNumberPair  描述其  0  索引为  string  且其  1  索引为 number。

image.png

如果我们尝试访问溢出的下标,那么会报错

image.png

元组类型在高度依赖基于约定的 API 中非常有用,其中每个元素的含义都是“显而易见的”。

像这样的简单元组类型,可以使用特定索引属性的  Array  类型来模拟,但是没有长度检查报错

interface StringNumberPair {
  // specialized properties
  length: 2;
  0: string;
  1: number;

  // Other 'Array<string | number>' members...
  slice(start?: number, end?: number): Array<string | number>;
}

元组可以通过写出问号(在元素类型的后面加  ? )来拥有可选属性。可选元组元素只能出现在末尾,并且还会影响  length  的类型。

image.png

元组还可以有剩余元素,这些元素必须是数组/元组类型。

type StringNumberBooleans = [string, number, ...boolean[]];
type StringBooleansNumber = [string, ...boolean[], number];
type BooleansStringNumber = [...boolean[], string, number];
  • StringNumberBooleans  描述一个元组,其前两个元素分别为  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];

如上代码是正确的,boolean 的长度范围可以为 0 到无穷

为什么可选和剩余元素有用?它允许 TypeScript 将元组与参数列表相对应:

function readButtonInput(...args: [string, number, ...boolean[]]) {
  const [name, version, ...input] = args;
  // ...
}

相当于

function readButtonInput(name: string, version: number, ...input: boolean[]) {
  // ...
}

当您想要使用剩余参数获取可变数量的参数,但又不想引入中间变量时,这很有用。

只读元组类型

关于元组类型的最后一个注意事项 - 元组类型有  readonly  变体,并且可以通过在它们前面添加  readonly  修饰符来指定 - 就像数组简写语法一样。

function doSomething(pair: readonly [string, number]) {
  // ...
}

TypeScript 不允许写入  readonly  元组的任何属性。

image.png

大多数代码中,元组往往会被创建并且不能修改,因此尽可能将类型注释为  readonly  元组是一个推荐设置。这也很重要,并且带有  const  断言的数组文字将被推断为  readonly  元组类型。

image.png

在这里, distanceFromOrigin  并不修改内容,但需要一个可变的元组。由于  point  的类型被推断为  readonly [3, 4] ,因此它不会与  [number, number]  兼容,因为该类型不能保证  point  的元素不会发生改变。

参考:Object Types