TS泛型進階

沐華 發表於 2022-11-27

拿下泛型,TS 還有什麼難的嗎?

大家好,我是沐華,本文將剖析 TS 開發中常見工具型別的原始碼實現及使用方式,並且搭配與內容結合的練習,方便大家更好的理解和掌握。本文目標:

  • 更加深入的理解和掌握泛型
  • 更加熟練這些內建工具型別在專案中的運用

Exclude

Exclude<T, U>:作用簡單說就是把 T 裡面的 U 去掉,再返回 T 裡還剩下的。TU 必須是同種型別(具體型別/字面量型別)。如下

type T1 = Exclude<string | number, string>;
// type T1  = number; 

// 上面這個肯定一看就懂,那下面這樣呢

type T2 = Exclude<'a' | 'b' | 'c', 'b' | 'd'>;
// type T2  = 'a' | 'c';

怎麼就剩個 a | c 了?這怎麼執行的?

先看一張圖

TS泛型進階

三元表示式大家都知道,不是返回 a 就是返回 b,這麼算的話,這個 some 的型別應該是 b 才對呀,可這個結果是 a | b 又是怎麼回事呢,這都是由於 TS 中的拆分或者說叫分發機制導致的

簡單說就是聯合型別並且是裸型別就會產生分發,分發就會把聯合型別中的每一個型別單獨拿去判斷,最後返回結果組成的聯合型別a | b 就是這麼來的,這個特性在本文後面會提到多次所以鋪墊一下,這也是為什麼反 Exclude 放在開頭的原因

結合 Exclude 的實現和例子來理解下

// 原始碼定義
type Exclude<T, U> = T extends U ? never : T;

// 例子
type T2 = Exclude<'a' | 'b' | 'c', 'b' | 'd'>;
// type T2  = 'a' | 'c';

上面例子中的執行邏輯:

  • 由於分發會把聯合型別中的每一個型別單獨拿去判斷的原因,會先把 T ,也就是前面 a | b | c 給拆分再單獨放入 T extends U ? never : T 判斷
  • 第一次判斷 a(T就是a) U 就是 b | dT 並沒有繼承自 U,判斷為假,返回 T 也就是 a
  • 第二次判斷放入 b 判斷為真,返回 neverts 中的 never 我們知道就是不存在值的意思,連 undefined 都沒有,所以 never 會被忽略,不會產生任何效果
  • 第三次判斷放入 c,判斷為假,和 a 同理
  • 最後將每一個單獨判斷的結果組成聯合型別返回never 會忽略,所以就剩下 a | c
總之就是:如果 T extends U 滿足分發的條件,就會把所有單個型別依次放入判斷,最後返回記錄的結果組合的聯合型別

Extract

Extract<T, U>:作用是取出 T 裡面的 U ,返回。作用和 Exclude 剛好相反,傳參也是一樣的

看例子理解 Extract

type T1 = Extract<'a' | 'b' | 'c', 'a' | 'd'>;
// type T1  = 'a';

// 原始碼定義
type Extract<T, U> = T extends U ? T : never

Exclude 原始碼對比也只是三元表示式返回的 never : T 對調了一下,執行原理也是一樣一樣兒的,就不重複了

Omit

Omit<T, K>:作用是把 T(物件型別) 裡邊的 K 去掉,返回 T 裡還剩下的

Omit 的作用和 Exclude 是一樣的,都能做型別過濾並得到新型別。

不同的是 Exclude 主要是處理聯合型別,且會觸發分發,而 Omit 主要是處理物件型別,所以自然的這倆引數也不一樣。

用法如下

// 這種場景 type 和 interface 是一樣的,後面就不重複說明了
type User = {
    name: string
    age: number
}
type T1 = Omit<User, 'age'>
// type T1 = { name: string }

原始碼定義

// keyof any 就是 string | number | symbol
type Omit<T, K extends keyof any> = { [P in Exclude<keyof T, K>]: T[P]; }
  • 首先第一個引數 T 要傳物件型別, typeinterface 都可以
  • 第二個引數 K 限制了型別只能是 string | number | symbol,這一點跟 js 裡的物件是一個意思,物件型別的屬性名只支援這三種型別
  • in 是對映型別,用來對映遍歷列舉型別。大白話就是迴圈、迴圈語法,需要配合聯合型別來對型別進行遍歷。in 的右邊是可遍歷的列舉型別,左邊是遍歷出來的每一項
  • Exclude 去除掉傳入的屬性後,再遍歷剩下的屬性,生成新的型別返回

示例解析:

type User = {
    name: string
    age: number
    gender: string
}
type Omit<T, K extends keyof any> = { [P in Exclude<keyof T, K>]: T[P]; }
type T1 = Omit<User, 'age'>
// type T1 = { name: string, gender: string }

我們呼叫 Omit 傳入的引數是正確的,所以就分析一下後面的執行邏輯:

  • Exclude<keyof T, K> 等於 Exclude<'name'|'age'|'gender', 'age'>,返回的結果就是 'name'|'gender
  • 然後遍歷 'name'|'gender',第一次迴圈 P 就是 name,返回 T[P] 就是 User['name']
  • 第二次迴圈 P 就是 gender,返回 T[P] 就是 User['gender'],然後迴圈結束
  • 結果就是 { name: string, gender: string }

Pick

Pick<T, K> :作用是取出 T(物件型別) 裡邊兒的 K,返回。

好像和 Omit 剛好相反,Omit 是不要 KPick 是隻要 K

傳參方式和 Omit 是一樣的,就不贅述了,用法示例:

type User = {
    name: string
    age: number
    gender: string
}
type T1 = Pick<User, 'name' | 'gender'>
// type T1 = { name: string, gender: string }

原始碼定義

type Pick<T, K extends keyof T> = { [P in K]: T[P]; }
  • 可以看到等號左邊做了泛型約束,限制了第二個引數 K 必須是第一個引數 T 裡的屬性。
  • 如果第二個引數傳入聯合型別,會觸發分發,以此來確保準確性,聯合型別中的每一個單獨型別都必須是第一個物件型別中的屬性(不限制的話右邊就要出錯了)
  • 引數都正確之後,等號右邊的邏輯其實就是和 Omit 一模一樣的了,直接遍歷 K,取出返回就完事兒了

練習一

請利用本文上述內容完成:基於如下型別,實現一個去掉了 gender 的新型別,實現方法越多越好

type User = {
    name: string
    age: number
    gender: string
}

這個?

type T1 = { name: string, age: number }

???

我寫了幾個,歡迎補充:

type T1 = Omit<User, 'gender'>
type T2 = Pick<User, 'name' | 'age'>
type T3 = Pick<User, Exclude<keyof User, 'gender'>>
type T4 = { [P in 'name' | 'age'] : User[P] }
type T5 = { [P in Exclude<keyof User, 'gender'>] : User[P] }

Record

Record<K, T>:作用是自定義一個物件。K 為物件的 keykey 的型別,Tvaluevalue 的型別。

你有沒有這樣用過 ↓

const obj:any = {}

反正我有,其實用 Record 定義物件,在工作中還是很好用的,而且非常靈活,不同的物件定義上也會有一點區別,如下

空物件

// never,會限制為空物件
// any 指的是 string | number | symbol 這幾個型別都行
type T1 = Record<any, never>
let obj1:T1 = {}     // ok
// let obj1:T1 = {a:1} 這樣不行,只能是空物件

任意物件

// 任意物件,unknown 或 {} 表示物件內容不限,空物件也行
type T1 = Record<any, unknown>
// 或
type T1 = Record<any, {}>
let obj2:T1 = {}     // ok
let obj3:T1 = {a:1}  // ok

自定義物件 key

type keys = 'name' | 'age'
type T1 = Record<keys, string>
let obj1:T1 = {
    name: '沐華',
    age: '18'
    // age: 18  報錯,第二個引數 string 表示 value 值都只能是 string 型別
}

// 如果需要 value 是任意型別,下面兩個都行
type T2 = Record<keys, unknown>
type T3 = Record<keys, {}>

自定義物件 value

type keys = 'a' | 'b'
// type 或 interface 都一樣
type values<T> = {
    name?: T,
    age?: T,
    gender?: string
}

// 自定義 value 型別
type T1 = Record<keys, values<number | string>>
let obj:T1 = {
    a: { name: '沐華' },
    b: { age: 18 }
}

// 固定 value 值
type T2 = Record<keys, 111>
let obj1:T2 = {
    a: 111,
    b: 111
}

原始碼定義

type Record<K extends any, T> = { [P in K]: T; }

左邊限制了第一個引數 K 只能是 string | number | symbol 型別,可以是聯合型別,因為右邊遍歷 K 了,然後遍歷出來的每個屬性的值,直接賦值為傳入的第二個引數

Partial

Partial<T>:作用生成一個將 T(物件型別) 裡所有屬性都變成可選的之後的新型別

示例如下:

type User = {
    name: string
    age: number
}
type T1 = Partial<User>
// 簡單說 T1 和 T2 是一模一樣的
type T2 = {
    name?: string
    age?: number
}

原始碼定義

type Partial<T> = { [P in keyof T]?: T[P]; }

這下看原始碼定義的是不是特別簡單,就是迴圈傳進來的物件型別,給每個屬性加個 ? 變成可選屬生

Required

Required<T>:作用和 Partial<T> 剛好相反,Partial 是返回所有屬性都是非必填的物件型別,而 Required 則是返回所有屬性都是必填項的物件型別。引數 T 也是一個物件型別。

示例:

type User = {
    name?: string
    age?: number
}
type T1 = Required<User>
// 簡單說 T1 和 T2 是一模一樣的
type T2 = {
    name: string
    age: number
}

原始碼定義

type Required<T> = { [P in keyof T]-?: T[P]; }

Partial 的原始碼定義相比基本一樣的,只是這裡多了個減號 -,沒錯,就是減去的意思,-? 就是去掉 ?,然後就變成必填項了,這樣解釋是不是很好理解

Readonly

Readonly<T> :作用是返回一個所有屬性都是隻讀不可修改的物件型別,與 PartialRequired 是非常相似的。引數 T 也是一個物件型別。

示例:

type User = {
    name: string
    age?: number
}
type T1 = Readonly<User>
// 簡單說 T1 和 T2 是一模一樣的
type T2 = {
    readonly name: string
    readonly age?: number
}
type Readonly<T> = { readonly [P in keyof T]: T[P]; }

怎麼樣?看到這是不是越發覺得原始碼的型別定義越看越簡單了

我:那是不是說把所有隻讀型別,全都變成非只讀就只需要 -readonly 就行了?

你:是的,說得很對,就是這樣的

練習二

從上面幾個工具型別的原始碼定義中我們可以發現,都只是簡單的一層遍歷,就好像 js 中的淺複製,比如有下面這樣一個物件

type User = {
    name: string
    age: number
    children: {
        boy: number
        girl: number
    }
}

要把這樣一個物件所有屬性都改成可選屬性,用 Partial 就行不通了,它只能改變第一層,children 裡的所有屬性都改不了,所以請寫一個可以實現的型別,功能類似深複製的意思

先稍微想想再往下看答案喲

寫出來一個的話,PartialRequiredReadonly 的 “深複製” 型別是不是就都有了呢

想一下

// Partial 原始碼定義
type Partial<T> = { [P in keyof T]?: T[P]; }

// 遞迴 Partial
type DeepPartial<T> = T extends object ? { [P in keyof T]?: DeepPartial<T[P]> }:T;

外層再加了一個三元表示式,如果不是物件型別直接返回,如果是就遍歷;然後屬性值改成遞迴呼叫就可以了

// 遞迴 Required
type DeepRequired<T> = T extends object ? { [P in keyof T]-?: DeepRequired<T[P]> }:T;

// 遞迴 Readonly
type DeepReadonly<T> = T extends object ? { readonly [P in keyof T]: DeepReadonly<T[P]> }:T;

NonNullable

NonNullable<T>:作用是去掉 T 中的 nullundefinedT 為字面量/具體型別的聯合型別,如果是物件型別是沒有效果的。如下

type T1 = NonNullable<string | number | undefined>;
// type T1 = string | number

type T2 = NonNullable<string[] | null | undefined>;    
// type T2 = string[]

type T3 = {
    name: string
    age: undefined
}
type T4 = NonNullable<T3> // 物件是不行的

原始碼定義

// 4.8版本之前的版本
type NonNullable<T> = T extends null | undefined ? never : T;
// 4.8
type NonNullable<T> = T & {}

TS 4.8版本 之前的就是用一個三元表示式來過濾 null | undefined。而在 4.8 版本直接就是 T & {},這是什麼原理呢?其實是因為這個版本對 --strictNullChecks 做了增加,這主要體現還是在聯合型別和交叉型別上,為什麼這麼說?

js 中都知道萬物皆物件,原型鏈的最終點的正常物件就是 Object 了(null 算不正常的),資料型別都是在原型鏈中繼承於 Object 派生出來的。

ts 中也一樣,由於 {} 是一個空物件,所以除了 nullundefined 之外的基礎型別都可以視作繼承於 {} 派生出來的。或者說如果一個值不是 nullundefined 就等於 這個值 & {} 的結果,如下

type T1 = 'a' & {};  // 'a'
type T2 = number & {};  // number
type T3 = object & {};  // object
type T4 = { a: string } & {};  // { a: string }
type T5 = null & {};  // never
type T6 = undefined & {};  // never

如果 T & {} 中的 T 不是 null/undefined 就可以認為它肯定符合 {} 型別,就可以把 {} 從交叉型別中去掉了,如果是,則會被判為 never,而 never 是會被忽略的(上面 Exclude 原始碼定義裡有提到),所以在結果裡自然就排除掉了 nullundefined

還有如果 T & {} 中的 T 是聯合型別,是會觸發分發的,這個就不再解釋了

練習三

請實現一個能去掉物件型別中 nullundefined 的型別

// 需要把如下型別變成 { name: string }
type User = {
    name: string
    age: null,
    gender: undefined
}

// 實現如下
type ObjNonNullable<T> = { [P in keyof T as T[P] extends null | undefined ? never : P]: T[P] };

type T1 = ObjNonNullable<User>
// type T1 = { name: string }

這裡出現了一個本文第一次出現的關鍵字 as,我們知道它可以用來斷言,在 ts 4.1 版本可以在對映型別裡用 as 實現鍵名重新對映,達到過濾或者修改屬性名的目的,如果指定的型別解析為 never 時,會被忽略不會生成這個屬性

如上只能過濾物件第一層的 nullundefined

如何更進一步改成可以遞迴的呢?

type User = {
    name: string
    age: undefined,
    children: {
        boy: number
        girl: number
        neutral: null
    }
}
// 遞迴處理物件型別的 DeepNonNullable
type DeepNonNullable<T> = T extends object ? { [P in keyof T as T[P] extends null | undefined ? never : P]: DeepNonNullable<T[P]> } : T;

type T1 = DeepNonNullable<User>
// type T1 = {
//    name: string;
//    children: {
//        boy: number;
//        girl: number;
//    };
//}

Awaited

Awaited<T>:作用是獲取 async/await 函式或 promisethen() 方法的返回值的型別。而且自帶遞迴效果,如果是這樣巢狀的非同步方法,也能拿到最終的返回值型別

示例:

// Promise
type T1 = Awaited<Promise<string>>;
// type T1 = string

// 巢狀 Promise,會遞迴
type T2 = Awaited<Promise<Promise<number>>>;
// type T2 = number

// 聯合型別,會觸發分發
type T3 = Awaited<boolean | Promise<number>>;
// type T3 = number | boolean

來看下原始碼定義,看下到底是怎麼執行的,是怎麼拿到結果的呢?

// 原始碼定義
type Awaited<T> = T extends null | undefined
    ? T
    : T extends object & { then(onfulfilled: infer F): any }
        ? F extends (value: infer V, ...args: any) => any
            ? Awaited<V>
            : never
        : T

泛型條件有點多,就換了下行,方便看

  • 如果 Tnullundefined 就直接返回 T
  • 如果 T 是物件型別,並且裡面有 then 方法,就用 infer 型別推斷出 then 方法的第一個引數onfulfilled 的型別賦值給 Fonfulfilled 其實就是我們熟悉的 resolve。所以這裡可以看出或者準確的說,Awaited 拿的不是 then() 的返回值型別,而是 resolve() 的返回值型別

    • 既然 F 是回撥函式 resolve ,就推斷出該函式第一個引數型別賦值給 Vresolve 的引數自然就是返回值

      • 傳入 V 遞迴呼叫
    • F 不是函式就返回 never
  • 如果 T 不是物件型別 或者 是物件但沒有 then 方法,返回 T ,就是最後一行的 T

Parameters

Parameters<T>:作用是獲取函式所有引數的型別集合,返回的是元組。T 自然就是函式了

使用示例:

declare function f1(arg: { a: number; b: string }): void;

// 沒有引數的函式
type T1 = Parameters<() => string>;
// type T1 = []

// 一個引數的函式
type T2 = Parameters<(s: string) => void>;
// type T2 = [s: string]

// 泛型引數的函式
type T3 = Parameters<<T>(arg: T) => T>;
// type T3 = [arg: unknown]

// typeof f1 結果為 (arg: { a: number; b: string }) => void
type T4 = Parameters<typeof f1>;
// type T4 = [arg: {
//     a: number;
//     b: string;
// }]

// any 和 never
type T5 = Parameters<any>;
// type T5 = unknown[]
type T6 = Parameters<never>;
// type T6 = never

// 下面這樣傳參是會報錯的
type T7 = Parameters<string>;
type T8 = Parameters<Function>;
// 原始碼定義
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never

可以看到限制了函式型別,然後 ...args 取引數和 js 中的用法是一樣的,infer 表示待推斷的型別變數,打斷出 ...args 取到的型別賦值給 P

ReturnType

ReturnType<T>:作用是獲取函式返回值的型別。T 為函式

示例:

declare function f1(): { a: number; b: string };

type T1 = ReturnType<() => string>;
// type T1 = string

type T2 = ReturnType<(s: string) => void>;
// type T2 = void

type T3 = ReturnType<<T>() => T>;
// type T3 = unknown

type T4 = ReturnType<<T extends U, U extends number[]>() => T>;
// type T4 = number[]

type T5 = ReturnType<typeof f1>;
// type T5 = {
//     a: number;
//     b: string;
// }

// any 和 never
type T6 = ReturnType<any>;
// type T6 = any
type T7 = ReturnType<never>;
// type T7 = never

// 下面這樣是不行的
type T8 = ReturnType<string>;
type T9 = ReturnType<Function>;
// 原始碼定義
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any

可以看到原始碼定義上和 Parameters 是基本一樣的,只是把型別推斷的引數換成返回值了

ConstructorParameters/InstanceType

我們知道 ParametersReturnType 這一對是獲取普通/箭頭函式的引數型別集合以及返回值型別的了,還有一對組合ConstructorParametersInstanceType 是獲取建構函式的引數型別集合以及返回值型別的,和上面的比較類似我就放到一起了

Uppercase/Lowercase

這倆兒的作用是轉換全部字母大小寫

type T1 = Uppercase<"abcd">
// type T1 = "ABCD"

type T2 = Lowercase<"ABCD">
// type T2 = "abcd"

Capitalize/Uncapitalize

這倆兒的作用是轉換首字母大小寫

type T1 = Capitalize<"abcd efg">
// type T1 = "Abcd efg"

type T2 = Uncapitalize<"ABCD EFG">
// type T2 = "aBCD EFG"

練習四

請實現一個型別,把物件型別中的屬性名換成大寫,需要注意的是物件屬性名支援 string | number | symbol 三種型別

type User1 = {
    name: string
    age: number
    18: number
}

// 實現如下,只需呼叫現在的工具型別 Uppercase 就行了

// 先取出所有字串屬性的出來,再處理返回 { NAME: string, AGE: number }
// type T1<T> = { [P in keyof T & string as Uppercase<P>]: T[P] }
// 只處理字串屬性的,其他正常返回
type T1<T> = { [P in keyof T as P extends string ? Uppercase<P> : P]: T[P] }

type T2 = T1<User1>
// type T2 = {
//     NAME: string;
//     AGE: number;
//     18: number
// }

綜合練習

請實現一個型別,可以把下劃線屬性名的物件,換成駝峰屬性名的物件。這個就沒有現成的工具型別呼叫了,所以需要我們額外實現一個

這個練習用到了本文中的很多知識,先自己寫一下咯

type User1 = {
    my_name: string
    my_age_type: number // 多個下劃線
    my_children: {
        my_boy: number
        my_girl: number
    }
}

// 實現如下
type T1<T> = T extends string
    ? T extends `${infer A}_${infer B}`
        ? `${A}${T1<Capitalize<B>>}` // 這裡有遞迴處理單個屬性名多個下劃線
        : T
    : T;
// 物件不遞迴
// type T2<T> = { [P in keyof T as T1<P>]: T[P] }
// 物件遞迴
type T2<T> = T extends object ? { [P in keyof T as T1<P>]: T2<T[P]> } : T

type T3 = T2<User1>
// type T3 = {
//     myName: string;
//     myAgeType: number;
//     myChildren: {
//         myBoy: number;
//         myGirl: number;
//     };
// }

這個練習用到了 extendsinferas迴圈遞迴,相信能更好地幫助我們理解和運用

結語

如果本文對你有一點點幫助,點個贊支援一下吧,你的每一個【贊】都是我創作的最大動力 ^_^

更多前端文章,或者加入前端交流群,歡迎關注公眾號【前端快樂多】,大家一起共同交流和進步呀

參考資料

https://www.typescriptlang.or...