TypeScript之对象类型

507 阅读12分钟

JavaScript中,传递数据最基础的方式是组织一个对象。在TypeScript中,对象类型对应的是对象类型(object types)

正如我们所见,对象类型(object types)可以是匿名的:

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

或者它们可以命名成一个interface

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

或者一个类型别名(type alias)

type Person = {
  name: string;
  age: number;
};
 
function greet(person: Person) {
  return "Hello " + person.name;
}

在以上的三个例子中,我们创建了可以传递对象的属性,传递的对象拥有name属性(string类型)和age属性(number类型)。

属性修改(Property Modifiers)

对象中的每个属性可以被赋予几个特性:类型(type),属性是否可选(optional),和是否属性可以被写入(readonly)

可选属性(Optional Properties)

更多时候,我们发现自己可能需要处理拥有属性的对象。对于这一点,我们可以通过在属性名称后标记?来标记属性是可选的。

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上使用了解构赋值(destructuring pattern),并且为xPosyPos提供了默认值。现在xPosyPos肯定存在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变量。

你可以使用高级类型require来移除所有可选属性。

只读属性(readonly Properties)

属性可以被标记为只读(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";
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,
  };
}

这在开发时,是一个非常有用的标志来控制一个对象如何被使用。在检查两种类型是否兼容时,TypeScript不会考虑两种类型的属性是否是只读的。

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来移除所有readonly

索引签名(Index Signatures)

在你不知道所有属性的请款下,但是你知道属性的类型,这样的话,你可以使用索引签名(index signatures)来描述类型,举个例子:

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

在上述中,StringArrayinterface有一个索引签名。这个索引签名意味着StringArraynumber类型的索引来访问的时候,会返回一个string类型。 索引属性必须是string或者number类型。

可以支持两种类型的索引器

可以支持两种类型的索引器,但是从number类型的索引返回的类型必须是从string索引返回的类型的子类型。这是因为当使用数字类型的索引时,在使用索引之前,JavaScript实际上会强制转化成string。这代表索引100(number)"100"(string)是同一个,所以这两个必须被标记为相互排斥的:

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 kay {
  [x: number]: Dog; // Dog是Animal的子类型,所以没有报错
  [x: string]: Animal;
}

虽然string类型的索引是描述字典(dictionary)的有利方式,他们还强制所有属性和返回类型匹配。这是因为string类型的所用用obj.propertyobj["property"]来表示同样有效。在接下来的例子中,name的类型和string类型的索引的返回类型不匹配,所以报了个错:

interface NumberDictionary {
  [index: string]: number;
 
  length: number; // ok
  name: string;
Property 'name' of type 'string' is not assignable to 'string' index type 'number'.
}

然而,如果索引签名是类型的并集,那么不同类型的属性是可以被接受的:

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

最后,你可以在索引签名前加readonly来防止被赋值:

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

你不能设置myArray[2],因为索引签名readonly类型的。

扩展类型

从其它类型扩展时是很常见的一件事情。举个例子,我们或许有一个BasicAddress类型用来描述寄包裹和信件通常需要的几个字段:

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

很多时候这是足够了的,但是地址经常有一个unit单位。然后我们就需要这样来描述一个AddressWithUnit

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

但是这样做的缺点是,我们重复了BasicAddress中的所有其它字段。我们可以用extend来扩展最初的BasicAddress,并且只需要添加一个unit属性就能达到和AddressWithUnit相同的效果。

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

interface上使用extends关键字让我们有效的从其它类型中复制成成员,并且添加新的成员。这对于减少类型声明非常有用,并用于表示同一属性的多个声明可能相关。举个例子,AddressWithUnit不需要重复street属性,并且因为streat来自BasicAddress,所以阅读者将知道这两个类型是关联的。

interface可以被多个类型extend:

interface Colorful {
  color: string;
}
 
interface Circle {
  radius: number;
}
 
interface ColorfulCircle extends Colorful, Circle {}
 
const cc: ColorfulCircle = {
  color: "red",
  radius: 42,
};

交叉类型(Intersection Types)

TypeScript提供林一种结构叫交叉类型(Intersection Types)来组合两个object类型。 交叉类型使用&操作符来定义:

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}`);
}
 
// 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'?

对比Interface和Intersections

这两种方式是非常相似的,但是实际上有细微的不同。两者之间的主要区别在于处理冲突,这通常是你在两个之间做出选择的原因。

泛型对象(Generic Object Types)

让我想象一个Box类型组合了任意类型:

interface Box {
  contents: any;
}

现在,contents属性被标记为any类型,虽然能用,但是会引发事故。 我们可以用unknown类型来代替,但是这说明我们已经知道内容的类型了,我们需要用类型守卫(type guard)来检查,或者使用类型断言。

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

一种比较安全的方法是为每种类型的内容搭建不同的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.

这太多了。其实我们可以使用Box定义一个类型参数(type parameter)类型:

interface Box<Type> {
  contents: Type;
}

当我们使用Box类型的时候,我们必须在Type的位置上提供一个类型传参(type argument)

let box: Box<string>;

TypeScript看到Box<string>TypeScript会把Box<string>中的每个Type替换成string。换句话说,Box<string>和之前提到的StringBox效果一样。

interface Box<Type> {`Box`
  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

Type可以被任何类型替代,所以Box是可以重复使用。这意味着,当我们需要一个新类型的盒子,我们不再需要定义一个新的Box类型。

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

这也代表我们可以通过使用`泛型函数(generic functions)`来避免使用重载:

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

值得注意的是类型别名(type aliases)也可以使用泛型。我们可以定义一个新的Box<Type>interface

interface Box<Type> {
  contents: Type;
}

写成类型别名(type aliases)的版本就是:

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

因此使用类型别名比使用interface可以描述更多的东西,不单单是对象,我们可以使用类型别名来写更多的帮助函数:

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

数组类型(The Array Type)

泛型对象(Generic object types)通常是一种独立于它们所包含元素的容器。这种结构让数据结构更加容易被复用。 实际上,我们一直在使用类似的类型: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 array.
   */
  push(...items: Type[]): number;
 
  // ...
}

现代的JavaScript也提供了其它的泛型数据结构,例如Map<K,V>,Set<T>,和Promise<T>。所有这一切都代表Map,SetPromise的表现形式,它们可以跟任何类型的集合一起使用。

ReadonlyArray类型(The ReadonlyArray Type)

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.

取而代之的时,我们可以赋值Array类型到ReadonlyArray类型。

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

就像TypeScriptArray<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[]'.
}

ReadonlyArrays是不可双向的:

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[]'.

元组类型(Tuple Types)

元组类型(Tuple)是另一种Array类型,它确切的知道它包含多少个元素。

type StringNumberPair = [string, number];

在这里,StringNumberPair是一个由stringnumber类型构成的元组类型。就像ReadonlyArray,在运行时会被移除。在类型系统中,StringNumberPair的含义是一个在索引0的位置上是string1位置上是number的数组。

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'.
}

我们还可以解构元组。

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

元组还可以通过在元素后面添加?,添加可选属性。可选元组元素可以添加在最后一个元素,并且可以影响length属性。

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];
  1. StringNumberrBooleans描述了一个前两个元素是stringnumber类型,但是剩下的任意数量都是boolean类型的元组。

  2. StringBooleansNumber描述了一个第一个元素是string类型,最后一个元素是number类型,中间任意数量是boolean类型的元组。

  3. 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[]) {
  // ...
}

当你想用一个rest参数爠可变数量的参数时,这很方便。

只读元素类型(readonly Tuple Types)

关于元组类型的最后一点说明就是,元组可以使用readonly,可以通过在元组前添加一个readonly来指定。

function doSomething(pair: readonly [string, number]) {
  pair[0] = "hello!";
// Cannot assign to '0' because it is a read-only property.
}

在大多数代码中,元组往往被创建并保持不变,所以,将元组注释为readonly是一种很好的方式。但是使用const断言也是推断为readonly元组类型的一种方式。

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的元素不会发生变化

翻译自 Object Types