TypeScript 对象类型 详解

对象类型

在 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 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

function paintShape(opts: PaintOptions) {
  let xPos = opts.xPos;
                  ^^^^
            // (property) PaintOptions.xPos?: number | undefined
  let yPos = opts.yPos;
                  ^^^^   
            // (property) PaintOptions.yPos?: number | undefined
  // ...
}

在 JavaScript 中,即使从来没有设置过某个属性,我们也依然可以访问它 —— 值是 undefined。我们可以对 undefined 这种情况做特殊的处理。

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);
                                 ^^^^ 
                            // (parameter) xPos: number
  console.log("y coordinate at", yPos);
                                 ^^^^ 
                            // (parameter) yPos: number
  // ...
}

这里我们为 paintShape 的参数使用了解构模式,同时也为 xPosyPos 提供了默认值。现在,xPosyPospaintShape 函数体中就一定是有值的,且调用该函数的时候这两个参数仍然是可选的。

注意,目前没有任何方法可以在解构模式中使用类型注解。这是因为下面的语法在 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,表示这是一个只读属性。虽然这不会改变运行时的任何行为,但标记为 readonly 的属性在类型检查期间无法再被重写。

interface SomeType {
  readonly prop: string;
}
 
function doSomething(obj: SomeType) {
  // 可以读取 obj.prop
  console.log(`prop has the value '${obj.prop}'.`);
 
  // 但无法重新给它赋值
  obj.prop = "hello";
// Cannot assign to 'prop' because it is a read-only property.
}

使用 readonly 修饰符并不一定意味着某个值是完全不可修改的 —— 或者换句话说,并不意味着它的内容是不可修改的。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 = {
       ^^^^^^^^
// Cannot assign to 'resident' because it is a read-only property.
    name: "Victor the Evictor",
    age: 42,
  };
}

理解 readonly 的含义非常重要。在使用 TypeScript 进行开发的过程中,它可以有效地表明一个对象应该如何被使用。TypeScript 在检查两个类型是否兼容的时候,并不会考虑它们的属性是否是只读的,所以只读属性也可以通过别名进行修改。

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

使用映射修饰符可以移除只读属性。

索引签名

有时候你无法提前知道某个类型所有属性的名字,但你知道这些属性值的类型。在这种情况下,你可以使用索引签名去描述可能值的类型。举个例子:

interface StringArray {
    [index: number]: string
}
const myArray: StringArray = getStringArray();
const secondItem = myArray[1];
      ^^^^^^^^^^    
     // const secondItem: string

上面的代码中,StringArray 接口有一个索引签名。这个索引签名表明当 StringArraynumber 类型的值索引的时候,它将会返回 string 类型的值。

一个索引签名的属性类型要么是 string,要么是 number

当然,也可以同时支持两种类型……

但前提是,数值型索引返回的类型必须是字符串型索引返回的类型的一个子类型。这是因为,当使用数值索引对象属性的时候,JavaScript 实际上会先把数值转化为字符串。这意味着使用 100(数值)进行索引与使用 "100"(字符串)进行索引,效果是一样的,因此这两者必须一致。

interface Animal {
  name: string;
}
 
interface Dog extends Animal {
  breed: string;
}
 
// Error: indexing with a numeric string might get you a completely separate type of Animal!
interface NotOkay {
  [x: number]: Animal;
// 'number' index type 'Animal' is not assignable to 'string' index type 'Dog'.
  [x: string]: Dog;
}

不过,如果索引签名所描述的类型本身是各个属性类型的联合类型,那么就允许出现不同类型的属性了:

interface NumberOrStringDictionary {
  [index: string]: number | string;
  length: number; // length 是数字,可以
  name: string; // name 是字符串,可以
}

最后,可以设置索引签名是只读的,这样可以防止对应索引的属性被重新赋值:

interface ReadonlyStringArray {
  readonly [index: number]: string;
}
 
let myArray: ReadonlyStringArray = getReadOnlyStringArray();
myArray[2] = "Mallory";
// Index signature in type 'ReadonlyStringArray' only permits reading.

因为索引签名设置了只读,所以无法再更改 myArray[2] 的值。

拓展类型

基于某个类型拓展出一个更具体的类型,这是一个很常见的需求。举个例子,我们有一个 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;

这里,我们结合 ColorfulCircle类型,产生了一个新的类型,它拥有 ColorfulCircle 的所有成员。

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 });
/*
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 类型,它可能包含任何类型的值:stringnumberGiraffe 等。

interface Box {
    contents: any;
}

现在,contents 属性的类型是 any,这当然没问题,但使用 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());

还有另一种确保类型安全的做法是,针对每种不同类型的 contents,创建不同的 Box 类型。

interface NumberBox {
  contents: number;
}
 
interface StringBox {
  contents: string;
}
 
interface BooleanBox {
  contents: boolean;
}

但这意味着我们需要创建不同的函数,或者是函数的重载,这样才能操作不同的 Box 类型。

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 {
    contents: Type;
}

你可以将这段代码解读为“Box 的类型是 Type,它的 contents 的类型是 Type”。接着,当我们引用 Box 的时候,我们需要传递一个类型参数用于替代 Type

let box: Box;

如果把 Box 看作是实际类型的模板,那么 Type 就是一个会被其它类型代替的占位符。当 TypeScript 看到 Box 的时候,它会将 Box 中的所有 Type 替换为 string,得到一个类似 { contents: string } 的对象。换句话说,Box 和之前例子中的 StringBox 是等效的。

interface Box {
  contents: Type;
}
interface StringBox {
  contents: string;
}
 
let boxA: Box = { contents: "hello" };
boxA.contents;
     ^^^^^^^^   
    // (property) Box.contents: string
 
let boxB: StringBox = { contents: "world" };
boxB.contents;
     ^^^^^^^^   
    // (property) StringBox.contents: string

因为 Type 可以被任何类型替换,所以 Box 具有可重用性。这意味着当我们的 contents 需要一个新类型的时候,完全无需再声明一个新的 Box 类型(虽然这么做没有任何问题)。

你可能感兴趣的:(TypeScript 对象类型 详解)