TypeScript 之 Object Types

冴羽發表於2021-11-23

前言

TypeScript 的官方文件早已更新,但我能找到的中文文件都還停留在比較老的版本。所以對其中新增以及修訂較多的一些章節進行了翻譯整理。

本篇整理自 TypeScript Handbook 中 「Object Types」 章節。

本文並不嚴格按照原文翻譯,對部分內容也做了解釋補充。

物件型別(Object types)

在 JavaScript 中,最基本的將資料成組和分發的方式就是通過物件。在 TypeScript 中,我們通過物件型別(object types)來描述物件。

物件型別可以是匿名的:

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

屬性修飾符(Property Modifiers)

物件型別中的每個屬性可以說明它的型別、屬性是否可選、屬性是否只讀等資訊。

可選屬性(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 就是可選屬性。因為他們是可選的,所以上面所有的呼叫方式都是合法的。

我們也可以嘗試讀取這些屬性,但如果我們是在 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
  // ...
}

這裡我們使用瞭解構語法以及為 xPosyPos 提供了預設值。現在 xPosyPos 的值在 paintShape 函式內部一定存在,但對於 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 的值賦值給區域性變數 ShapexPos: number 也是一樣,會基於 xPos 建立一個名為 number 的變數。

readonly 屬性(readonly Properties)

在 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";
  // 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) {
  // 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 在檢查兩個型別是否相容的時候,並不會考慮兩個型別裡的屬性是否是 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'

索引簽名(Index Signatures)

有的時候,你不能提前知道一個型別裡的所有屬性的名字,但是你知道這些值的特徵。

這種情況,你就可以用一個索引簽名 (index signature) 來描述可能的值的型別,舉個例子:

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

這樣,我們就有了一個具有索引簽名的介面 StringArray,這個索引簽名表示當一個 StringArray 型別的值使用 number 型別的值進行索引的時候,會返回一個 string型別的值。

一個索引簽名的屬性型別必須是 string 或者是 number

雖然 TypeScript 可以同時支援 stringnumber 型別,但數字索引的返回型別一定要是字元索引返回型別的子型別。這是因為當使用一個數字進行索引的時候,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;
}

儘管字串索引用來描述字典模式(dictionary pattern)非常的有效,但也會強制要求所有的屬性要匹配索引簽名的返回型別。這是因為一個宣告類似於 obj.property 的字串索引,跟 obj["property"]是一樣的。在下面的例子中,name 的型別並不匹配字串索引的型別,所以型別檢查器會給出報錯:

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.

因為索引簽名是 readonly ,所以你無法設定 myArray[2] 的值。

屬性繼承(Extending Types)

有時我們需要一個比其他型別更具體的型別。舉個例子,假設我們有一個 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;
}

對介面使用 extends關鍵字允許我們有效的從其他宣告過的型別中拷貝成員,並且隨意新增新成員。

介面也可以繼承多個型別:

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)的方法,用於合併已經存在的物件型別。

交叉型別的定義需要用到 & 操作符:

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'?

介面繼承與交叉型別(Interfalces vs Intersections)

這兩種方式在合併型別上看起來很相似,但實際上還是有很大的不同。最原則性的不同就是在於衝突怎麼處理,這也是你決定選擇那種方式的主要原因。

interface Colorful {
  color: string;
}

interface ColorfulSub extends Colorful {
  color: number
}

// Interface 'ColorfulSub' incorrectly extends interface 'Colorful'.
// Types of property 'color' are incompatible.
// Type 'number' is not assignable to type 'string'.

使用繼承的方式,如果重寫型別會導致編譯錯誤,但交叉型別不會:

interface Colorful {
  color: string;
}

type ColorfulSub = Colorful & {
  color: number
}

雖然不會報錯,那 color 屬性的型別是什麼呢,答案是 never,取得是 stringnumber 的交集。

泛型物件型別(Generic Object Types)

讓我們寫這樣一個 Box 型別,可以包含任何值:

interface Box {
  contents: any;
}

現在 content 屬性的型別為 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());

一個更加安全的做法是將 Box 根據 contents 的型別拆分的更具體一些:

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 ,它宣告瞭一個型別引數 (type parameter):

interface Box<Type> {
  contents: Type;
}

你可以這樣理解:BoxType 就是 contents 擁有的型別 Type

當我們引用 Box 的時候,我們需要給予一個型別實參替換掉 Type

let box: Box<string>;

Box 想象成一個實際型別的模板,Type 就是一個佔位符,可以被替代為具體的型別。當 TypeScript 看到 Box<string>,它就會替換為 Box<Type>Typestring ,最後的結果就會變成 { 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

不過現在的 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;
};

型別別名不同於介面,可以描述的不止是物件型別,所以我們也可以用型別別名寫一些其他種類的的泛型幫助型別。

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 型別(The Array Type)

我們之前講過 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>。因為 MapSetPromise的行為表現,它們可以跟任何型別搭配使用。

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

ReadonlyArray 主要是用來做意圖宣告。當我們看到一個函式返回 ReadonlyArray,就是在告訴我們不能去更改其中的內容,當我們看到一個函式支援傳入 ReadonlyArray ,這是在告訴我們我們可以放心的傳入陣列到函式中,而不用擔心會改變陣列的內容。

不像 ArrayReadonlyArray 並不是一個我們可以用的構造器函式。

new ReadonlyArray("red", "green", "blue");
// 'ReadonlyArray' only refers to a type, but is being used as a value here.

然而,我們可以直接把一個常規陣列賦值給 ReadonlyArray

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

TypeScript 也針對 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[]'.
}

最後有一點要注意,就是 ArraysReadonlyArray 並不能雙向的賦值:

let x: readonly string[] = [];
let y: string[] = [];
 
x = y; // ok
y = x; // The type 'readonly string[]' is 'readonly' and cannot be assigned to the mutable type 'string[]'.

元組型別(Tuple Types)

元組型別是另外一種 Array 型別,當你明確知道陣列包含多少個元素,並且每個位置元素的型別都明確知道的時候,就適合使用元組型別。

type StringNumberPair = [string, number];

在這個例子中,StringNumberPair 就是 stringnumber 的元組型別。

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

如果要獲取元素數量之外的元素,TypeScript 會提示錯誤:

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 中很有用,因為它會讓每個元素的意義都很明顯。當我們解構的時候,元組給了我們命名變數的自由度。在上面的例子中,我們可以命名元素 01 為我們想要的名字。

然而,也不是每個使用者都這樣認為,所以有的時候,使用一個帶有描述屬性名字的物件也許是個更好的方式。

除了長度檢查,簡單的元組型別跟宣告瞭 length 屬性和具體的索引屬性的 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

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
}

Tuples 也可以使用剩餘元素語法,但必須是 array/tuple 型別:

type StringNumberBooleans = [string, number, ...boolean[]];
type StringBooleansNumber = [string, ...boolean[], number];
type 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];

console.log(a.length); // (property) length: number

type StringNumberPair = [string, number];
const d: StringNumberPair = ['1', 1];
console.log(d.length); // (property) length: 2

可選元素和剩餘元素的存在,使得 TypeScript 可以在引數列表裡使用元組,就像這樣:

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

基本等同於:

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

readonly 元組型別(readonly Tuple Types)

元組型別也是可以設定 readonly 的:

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

這樣 TypeScript 就不會允許寫入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] 並不相容,所以 TypeScript 給了一個報錯。

TypeScript 系列

TypeScript 系列文章地址:https://github.com/mqyqingfeng/Blog

如果你對於 TypeScript 有什麼困惑或者想要了解的內容,歡迎與我交流,微信:「mqyqingfeng」,公眾號:「冴羽的JavaScript部落格」或者「yayujs」

如果有錯誤或者不嚴謹的地方,請務必給予指正,十分感謝。如果喜歡或者有所啟發,歡迎 star,對作者也是一種鼓勵。

相關文章