掌握 TypeScript 中的映射類型

DRY 原則(Don't repeat yourself)是軟件開發(fā)中最重要的原則之一,即不要重復自己。應該避免在代碼中的兩個或多個地方存在重復的業(yè)務邏輯。

在 TypeScript 中,映射類型可以幫助我們避免編寫重復的代碼,它可以根據(jù)現(xiàn)有類型和定義的一些規(guī)則來創(chuàng)建新類型。下面就來看一下什么是映射類型以及如何構建自己的映射類型。

1. 基本概念
在介紹映射類型之前,先來看一些前置知識。

(1)索引訪問類型
在 TypeScript 中,我們可以通過按名稱查找屬性來訪問它的類型:

type AppConfig = {
  username: string;
  layout: string;
};

type Username = AppConfig["username"];
在這個例子中,通過 AppConfig 類型的索引 username 獲取到其類型 string,類似于在 JavaScript 中通過索引來獲取對象的屬性值。

(2)索引簽名
當類型屬性的實際名稱是未知的,但它們將引用的數(shù)據(jù)類型已知時,索引簽名就很方便。

type User = {
  name: string;
  preferences: {
    [key: string]: string;
  }
};

const currentUser: User = {
  name: 'Foo Bar',
  preferences: {
    lang: 'en',
  },
};
const currentLang = currentUser.preferences.lang;
在上面的例子中,currentLang 的類型是 string 而不是 any。此功能與 keyof 運算符一起搭配使用是使映射類型成為可能的核心之一。

(3)聯(lián)合類型
聯(lián)合類型是兩種或多種類型的組合。它表明值的類型可以是聯(lián)合中包含的任何一種類型。

type StringOrNumberUnion = string | number;

let value: StringOrNumberUnion = 'hello, world!';
value = 100;
下面是一個更復雜的例子,編譯器可以為聯(lián)合類型提供一些高級保護:

type Animal = {
  name: string;
  species: string;
};

type Person = {
  name: string;
  age: number;
};

type AnimalOrPerson = Animal | Person;

const value: AnimalOrPerson = loadFromSomewhereElse();

console.log(value.name);   // ?
console.log(value.age);    // ?

if ('age' in value) {
  console.log(value.age); // ?
}
在這個例子中,因為 Animal 和 Person 都有 name 屬性,所以第 15 行的 value.name 可以正常輸出,沒有錯誤。而第 16 行的 value.age 會編譯錯誤,因為如果 value 是 Animal 類型,則 value 是沒有 age 屬性的。在第 19 行的 if 塊中,因為只有 value 存在 age 屬性才能進入這個代碼塊。所以,在這個 if 塊中,value 一定是 Person,TS 可以知道 value 一定是具有 age 屬性的,所以編譯正確。

(4)keyof 類型運算符
keyof 類型運算符返回傳遞給它的類型的 key 的聯(lián)合。

type AppConfig = {
  username: string;
  layout: string;
};

type AppConfigKey = keyof AppConfig;
在這個例子中,AppConfigKey 類型會被解析為"username" | "layout"。它可以與索引簽名一起使用:

type User = {
  name: string;
  preferences: {
    [key: string]: string;
  }
};

type UserPreferenceKey = keyof User["preferences"];
這里,UserPreferenceKey 類型被解析為 string | number。

(5)元組類型
元組是一種特殊的數(shù)組類型,其中數(shù)組的元素可能是特定索引處的特定類型。它們允許 TypeScript 編譯器圍繞值數(shù)組提供更高的安全性,尤其是當這些值屬于不同類型時。

例如,TypeScript 編譯器能夠為元組的各種元素提供類型安全:

type Currency = [number, string];

const amount: Currency = [100, 'USD'];

function add(values: number[]) {
   return values.reduce((a, b) => a + b);
}

add(amount);
// Error: Argument of type 'Currency' is not assignable to parameter of type 'number[]'.
// Type 'string' is not assignable to type 'number'.
上面的代碼中會報錯,Currency 類型的參數(shù)不能分配給“number[]”類型的參數(shù),string 類型不能分配給 number 類型。

當訪問超出元組定義類型的索引處的元素時,TypeScript 能夠進行提示:

type LatLong = [number, number];

const loc: LatLong = [48.858370, 2.294481];

console.log(loc[2]);
// Error: Tuple type 'LatLong' of length '2' has no element at index '2'.
這里,元組類型 LatLong 只有兩個元素,當試圖訪問第三個元素時,就會報錯。

(6)條件類型
條件類型是一個表達式,類似于 JavaScript 中的三元表達式,其語法如下:

T extends U ? X : Y
來看一個實際的例子:

type ConditionalType = string extends boolean ? string : boolean;
在上面的示例中,ConditionalType 的類型將是 boolean,因為條件string extends boolean 是始終為 false。

2. 映射類型
(1)初體驗
在 TypeScript 中,當需要從另一種類型派生(并保持同步)另一種類型時,使用映射類型會特別有用。

// 用戶的配置值
type AppConfig = {
  username: string;
  layout: string;
};

// 用戶是否有權更改配置值
type AppPermissions = {
  changeUsername: boolean;
  changeLayout: boolean;
};
在上面的代碼中,AppConfig 和 AppPermissions 之間是存在隱式關系的,每當向 AppConfig 添加新的配置值時,AppPermissions 中也必須有相應的布爾值。

這里可以使用映射類型來管理兩者之間的關系:

type AppConfig = {
  username: string;
  layout: string;
};

type AppPermissions = {
  [Property in keyof AppConfig as `change${Capitalize<Property>}`]: boolean
};
在上面的代碼中,只要 AppConfig 中的類型發(fā)生變化,AppPermissions 就會隨之變化。實現(xiàn)了兩者之間的映射關系。

(2)概念
在 TypeScript 和 JavaScript 中,最常見的映射就是 Array.prototype.map():

[1, 2, 3].map(value => value.toString()); // ["1", "2", "3"]
這里,我們將數(shù)組中的數(shù)字映射到其字符串的表示形式。因此,TypeScript 中的映射類型意味著將一種類型轉換為另一種類型,方法就是對其每個屬性進行轉換。

(3)實例
下面來通過一個例子來深入理解一下映射類型。對設備定義以下類型,其包含制造商和價格屬性:

type Device = {
  manufacturer: string;
  price: number;
};
為了讓用戶更容易理解設備信息,因此為對象添加一個新類型,該對象可以使用適當?shù)母袷絹砀袷交O備的每個屬性:






type DeviceFormatter = {
  [Key in keyof Device as `format${Capitalize<Key>}`]: (value: Device[Key]) => string;
};
我們來拆解一下上面的代碼。Key in keyof Device 使用 keyof 類型運算符生成 Device 中所有鍵的并集。將它放在索引簽名中實際上是遍歷 Device 的所有屬性并將它們映射到 DeviceFormatter 的屬性。

format${Capitalize<Key>} 是映射的轉換部分,它使用 key 重映射和模板文字類型將屬性名稱從 x 更改為 formatX。

(value: Device[Key]) => string; 利用索引訪問類型 Device[Key] 來指示格式化函數(shù)的 value 參數(shù)是格式化的屬性的類型。因此,formatManufacturer 接受一個 string(制造商),而 formatPrice 接受一個number(價格)。

下面是 DeviceFormatter 類型的樣子:

type DeviceFormatter = {
  formatManufacturer: (value: string) => string;
  formatPrice: (value: number) => string;
};
現(xiàn)在,假設將第三個屬性 releaseYear 添加到 Device 類型中:

type Device = {
  manufacturer: string;
  price: number;
  releaseYear: number;
}
由于映射類型的強大功能,DeviceFormatter 類型會自動擴展為如下類型,無需進行任何額外的工作:

type DeviceFormatter = {
  formatManufacturer: (value: string) => string;
  formatPrice: (value: number) => string;
  formatReleaseYear: (value: number) => string;
};
3. 實用程序中的映射
TypeScript 附帶了許多用作實用程序的映射類型,最常見的包括 Omit、Partial、Readonly、Readonly、Exclude、Extract、NonNullable、ReturnType 等。下面來看看其中的兩個是如何構建的。

(1)Partial
Partial 是一種映射類型,可以將已有的類型屬性轉換為可選類型,并通過使用與 undefined 的聯(lián)合使類型可以為空。

interface Point3D {
    x: number;
    y: number;
    z: number;
}

type PartialPoint3D = Partial<Point3D>;
這里的 PartialPoint3D 類型實際是這樣的:

type PartialPoint3D = {
    x?: number;
    y?: number;
    z?: number;
}
當我們鼠標懸浮在 Partial 上時,就會看到它的定義:



把它拿出來:

type Partial<T> = { [P in keyof T]?: T[P] | undefined; }
下面來拆解一下這行代碼:

使用泛型來傳遞目標接口 T;
使用 keyof T 來獲取 T 的所有 key。
通過使用 [P in keyof T] 來訪問并循環(huán)所有的 key;
它通過添加 ? 使 key 成為可選的。
使用聯(lián)合類型 T[P] | undefined 使 key 的類型可以為空;
(2)Exclude
Exclude 是一種映射類型,可讓有選擇地從類型中刪除屬性。其定義如下:

type Exclude<T, U> = T extends U ? never : T
它通過使用條件類型從 T 中排除那些可分配給 U 的類型,并且在排除的屬性上返回 nerver。

type animals = 'bird' | 'cat' | 'crocodile';

type mamals = Exclude<animals, 'crocodile'>;  // 'bird' | 'cat'
4. 構建映射類型
通過上面的對 TypeScript 內置實用程序類型的原理解釋,對映射類型有了更深的理解。最后,我們來構建一個自己的映射類型:Optional,它可以將原類型中指定 key 的類型置為可選的并且可以為空。

我們可以這樣做:

將整個類型轉換為 Optional
從該新類型中僅選擇想要的屬性使其成為可選的。
將原始類型與排除的屬性連接起來。
實現(xiàn)代碼及測試用例如下:

type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;

type Person = {
  name: string;
  surname: string;
  email: string;
}
 
type User = Optional<Person, 'email'>;
// 現(xiàn)在 email 屬性是可選的

type AnonymousUser = Optional<Person, 'name' | 'surname'>;
// 現(xiàn)在 email 和 surname 屬性是可選的
注意,這里使用 K extends keyof T 來確保只能傳遞屬于類型/接口的屬性。否則,TypeScript 將在編譯時拋出錯誤。

映射類型的一大優(yōu)點就是它們的可組合性:可以組合它們來創(chuàng)建新的映射類型。

上面使用了已有的實用程序類型實現(xiàn)了我們想要的 Optional。當然,我們也可以在不使用任何其他映射類型的情況下重新創(chuàng)建 Optional 映射類型實用程序:

type Optional<T, K extends keyof T> =
    { [P in K]?: T[P] }
    &
    { [P in Exclude<keyof T, K>]: T[P] };
上面的代碼結合了兩種類型:

第一種類型通過使用 ? 修飾符使 T 的所有 K 的 key 都是可選的。
第二種類型通過使用 Excluse<keyof T,K>來獲取剩余的key。

作者:GUOZE


歡迎關注微信公眾號 :前端充電寶