# 对象类型

在 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)的对象。

# 属性修饰符

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

# # 可选属性

很多时候,我们会发现自己在处理可能具有属性集的对象。在这些情况下,我们可以通过在其名称末尾添加问号 (?) 来将这些属性标记为可选。

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

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

在这个例子中,xPosyPos都被认为是可选的。我们可以选择提供其中任何一个,因此上面对 paintShape的每个调用都是有效的。所有的可选性真正说明的是,如果设置了属性,它最好有一个特定的类型。

我们也可以从这些属性中读取——但是当我们在 strictNullChecks 下读取时,TypeScript 会告诉我们它们可能是 undefined

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

interface PaintOptions {
  shape: Shape;
  xPos?: number;
  yPos?: number;
}

function paintShape(opts: PaintOptions) {
  let xPos = opts.xPos;
  let yPos = opts.yPos;
  // ...
}

在 JavaScript 中,即使该属性从未被设置,我们仍然可以访问它——它只会给我们值 undefined。我们可以专门处理undefined

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

interface PaintOptions {
  shape: Shape;
  xPos?: number;
  yPos?: number;
}

function paintShape(opts: PaintOptions) {
  let xPos = opts.xPos === undefined ? 0 : opts.xPos;
  let yPos = opts.yPos === undefined ? 0 : opts.yPos;
  // ...
}

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

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

interface PaintOptions {
  shape: Shape;
  xPos?: number;
  yPos?: number;
}

function paintShape({ shape, xPos = 0, yPos = 0 }: PaintOptions) {
  console.log("x coordinate at", xPos);
  console.log("y coordinate at", yPos);
  // ...
}

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

请注意,目前没有办法在解构模式中放置类型注释。这是因为下面的语法在 JavaScript 中已经有了不同的含义。

interface Shape {}
declare function render(x: unknown);
function draw({ shape: Shape, xPos: number = 100 /*...*/ }) {
  render(shape);
  render(xPos);
}

在对象解构模式中,shape: Shape表示“获取属性 shape并将其在本地重新定义为名为 Shape的变量。同样,xPos: number创建一个名为 number的变量,其值基于参数的 xPos

使用 映射修饰符 ,您可以删除 optional属性。

# # readonly 属性

对于 TypeScript,属性也可以标记为 readonly。虽然它不会在运行时改变任何行为,但在类型检查期间无法写入标记为 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";
}

使用 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 = {
    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'

使用 映射修饰符 ,您可以删除 readonly属性。

# # 索引签名

有时您并不提前知道类型属性的所有名称,但您确实知道值的形状。

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

declare function getStringArray(): StringArray;
interface StringArray {
  [index: number]: string;
}

const myArray: StringArray = getStringArray();
const secondItem = myArray[1];

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

索引签名属性类型必须是 'string' 或 'number'。

虽然字符串索引签名是描述 "dictionary" 模式的强大方式,但它们还强制所有属性与其返回类型匹配。这是因为字符串索引声明 obj.property也可用作 obj["property"]。在下面的例子中,name的类型与字符串索引的类型不匹配,类型检查器给出错误:

interface NumberDictionary {
  [index: string]: number;

  length: number; // ok
  name: string;
}

但是,如果索引签名是属性类型的联合,则可以接受不同类型的属性:

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

最后,您可以制作索引签名 readonly以防止分配给它们的索引:

declare function getReadOnlyStringArray(): ReadonlyStringArray;
interface ReadonlyStringArray {
  readonly [index: number]: string;
}

let myArray: ReadonlyStringArray = getReadOnlyStringArray();
myArray[2] = "Mallory";

您不能设置 myArray[2],因为索引签名是 readonly

# 扩展类型

拥有可能是其他类型的更具体版本的类型是很常见的。例如,我们可能有一个 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;
}

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

# 交集类型

interfaces 允许我们通过扩展其他类型来构建新类型。TypeScript 提供了另一种称为交集类型的构造,主要用于组合现有的对象类型。

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

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

type ColorfulCircle = Colorful & Circle;

在这里,我们将 ColorfulCircle相交以生成一个包含 ColorfulCircle的所有成员的新类型。

interface Colorful {
  color: string;
}
interface Circle {
  radius: number;
}
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 });

# 接口与交集

我们只是研究了两种组合相似但实际上略有不同的类型的方法。使用接口,我们可以使用 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;
}

但这意味着我们必须创建不同的函数或函数重载,才能对这些类型进行操作。

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类型。

interface Box<Type> {
  contents: Type;
}

您可能会将其解读为“TypeBox是其 contents具有 Type类型的东西”。稍后,当我们引用 Box时,我们必须给出一个类型参数来代替 Type

interface Box<Type> {
  contents: Type;
}
let box: Box<string>;

Box视为真实类型的模板,其中 Type是一个占位符,将被其他类型替换。当 TypeScript 看到 Box<string&gt;时,它会将 Box<Type&gt;中的每个 Type实例替换为 string,并最终使用 { contents: string }之类的东西。换言之,Box<string&gt;和我们之前的 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;

Box是可重用的,因为 Type可以用任何东西代替。这意味着当我们需要一个新类型的盒子时,我们根本不需要声明一个新的 Box类型(尽管如果我们愿意,我们当然可以)。

interface Box<Type> {
  contents: Type;
}

interface Apple {
  // ....
}

// Same as '{ contents: Apple }'.
type AppleBox = Box<Apple>;

这也意味着我们可以通过使用 泛型函数 来完全避免重载。

interface Box<Type> {
  contents: Type;
}

function setContents<Type>(box: Box<Type>, newContents: Type) {
  box.contents = newContents;
}

值得注意的是,类型别名也可以是通用的。我们可以定义新的 Box<Type&gt;接口,它是:

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 OneOrManyOrNullStrings = OneOrManyOrNull<string>;

稍后我们将回过头来输入别名。

# # Array 型

通用对象类型通常是某种容器类型,它们独立于它们所包含的元素类型工作。数据结构以这种方式工作是理想的,这样它们就可以在不同的数据类型中重用。

事实证明,在这本手册中,我们一直在使用一种类似的类型:Array类型。每当我们写出像 number[]string[]这样的类型时,这实际上只是 Array<number&gt;Array<string&gt;的简写。

function doSomething(value: Array<string>) {
  // ...
}

let myArray: string[] = ["hello", "world"];

// either of these work!
doSomething(myArray);
doSomething(new Array("hello", "world"));

很像上面的 Box类型,Array本身是一个泛型类型。

interface Number {}
interface String {}
interface Boolean {}
interface Symbol {}
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&gt;Set<T&gt;Promise<T&gt;。所有这一切真正意味着由于 MapSetPromise的行为方式,它们可以与任何类型的集合一起使用。

# 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!");
}

就像属性的 readonly修饰符一样,它主要是我们可以用于意图的工具。当我们看到一个返回 ReadonlyArrays 的函数时,它告诉我们根本不打算更改内容,而当我们看到一个消耗 ReadonlyArrays 的函数时,它告诉我们可以将任何数组传递给该函数,而不必担心它会改变它的内容。

Array不同,我们没有可以使用的 ReadonlyArray构造函数。

new ReadonlyArray("red", "green", "blue");

相反,我们可以将常规的 Array分配给 ReadonlyArray

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

正如 TypeScript 为 Array<Type&gt;Type[]提供简写语法一样,它也为 ReadonlyArray<Type&gt;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!");
}

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

let x: readonly string[] = [];
let y: string[] = [];

x = y;
y = x;

# # 元组类型

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

type StringNumberPair = [string, number];
//                      ^^^^^^^^^^^^^^^^

这里,StringNumberPairstringnumber的元组类型。与 ReadonlyArray一样,它在运行时没有表示,但对 TypeScript 很重要。对于类型系统,StringNumberPair描述了 0索引包含 string1索引包含 number的数组。

function doSomething(pair: [string, number]) {
  const a = pair[0];
  const b = pair[1];
  // ...
}

doSomething(["hello", 42]);

如果我们试图索引超过元素的数量,我们会得到一个错误。

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

  const c = pair[2];
}

我们也可以解构元组使用JavaScript的数组解构。

function doSomething(stringHash: [string, number]) {
  const [inputString, hash] = stringHash;

  console.log(inputString);

  console.log(hash);
}

元组类型在大量基于约定的 API 中很有用,其中每个元素的含义都是 "obvious"。这使我们在解构变量时可以灵活地命名变量。在上面的示例中,我们可以将元素 01命名为我们想要的任何名称。

但是,由于并非每个用户都对显而易见的事物持有相同的看法,因此可能值得重新考虑使用具有描述性属性名称的对象是否更适合您的 API。

除了那些长度检查之外,像这样的简单元组类型等价于为特定索引声明属性的 Array版本,以及使用数字字面类型声明 length的类型。

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

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

您可能感兴趣的另一件事是元组可以通过写出问号(元素类型后的?)来具有可选属性。可选的元组元素只能放在最后,也会影响 length的类型。

type Either2dOr3d = [number, number, number?];

function setCoordinate(coord: Either2dOr3d) {
  const [x, y, z] = coord;

  console.log(`Provided coordinates had ${coord.length} dimensions`);
}

元组也可以有剩余元素,它们必须是数组/元组类型。

type StringNumberBooleans = [string, number, ...boolean[]];
type StringBooleansNumber = [string, ...boolean[], number];
type BooleansStringNumber = [...boolean[], string, number];
  • StringNumberBooleans描述了一个元组,其前两个元素分别是 stringnumber,但后面可以有任意数量的 boolean
  • StringBooleansNumber描述了一个元组,它的第一个元素是 string,然后是任意数量的 boolean,并以 number结尾。
  • BooleansStringNumber描述了一个元组,其起始元素是任意数量的 boolean,以 stringnumber结尾。

带有剩余元素的元组没有集合 "length" - 它只有一组位于不同位置的知名元素。

type StringNumberBooleans = [string, number, ...boolean[]];
const a: StringNumberBooleans = ["hello", 1];
const b: StringNumberBooleans = ["beautiful", 2, true];
const c: StringNumberBooleans = ["world", 3, true, false, true, false, true];

为什么 optional 和 rest 元素可能有用?好吧,它允许 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 中不允许写入 readonly元组的任何属性。

function doSomething(pair: readonly [string, number]) {
  pair[0] = "hello!";
}

在大多数代码中,元组往往被创建并保持不变,因此尽可能将类型注释为 readonly元组是一个很好的默认设置。这一点也很重要,因为带有 const断言的数组字面将使用 readonly元组类型来推断。

let point = [3, 4] as const;

function distanceFromOrigin([x, y]: [number, number]) {
  return Math.sqrt(x ** 2 + y ** 2);
}

distanceFromOrigin(point);

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

Last Updated: 5/25/2023, 2:35:11 PM