TypeScript 官方手冊翻譯計劃【五】:物件型別

Chor 發表於 2021-11-28
  • 說明:目前網上沒有 TypeScript 最新官方文件的中文翻譯,所以有了這麼一個翻譯計劃。因為我也是 TypeScript 的初學者,所以無法保證翻譯百分之百準確,若有錯誤,歡迎評論區指出;
  • 翻譯內容:暫定翻譯內容為 TypeScript Handbook,後續有空會補充翻譯文件的其它部分;
  • 專案地址TypeScript-Doc-Zh,如果對你有幫助,可以點一個 star ~

本章節官方文件地址:Object Types

物件型別

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

你可以將這段程式碼解讀為“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

因為 Type 可以被任何型別替換,所以 Box 具有可重用性。這意味著當我們的 contents 需要一個新型別的時候,完全無需再宣告一個新的 Box 型別(雖然這麼做沒有任何問題)。

interface Box<Type> {
    contents: Type;
}
interface Apple {
    //...
}
// 和 { 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 (陣列)型別。我們編寫的 number[] 型別或者 string[] 型別,實際上都是 Array<number>Array<string> 的簡寫。

function doSomething(value: Array<string>) {
  // ...
}
 
let myArray: string[] = ["hello", "world"];
 
// 下面兩種寫法都可以!
doSomething(myArray);
doSomething(new Array("hello", "world"));

就和前面的 Box 型別一樣,Array 本身也是一個泛型:

interface Array<Type> {
  /**
   * 獲取或者設定陣列的長度
   */
  length: number;
 
  /**
   * 移除陣列最後一個元素,並返回該元素
   */
  pop(): Type | undefined;
 
  /**
   * 向陣列新增新元素,並返回陣列的新長度
   */
  push(...items: Type[]): number;
 
  // ...
}

現代 JavaScript 也提供了其它同樣是泛型的資料結構,比如 Map<K,V>Set<T>Promise<T>。這其實意味著,MapSetPromise 的表現形式使得它們能夠處理任意的型別集。

只讀陣列型別

ReadonlyArray(只讀陣列) 是一種特殊的型別,它描述的是無法被修改的陣列。

function doStuff(values: ReadonlyArray<string>) {
  // 我們可以讀取 values 
  const copy = values.slice();
  console.log(`The first value is ${values[0]}`);
 
  // ...但是無法修改 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"];

TypeScript 不僅為 Array<Type> 提供了簡寫 Type[],也為 ReadonlyArray<Type> 提供了簡寫 readonly Type[]

function doStuff(values: readonly string[]) {
  // 我們可以讀取 values
  const copy = values.slice();
  console.log(`The first value is ${values[0]}`);
 
  // ...但無法修改 values
  values.push("hello!");
        ^^^^^
      // Property 'push' does not exist on type 'readonly string[]'.
}

最後一件需要注意的事情是,和 readonly 屬性修飾符不同,普通的 ArrayReadonlyArray 之間的可賦值性不是雙向的。

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

元組型別

元組型別是一種特殊的 Array 型別,它的元素數量以及每個元素對應的型別都是明確的,

type StringNumberPair = [string, number];

這裡,StringNumberPair 是一個包含 string 型別和 number 型別的元組型別。和 ReadonlyArray 一樣,它沒有對應的執行時表示,但對於 TypeScript 仍非常重要。對於型別系統而言,StringNumberPair 描述了這樣的一個陣列:下標為 0 的位置包含了一個 string 型別的值,下標為 1 的位置包含了一個 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'.
}

我們也可以使用 JavaScript 的陣列解構去解構元組

function doSomething(stringHash: [string, number]) {
  const [inputString, hash] = stringHash;
 
  console.log(inputString);
              ^^^^^^^^^^^    
        // const inputString: string
 
  console.log(hash);
              ^^^^   
            // const hash: number
}

元組型別在高度基於約定的 API 中很有用,因為每個元素的含義都是“明確的”。這給予了我們一種靈活性,讓我們在解構元組的時候可以給變數取任意名字。在上面的例子中,我們可以給下標為 0 和 1 的元素取任何名字。

不過,怎麼才算“明確”呢?每個開發者的見解都不一樣。也許你需要重新考慮一下,在 API 中使用帶有描述屬性的物件是否會更好。

除了長度檢查之外,類似這樣的簡單元組型別其實等價於一個物件,這個物件宣告瞭特定下標的屬性,且包含了數值字面量型別的 length 屬性。

interface StringNumberPair {
  // 特定的屬性
  length: 2;
  0: string;
  1: number;
 
  // 其它 Array<string | number> 型別的成員
  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];
  • StringNumberBooleans 表示這樣的一個元組:它的前兩個元素分別是 stringnumber 型別,同時後面跟著若干個 boolean 型別的元素。
  • StringBooleansNumber 表示這樣的一個元組:它的第一個元素是 string 型別,接著跟著若干個 boolean 型別的元素,最後一個元素是 number
  • BooleansStringNumber 表示這樣的一個元組:它前面有若干個 boolean 型別的元素,最後兩個元素分別是 stringnumber 型別。

使用展開運算子的元組沒有明確的長度 —— 可以明確的只是它的不同位置有對應型別的元素。

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 修飾符,我們可以宣告一個只讀的元組型別 —— 就像只讀陣列的簡寫一樣。

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 的元素不會被修改。