TypeScript 2.1中的型別運算 & 一個遞迴的Readonly泛型

jokester發表於2019-02-16

去年12月的 TypeScript 2.1 中加入了 keyof / Lookup Types / Mapped Types 等 (編譯期的) 型別運算特性。
本文將介紹這些特性,並用這些特性實現一個 “遞迴的Readonly” 泛型。

新特性的介紹

keyof

keyof T 返回一個型別,這個型別是一個 string literal 的 union,內容是T中所有的屬性名 (key)。

例: keyof { a: 1, b: 2 } 得到的型別是 "a" | "b"

Lookup Types / 查詢型別

[] 的型別版。

T[K] 返回 (型別T中以K為屬性名的值) 的型別。K 必須是 keyof T 的子集,可以是一個字串字面量。

const a = { k1: 1, k2: "v2" };

// tv1 為number
type tv1 = (typeof a)["k1"];

// tv2 為string
type tv2 = (typeof a)["k2"];

// tv$ 為 (number|string): 屬性名的並集對應到了屬性值的型別的並集
type tv$ = (typeof a)["k1" | "k2"];

// 以上的括號不是必需的: typeof 優先順序更高

// 也可以用於獲取內建型別 (string 或 string[]) 上的方法的型別

// (pos: number) => string
type t_charAt = string["charAt"];  

// (...items: string[]) => number
type t_push = string[]["push"];

Mapped Types / 對映型別

我們可以在型別定義中引用其他型別的 (部分或全部) 屬性,並對其進行運算,用運算結果定義出新的型別 (Mapped Type)。即”把舊型別的屬性 map (對映) 成新型別的屬性”,可以比作 list comprehension (把舊 list 的成員 map 成新 list 的成員) 的型別屬性版。

引用哪些屬性同樣是通過一個 string literal 的 union 來定義的。這個union必須是 keyof 舊型別 的子集,可以是一個或多個 string literal,也可以是keyof的返回值 (即對映全部屬性)。

interface A {
    k1: string;
    k2: string;
    k3: number;
}

// 從A中取一部分屬性,型別不變 (A[P] 是上面講的查詢型別)
// 結果: type A_var1 = { k1: string, k3: number }
type A_var1 = {
    [P in "k1" | "k3"]: A[P];
}

// 從A中取所有屬性, 型別改為number
// 結果: type A_var1 = { k1: number, k2: number, k3: number }
// **注意** keyof / Mapped type / 泛型一起使用時有一些特殊規則。建議讀一下最後一部分 "DeepReadonly 是怎樣展開的"
type A_var2 = {
    [P in keyof A]: number;
}

// 從A中取所有屬性, 型別改為相應的Promise (TS 2.1 release note中的Deferred是這個的泛型版)
type A_var3 = {
    [P in keyof A]: Promise<A[P]>;
}

新特性的例子: Readonly

使用上面介紹的新特性可以定義出一些可用作 型別的 decorator 的泛型,比如下面的 Readonly (已經在TS2.1標準庫中):

/**
 * Make all properties in T readonly
 */
type Readonly<T> = {
    readonly [P in keyof T]: T[P];
};

interface A {
    k1: string;
    k2: string;
    k3: number;
}

/**
 型別運算的結果為
type A_ro = {
    readonly k1: string;
    readonly k2: string;
    readonly k3: number;
}
 */
type A_ro = Readonly<A>;

利用這些型別運算,我們可以表達出更復雜的編譯期約束,十分適合 (需要和無限的型別一起工作的) 的程式碼或庫。比如 Release note 中還提到的Partial / Pick / Record 等型別。

Readonly的強化版: DeepReadonly

前面提到的 Readonly 只限制屬性只讀,不會把屬性的屬性也變成只讀:

const v = { k1: 1, k2: { k21: 2 } };

const v_ro = v as Readonly<typeof v>;

// 屬性: 不可賦值
v_ro.k1 = 2; 
// 屬性的屬性: 可以賦值
v_ro.k2.k21 = 3;

我們可以寫一個DeepReadonly,實現遞迴的只讀:

type DeepReadonly<T> = {
    readonly [P in keyof T]: DeepReadonly<T[P]>;
};

const v_deep_ro = v as any as DeepReadonly<typeof v>;
// 屬性: 不可賦值
v_deep_ro.k1 = 2;
// 屬性的屬性: 也不可賦值
v_deep_ro.k2.k21 = 3;

DeepReadonly 是怎樣展開的

(這個話題是 @vilicvane 幫我審稿時提到的。我又翻了一下 相關的 issue 後覺得滿有意思… 就一起加進來了。不讀這個在大多數情況下應該不影響使用。)

背景: 如果 A 是泛型的型別引數 (比如 T<A>),則稱形如 { [P in keyof A]: (型別表示式) } 的對映型別為 A 的 同構 (isomorphic) 型別。這樣的型別含有和 A 相同的屬性名,即相同的”形狀”。在展開 T<A> 時有如下的附加規則:

  1. 基本型別 (string | number | boolean | undefined | null) 的同構型別強行定義為其本身,即跳過了對值型別的運算

  2. union 型別 (如 type A = A1 | A2) 的同構型別 T<A> 展開為 T<A1> | T<A2>

所以上面的 DeepReadonly<typeof v>的 (概念上) 展開過程是這樣的 :

type T_DeepRO = DeepReadonly<{ k1: number; k2: { k21: number } }>

type T_DeepRO = {
    readonly k1: number;
    readonly k2: DeepReadonly<{ k21: number }>;
}

type T_DeepRO = {
    readonly k1: number;
    readonly k2: {
        readonly k21: DeepReadonly<number>;
    }
}

↓ (規則1)

type T_DeepRO = {
    readonly k1: number;
    readonly k2: {
        readonly k21: number;
    }
}

(規則1有時會導致一些不直觀的結果,不過大多數情況下我們不是想要基本型別的同構型別,到此停止展開可以接受)

相關文章