掌握 TypeScript 中的映射類型

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

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

1. 基本概念
在介紹映射類型之前,先來(lái)看一些前置知識(shí)。

(1)索引訪問(wèn)類型
在 TypeScript 中,我們可以通過(guò)按名稱查找屬性來(lái)訪問(wèn)它的類型:

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

type Username = AppConfig["username"];
在這個(gè)例子中,通過(guò) AppConfig 類型的索引 username 獲取到其類型 string,類似于在 JavaScript 中通過(guò)索引來(lái)獲取對(duì)象的屬性值。

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

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 運(yùn)算符一起搭配使用是使映射類型成為可能的核心之一。

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

type StringOrNumberUnion = string | number;

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

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); // ?
}
在這個(gè)例子中,因?yàn)?Animal 和 Person 都有 name 屬性,所以第 15 行的 value.name 可以正常輸出,沒(méi)有錯(cuò)誤。而第 16 行的 value.age 會(huì)編譯錯(cuò)誤,因?yàn)槿绻?value 是 Animal 類型,則 value 是沒(méi)有 age 屬性的。在第 19 行的 if 塊中,因?yàn)橹挥?value 存在 age 屬性才能進(jìn)入這個(gè)代碼塊。所以,在這個(gè) if 塊中,value 一定是 Person,TS 可以知道 value 一定是具有 age 屬性的,所以編譯正確。

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

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

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

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

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

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

例如,TypeScript 編譯器能夠?yàn)樵M的各種元素提供類型安全:

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'.
上面的代碼中會(huì)報(bào)錯(cuò),Currency 類型的參數(shù)不能分配給“number[]”類型的參數(shù),string 類型不能分配給 number 類型。

當(dāng)訪問(wèn)超出元組定義類型的索引處的元素時(shí),TypeScript 能夠進(jìn)行提示:

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 只有兩個(gè)元素,當(dāng)試圖訪問(wèn)第三個(gè)元素時(shí),就會(huì)報(bào)錯(cuò)。

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

T extends U ? X : Y
來(lái)看一個(gè)實(shí)際的例子:

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

2. 映射類型
(1)初體驗(yàn)
在 TypeScript 中,當(dāng)需要從另一種類型派生(并保持同步)另一種類型時(shí),使用映射類型會(huì)特別有用。

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

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

這里可以使用映射類型來(lái)管理兩者之間的關(guān)系:

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

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

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

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

(3)實(shí)例
下面來(lái)通過(guò)一個(gè)例子來(lái)深入理解一下映射類型。對(duì)設(shè)備定義以下類型,其包含制造商和價(jià)格屬性:

type Device = {
  manufacturer: string;
  price: number;
};
為了讓用戶更容易理解設(shè)備信息,因此為對(duì)象添加一個(gè)新類型,該對(duì)象可以使用適當(dāng)?shù)母袷絹?lái)格式化設(shè)備的每個(gè)屬性:






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

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

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

下面是 DeviceFormatter 類型的樣子:

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

type Device = {
  manufacturer: string;
  price: number;
  releaseYear: number;
}
由于映射類型的強(qiáng)大功能,DeviceFormatter 類型會(huì)自動(dòng)擴(kuò)展為如下類型,無(wú)需進(jìn)行任何額外的工作:

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

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

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

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

type PartialPoint3D = {
    x?: number;
    y?: number;
    z?: number;
}
當(dāng)我們鼠標(biāo)懸浮在 Partial 上時(shí),就會(huì)看到它的定義:



把它拿出來(lái):

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

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

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

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

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

我們可以這樣做:

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

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 來(lái)確保只能傳遞屬于類型/接口的屬性。否則,TypeScript 將在編譯時(shí)拋出錯(cuò)誤。

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

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

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

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

作者:GUOZE


歡迎關(guān)注微信公眾號(hào) :前端充電寶