TypeScript 條件型別精讀與實踐

牧云云 發表於 2021-10-04

在大多數程式中,我們必須根據輸入做出決策。TypeScript 也不例外,使用條件型別可以描述輸入型別與輸出型別之間的關係。

本文同步首發在個人部落格中,歡迎訂閱、交流。

用於條件判斷時的 extends

當 extends 用於表示條件判斷時,可以總結出以下規律

  1. 若位於 extends 兩側的型別相同,則 extends 在語義上可理解為 ===,可以參考如下例子:
type result1 = 'a' extends 'abc' ? true : false // false
type result2 = 123 extends 1 ? true : false     // false
  1. 若位於 extends 右側的型別包含位於 extends 左側的型別(即狹窄型別 extends 寬泛型別)時,結果為 true,反之為 false。可以參考如下例子:
type result3 = string extends string | number ? true : false // true
  1. 當 extends 作用於物件時,若在物件中指定的 key 越多,則其型別定義的範圍越狹窄。可以參考如下例子:
type result4 = { a: true, b: false } extends { a: true } ? true : false // true

在泛型型別中使用條件型別

考慮如下 Demo 型別定義:

type Demo<T, U> = T extends U ? never : T

結合用於條件判斷時的 extends,可知 'a' | 'b' | 'c' extends 'a' 是 false, 因此 Demo<'a' | 'b' | 'c', 'a'> 結果是 'a' | 'b' | 'c' 麼?

查閱官網,其中有提到:

When conditional types act on a generic type, they become distributive when given a union type.

即當條件型別作用於泛型型別時,聯合型別會被拆分使用。即 Demo<'a' | 'b' | 'c', 'a'> 會被拆分為 'a' extends 'a''b' extends 'a''c' extends 'a'。用虛擬碼表示類似於:

function Demo(T, U) {
  return T.map(val => {
    if (val !== U) return val
    return 'never'
  })
}

Demo(['a', 'b', 'c'], 'a') // ['never', 'b', 'c']

此外根據 never 型別的定義 —— never 型別可分配給每種型別,但是沒有型別可以分配給 never(除了 never 本身)。即 never | 'b' | 'c' 等價於 'b' | 'c'

因此 Demo<'a' | 'b' | 'c', 'a'> 的結果並不是 'a' | 'b' | 'c' 而是 'b' | 'c'

工具型別

心細的讀者可能已經發現了 Demo 型別的宣告過程其實就是 TypeScript 官方提供的工具型別中 Exclude<Type, ExcludedUnion> 的實現原理,其用於將聯合型別 ExcludedUnion 排除在 Type 型別之外。

type T = Demo<'a' | 'b' | 'c', 'a'> // T: 'b' | 'c'

基於 Demo 型別定義,進一步地還可以實現官方工具型別中的 Omit<Type, Keys>,其用於移除物件 Type
中滿足 keys 型別的屬性值。

type Omit<Type, Keys> = {
  [P in Demo<keyof Type, Keys>]: Type<P>
}

interface Todo {
  title: string;
  description: string;
  completed: boolean;
}

type T = Omit<Todo, 'description'> // T: { title: string; completed: boolean }

逃離艙

如果想讓 Demo<'a' | 'b' | 'c', 'a'> 的結果為 'a' | 'b' | 'c' 是否可以實現呢? 根據官網描述:

Typically, distributivity is the desired behavior. To avoid that behavior, you can surround each side of the extends keyword with square brackets.

如果不想遍歷泛型中的每一個型別,可以用方括號將泛型給括起來以表示使用該泛型的整體部分。

type Demo<T, U> = [T] extends [U] ? never : T

// result 此時型別為 'a' | 'b' | 'c'
type result = Demo<'a' | 'b' | 'c', 'a'>

在箭頭函式中使用條件型別

在箭頭函式中使用三元表示式時,從左向右的閱讀習慣導致函式內容區若不加括號則會讓使用方感到困惑。比如下方程式碼中 x 是函式型別還是布林型別呢?

// The intent is not clear.
var x = a => 1 ? true : false

在 eslint 規則 no-confusing-arrow 中,推薦如下寫法:

var x = a => (1 ? true : false)

在 TypeScript 的型別定義中,若在箭頭函式中使用 extends 也是同理,由於從左向右的閱讀習慣,也會導致閱讀者對型別程式碼的執行順序感到困惑。

type Curry<P extends any[], R> =
  (arg: Head<P>) => HasTail<P> extends true ? Curry<Tail<P>, R> : R

因此在箭頭函式中使用 extends 建議加上括號,對於進行 code review 有很大的幫助。

type Curry<P extends any[], R> =
  (arg: Head<P>) => (HasTail<P> extends true ? Curry<Tail<P>, R> : R)

結合型別推導使用條件型別

在 TypeScript 中,一般會結合 extends 來使用型別推導 infer 語法。使用它可以實現自動推導型別的目的。比如用其來實現工具型別 ReturnType<Type>,該工具型別用於返回函式 Type 的返回型別。

type ReturnType<T extends Function> = T extends (...args: any) => infer U ? U : never

MyReturnType<() => string>          // string
MyReturnType<() => Promise<boolean> // Promise<boolean>

結合 extends 與型別推導還可以實現與陣列相關的 Pop<T>Shift<T>Reverse<T> 工具型別。

Pop<T>:

type Pop<T extends any[]> = T extends [...infer ExceptLast, any] ? ExceptLast : never

type T = Pop<[3, 2, 1]> // T: [3, 2]

Shift<T>:

type Shift<T extends any[]> = T extends [infer _, ...infer O] ? O : never

type T = Shift<[3, 2, 1]> // T: [2, 1]

Reverse<T>

type Reverse<T> = T extends [infer F, ...infer Others]
  ? [...Reverse<Others>, F]
  : []

type T = Reverse<['a', 'b']> // T: ['b', 'a']

使用條件型別來判斷兩個型別完全相等

我們也可以使用條件型別來判斷 A、B 兩個型別是否完全相等。當前社群上主要有兩種方案:

方案一: 參考 issue

export type Equal1<T, S> =
  [T] extends [S] ? (
    [S] extends [T] ? true : false
  ) : false

目前該方案的唯一缺點是會將 any 型別與其它任何型別判為相等。

type T = Equal1<{x:any}, {x:number}> // T: true

方案二: 參考 issue

export type Equal2<X, Y> =
  (<T>() => T extends X ? 1 : 2) extends
  (<U>() => U extends Y ? 1 : 2) ? true : false

目前該方案的唯一缺點是在對交叉型別的處理上有一點瑕疵。

type T = Equal2<{x:1} & {y:2}, {x:1, y:2}> // false

以上兩種判斷型別相等的方法見仁見智,筆者在此拋磚引玉。