TypeScript 之 Conditional Types

冴羽發表於2021-12-02

前言

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

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

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

條件型別(Conditional Types)

很多時候,我們需要基於輸入的值來決定輸出的值,同樣我們也需要基於輸入的值的型別來決定輸出的值的型別。條件型別(Conditional types)就是用來幫助我們描述輸入型別和輸出型別之間的關係。

interface Animal {
  live(): void;
}

interface Dog extends Animal {
  woof(): void;
}
 
type Example1 = Dog extends Animal ? number : string;     
// type Example1 = number
 
type Example2 = RegExp extends Animal ? number : string;     
// type Example2 = string

條件型別的寫法有點類似於 JavaScript 中的條件表示式(condition ? trueExpression : falseExpression ):

SomeType extends OtherType ? TrueType : FalseType;

單從這個例子,可能看不出條件型別有什麼用,但當搭配泛型使用的時候就很有用了,讓我們拿下面的 createLabel 函式為例:

interface IdLabel {
  id: number /* some fields */;
}
interface NameLabel {
  name: string /* other fields */;
}
 
function createLabel(id: number): IdLabel;
function createLabel(name: string): NameLabel;
function createLabel(nameOrId: string | number): IdLabel | NameLabel;
function createLabel(nameOrId: string | number): IdLabel | NameLabel {
  throw "unimplemented";
}

這裡使用了函式過載,描述了 createLabel 是如何基於輸入值的型別不同而做出不同的決策,返回不同的型別。注意這樣一些事情:

  1. 如果一個庫不得不在一遍又一遍的遍歷 API 後做出相同的選擇,它會變得非常笨重。
  2. 我們不得不建立三個過載,一個是為了處理明確知道的型別,我們為每一種型別都寫了一個過載(這裡一個是 string,一個是 number),一個則是為了通用情況 (接收一個 string | number)。而如果增加一種新的型別,過載的數量將呈指數增加。

其實我們完全可以用把邏輯寫在條件型別中:

type NameOrId<T extends number | string> = T extends number
  ? IdLabel
  : NameLabel;

使用這個條件型別,我們可以簡化掉函式過載:

function createLabel<T extends number | string>(idOrName: T): NameOrId<T> {
  throw "unimplemented";
}
 
let a = createLabel("typescript");
// let a: NameLabel
 
let b = createLabel(2.8);
// let b: IdLabel
 
let c = createLabel(Math.random() ? "hello" : 42);
// let c: NameLabel | IdLabel

條件型別約束 (Conditional Type Constraints)

通常,使用條件型別會為我們提供一些新的資訊。正如使用 型別保護(type guards) 可以 收窄型別(narrowing) 為我們提供一個更加具體的型別,條件型別的 true 分支也會進一步約束泛型,舉個例子:

type MessageOf<T> = T["message"];
// Type '"message"' cannot be used to index type 'T'.

TypeScript 報錯是因為 T 不知道有一個名為 message 的屬性。我們可以約束 T,這樣 TypeScript 就不會再報錯:

type MessageOf<T extends { message: unknown }> = T["message"];
 
interface Email {
  message: string;
}
 
type EmailMessageContents = MessageOf<Email>;
// type EmailMessageContents = string

但是,如果我們想要 MessgeOf 可以傳入任何型別,但是當傳入的值沒有 message 屬性的時候,則返回預設型別比如 never 呢?

我們可以把約束移出來,然後使用一個條件型別:

type MessageOf<T> = T extends { message: unknown } ? T["message"] : never;
 
interface Email {
  message: string;
}
 
interface Dog {
  bark(): void;
}
 
type EmailMessageContents = MessageOf<Email>;           
// type EmailMessageContents = string
 
type DogMessageContents = MessageOf<Dog>;          
// type DogMessageContents = never

true 分支裡,TypeScript 會知道 T 有一個 message屬性。

再舉一個例子,我們寫一個 Flatten 型別,用於獲取陣列元素的型別,當傳入的不是陣列,則直接返回傳入的型別:

type Flatten<T> = T extends any[] ? T[number] : T;
 
// Extracts out the element type.
type Str = Flatten<string[]>;  
// type Str = string
 
// Leaves the type alone.
type Num = Flatten<number>;  
// type Num = number

注意這裡使用了索引訪問型別 裡的 number 索引,用於獲取陣列元素的型別。

在條件型別裡推斷(Inferring Within Conditional Types)

條件型別提供了 infer 關鍵詞,可以從正在比較的型別中推斷型別,然後在 true 分支裡引用該推斷結果。藉助 infer,我們修改下 Flatten 的實現,不再借助索引訪問型別“手動”的獲取出來:

type Flatten<Type> = Type extends Array<infer Item> ? Item : Type;

這裡我們使用了 infer 關鍵字宣告瞭一個新的型別變數 Item ,而不是像之前在 true 分支裡明確的寫出如何獲取 T 的元素型別,這可以解放我們,讓我們不用再苦心思考如何從我們感興趣的型別結構中挖出需要的型別結構。

我們也可以使用 infer 關鍵字寫一些有用的 型別幫助別名(helper type aliases)。舉個例子,我們可以獲取一個函式返回的型別:

type GetReturnType<Type> = Type extends (...args: never[]) => infer Return
  ? Return
  : never;
 
type Num = GetReturnType<() => number>;
// type Num = number
 
type Str = GetReturnType<(x: string) => string>;
// type Str = string
 
type Bools = GetReturnType<(a: boolean, b: boolean) => boolean[]>;   
// type Bools = boolean[]

當從多重呼叫簽名(就比如過載函式)中推斷型別的時候,會按照最後的簽名進行推斷,因為一般這個簽名是用來處理所有情況的簽名。

declare function stringOrNum(x: string): number;
declare function stringOrNum(x: number): string;
declare function stringOrNum(x: string | number): string | number;
 
type T1 = ReturnType<typeof stringOrNum>;                     
// type T1 = string | number

分發條件型別(Distributive Conditional Types)

當在泛型中使用條件型別的時候,如果傳入一個聯合型別,就會變成 分發的(distributive),舉個例子:

type ToArray<Type> = Type extends any ? Type[] : never;

如果我們在 ToArray 傳入一個聯合型別,這個條件型別會被應用到聯合型別的每個成員:

type ToArray<Type> = Type extends any ? Type[] : never;
 
type StrArrOrNumArr = ToArray<string | number>;        
// type StrArrOrNumArr = string[] | number[]

讓我們分析下 StrArrOrNumArr 裡發生了什麼,這是我們傳入的型別:

string | number;

接下來遍歷聯合型別裡的成員,相當於:

ToArray<string> | ToArray<number>;

所以最後的結果是:

string[] | number[];

通常這是我們期望的行為,如果你要避免這種行為,你可以用方括號包裹 extends 關鍵字的每一部分。

type ToArrayNonDist<Type> = [Type] extends [any] ? Type[] : never;
 
// 'StrArrOrNumArr' is no longer a union.
type StrArrOrNumArr = ToArrayNonDist<string | number>;
// type StrArrOrNumArr = (string | number)[]

TypeScript 系列

  1. TypeScript 之 Narrowing
  2. TypeScript 之 More on Functions
  3. TypeScript 之 Object Type
  4. TypeScript 之 Generics
  5. TypeScript 之 Keyof Type Operator
  6. TypeScript 之 Typeof Type Operator
  7. TypeScript 之 Indexed Access Types

微信:「mqyqingfeng」,加我進冴羽唯一的讀者群。

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

相關文章