你所不了解的TypeScript 類型編程
點擊上方 程序員成長指北,關(guān)注公眾號
回復(fù)1,加入高級Node交流群
作者:林不渡(作者授權(quán)轉(zhuǎn)載)
https://juejin.cn/post/6885672896128090125
前言
作為前端開發(fā)的趨勢之一,TypeScript正在越來越普及,很多人像我一樣寫了TS后再也回不去了,比如寫算法題寫demo都用TS,JS只有在Webpack配置(實際上這也可以用TS寫)等少的可憐的情況下才會用到(有了ts-node后,我連爬蟲都用ts寫了)。TS的學(xué)習(xí)成本實際上并不高(的確是,具體原因我在下面會講,別急著錘我),我個人認(rèn)為它可以被分成兩個部分:
預(yù)實現(xiàn)的ES提案,如 裝飾器(我之前的一篇文章 走近MidwayJS:初識TS裝飾器與IoC機制 中講了一些關(guān)于TS裝飾器的歷史, 有興趣的可以看看), 可選鏈 ?.,空值合并運算符??,類的私有成員private等。除了部分語法如裝飾器以外,大部分的預(yù)實現(xiàn)實際上就是未來的ES語法。對于這一部分來說,無論你先前是只學(xué)習(xí)過JS(就像我一樣),還是有過Java、C#的使用經(jīng)歷,都能非常快速地上手,這也是實際開發(fā)中使用最多的部分,畢竟和另一塊-類型編程比起來,還是這一部分更接地氣。類型編程,無論是一個普通接口( interface)或是類型別名type,還是密密麻麻的extendsinfer工具類型blabla...(下文會展開介紹),我個人認(rèn)為都屬于類型編程的范疇。這一塊實際上對代碼的功能層面沒有任何影響,即使你把它寫成anyscript,代碼該咋樣還是咋樣。而這也就是類型編程一直不受到太多重視的原因:相比于語法,它會帶來代碼量大大增多(可能接近甚至超過業(yè)務(wù)代碼),編碼耗時增長(頭發(fā)--)等問題,而帶來的唯一好處就是 類型安全 , 包括如臂使指的類型提示(VS Code YES!),進一步減少可能存在的調(diào)用錯誤,以及降低維護成本。看起來似乎有得有失,但實際上,假設(shè)你花費1單位腦力使用基礎(chǔ)的TS以及簡單的類型編程,你就能夠獲得5個單位的回饋。但接下來,有可能你花費10個單位腦力,也只能再獲得2個單位的回饋。另外一個類型編程不受重視的原因則是實際業(yè)務(wù)中并不會需要多么苛刻的類型定義,通常是底層框架類庫才會有此類需求,這一點就見仁見智了,但我想沒人會想永遠(yuǎn)當(dāng)業(yè)務(wù)仔吧(沒有陰陽怪氣的意思)。
正文部分包括:
基礎(chǔ)泛型 索引類型 & 映射類型 條件類型 & 分布式條件類型 infer關(guān)鍵字 類型守衛(wèi) is in 關(guān)鍵字 內(nèi)置工具類型機能與原理 內(nèi)置工具類型增強 更多通用工具類型
這些名詞可能看著有點勸退,但我會盡可能描述的通俗易懂,讓你在閱讀時不斷發(fā)出“就這?”的感慨:)
為了適配所有基礎(chǔ)的讀者,本文會講解的盡可能細(xì)致,如果你已經(jīng)熟悉某部分知識,請?zhí)^~
泛型 Generic Type
假設(shè)我們有這么一個函數(shù):
function foo(args: unknown): unknown { ... }
如果它接收一個字符串,返回這個字符串的部分截取,如果接收一個數(shù)字,返回這個數(shù)字的n倍,如果接收一個對象,返回鍵值被更改過的對象(鍵名不變),如果這時候需要類型定義,是否要把unknown替換為string | number | object?這樣固然可以,但別忘記我們需要的是 入?yún)⑴c返回值類型相同 的效果。這個時候泛型就該登場了,泛型使得代碼段的類型定義易于重用(比如我們上面提到的場景又多了一種接收布爾值返回布爾值的場景后的修改),并提升了靈活性與嚴(yán)謹(jǐn)性:
工程層面當(dāng)然不會寫這樣的代碼了... 但就當(dāng)個例子看吧hhh
function foo<T>(arg: T): T {
return arg;
}
我們使用T來表示一個未知的類型,它是入?yún)⑴c返回值的類型,在使用時我們可以顯示指定泛型:
foo<string>("linbudu")
const [count, setCount] = useState<number>(1)
當(dāng)然也可以不指定,因為TS會自動推導(dǎo)出泛型的實際類型。
泛型在箭頭函數(shù)下的書寫:
const foo = <T>(arg: T) => arg;
復(fù)制代碼如果你在TSX文件中這么寫,
<T>可能會被識別為JSX標(biāo)簽,因此需要顯式告知編譯器:const foo = <T extends {}>(arg: T) => arg;
復(fù)制代碼
除了用在函數(shù)中,泛型也可以在類中使用:
class Foo<T, U> {
constructor(public arg1: T, public arg2: U) {}
public method(): T {
return this.arg1;
}
}
泛型除了單獨使用,也經(jīng)常與其他類型編程語法結(jié)合使用,可以說泛型就是TS類型編程最重要的基石。單獨對于泛型的介紹就到這里(因為單純的講泛型實在沒有什么好講的),在接下來我們會講解更多泛型的高級使用技巧。
索引類型與映射類型
在閱讀這一部分前,你需要做好思維轉(zhuǎn)變的準(zhǔn)備,需要認(rèn)識到 類型編程實際也是編程,因此你可以將一部分編程思路復(fù)用過來。我們實現(xiàn)一個簡單的函數(shù):
// 假設(shè)key是obj鍵名
function pickSingleValue(obj, key) {
return obj[key];
}
思考要為其進行類型定義的話,有哪些需要定義的地方?
參數(shù) obj參數(shù) key返回值
這三樣之間是否存在關(guān)聯(lián)?
key必然是obj中的鍵值名之一,一定為string類型返回的值一定是obj中的鍵值
因此我們初步得到這樣的結(jié)果:
function pickSingleValue<T>(obj: T, key: keyof T) {
return obj[key];
}
keyof 是 索引類型查詢的語法, 它會返回后面跟著的類型參數(shù)的鍵值組成的字面量類型(literal types),舉個例子:
interface foo {
a: number;
b: string;
}
type A = keyof foo; // "a" | "b"
字面量類型是對類型的進一步限制,比如你的狀態(tài)碼只可能是0/1/2,那么你就可以寫成
status: 0 | 1 | 2的形式。字面量類型包括字符串字面量、數(shù)字字面量、布爾值字面量。
還少了返回值,如果你此前沒有接觸過此類語法,應(yīng)該會卡住,我們先聯(lián)想下for...in語法,通常遍歷對象會這么寫:
const fooObj: foo = { a: 1, b: "1" };
for (const key in fooObj) {
console.log(key);
console.log(fooObj[key as keyof foo]);
}
和上面的寫法一樣,我們拿到了key,就能拿到對應(yīng)的value,那么value的類型也就不在話下了:
function pickSingleValue<T>(obj: T, key: keyof T): T[keyof T] {
return obj[key];
}
偽代碼解釋下:
interface T {
a: number;
b: string;
}
type TKeys = keyof T; // "a" | "b"
type PropAType = T['a']; // number
復(fù)制代碼你用鍵名可以取出對象上的鍵值,自然也就可以取出接口上的鍵值(也就是類型)啦~
但這種寫法很明顯有可以改進的地方:keyof出現(xiàn)了兩次,以及泛型T應(yīng)該被限制為對象類型,就像我們平時會做的那樣:用一個變量把多處出現(xiàn)的存起來,在類型編程里,泛型就是變量。
function pickSingleValue<T extends object, U extends keyof T>(obj: T, key: U): T[U] {
return obj[key];
}
這里又出現(xiàn)了新東西extends... 它是啥?你可以暫時把T extends object理解為T被限制為對象類型,U extends keyof T理解為泛型U必然是泛型T的鍵名組成的聯(lián)合類型(以字面量類型的形式)。具體的知識我們會在下一節(jié)條件類型講到。假設(shè)現(xiàn)在我們不只要取出一個值了,我們要取出一系列值:
function pick<T extends object, U extends keyof T>(obj: T, keys: U[]): T[U][] {
return keys.map((key) => obj[key]);
}
// pick(obj, ['a', 'b'])
有兩個重要變化:
keys: U[]我們知道U是T的鍵名組成的聯(lián)合類型,那么要表示一個內(nèi)部元素均是T鍵名的數(shù)組,就可以使用這種方式,具體的原理請參見下文的 分布式條件類型 章節(jié)。T[U][]它的原理實際上和上面一條相同,之所以單獨拿出來是因為我認(rèn)為它是一個很好地例子:簡單的表現(xiàn)了TS類型編程的組合性,你不感覺這種寫法就像搭積木一樣嗎?
索引簽名 Index Signature
索引簽名用于快速建立一個內(nèi)部字段類型相同的接口,如
interface Foo {
[keys: string]: string;
}
那么接口Foo就被認(rèn)定為字段全部為string類型。值得注意的是,由于JS可以同時通過數(shù)字與字符串訪問對象屬性,因此keyof Foo的結(jié)果會是string | number。
const o:Foo = {
1: "蕪湖!",
};
o[1] === o["1"];
復(fù)制代碼
但是一旦某個接口的索引簽名類型為number,那么它就不能再通過字符串索引訪問,如o['1']這樣。
映射類型 Mapped Types
映射類型同樣是類型編程的重要底層組成,通常用于在舊有類型的基礎(chǔ)上進行改造,包括接口包含字段、字段的類型、修飾符(readonly與?)等等。從一個簡單場景入手:
interface A {
a: boolean;
b: string;
c: number;
d: () => void;
}
現(xiàn)在我們有個需求,實現(xiàn)一個接口,它的字段與接口A完全相同,但是其中的類型全部為string,你會怎么做?直接重新聲明一個然后手寫嗎?我們可是聰明的程序員誒,那必不可能這么笨。如果把接口換成對象再想想,其實很簡單,new一個新對象,然后遍歷A的鍵名(Object.keys())來填充這個對象。
type StringifyA<T> = {
[K in keyof T]: string;
};
是不是很熟悉?重要的就是這個in操作符,你完全可以把它理解為就是for...in,也就是說你還可以獲取到接口鍵值類型,比如我們復(fù)制接口!
type Clone<T> = {
[K in keyof T]: T[K];
};
掌握這種思路,其實你已經(jīng)接觸到一些工具類型的底層實現(xiàn)了:
你可以把工具類型理解為你平時放在utils文件夾下的公共函數(shù),提供了對公用邏輯(在這里則是類型編程邏輯)的封裝,比如上面的兩個類型接口就是~
先寫個最常用的Partial嘗嘗鮮,工具類型的詳細(xì)介紹我們會在專門的章節(jié)展開:
// 將接口下的字段全部變?yōu)榭蛇x的
type Partial<T> = {
[K in keyof T]?: T[k];
};
是不是特別簡單,讓你已經(jīng)脫口而出“就這!”,類似的,還可以實現(xiàn)個Readonly,把接口下的字段全部變?yōu)橹蛔x的。索引類型、映射類型相關(guān)的知識我們暫且介紹到這里,要真正理解它們的作用,還需要好好梳理下,建議你看看自己之前項目的類型定義有沒有可以優(yōu)化的地方。
條件類型 Conditional Types
條件類型的語法實際上就是三元表達(dá)式:
T extends U ? X : Y
如果你覺得這里的extends不太好理解,可以暫時簡單理解為U中的屬性在T中都有。
因此條件類型理解起來更直觀,唯一需要有一定理解成本的就是 何時條件類型系統(tǒng)會收集到足夠的信息來確定類型,也就是說,條件類型有可能不會被立刻完成判斷。在了解這一點前,我們先來看看條件類型常用的一個場景:泛型約束,實際上就是我們上面的例子:
function pickSingleValue<T extends object, U extends keyof T>(obj: T, key: U): T[U] {
return obj[key];
}
這里的T extends object與U extends keyof T都是泛型約束,分別將T約束為對象類型和將U約束為T鍵名的字面量聯(lián)合類型。我們通常使用泛型約束來**“使得泛型收窄”**。以一個使用條件類型作為函數(shù)返回值類型的例子:
declare function strOrnum<T extends boolean>(
x: T
): T extends true ? string : number;
在這種情況下,條件類型的推導(dǎo)就會被延遲(deferred),因為此時類型系統(tǒng)沒有足夠的信息來完成判斷。只有給出了所需信息(在這里是x值),才可以完成推導(dǎo)。
const strReturnType = strOrNum(true);
const numReturnType = strOrNum(false);
同樣的,就像三元表達(dá)式可以嵌套,條件類型也可以嵌套,如果你看過一些框架源碼,也會發(fā)現(xiàn)其中存在著許多嵌套的條件類型,無他,條件類型可以將類型約束收攏到非常精確的范圍內(nèi)。
type TypeName<T> = T extends string
? "string"
: T extends number
? "number"
: T extends boolean
? "boolean"
: T extends undefined
? "undefined"
: T extends Function
? "function"
: "object";
分布式條件類型 Distributive Conditional Types
官方文檔對分布式條件類型的講解內(nèi)容甚至要多于條件類型,因此你也知道這玩意沒那么簡單了吧~ 分布式條件類型實際上不是一種特殊的條件類型,而是其特性之一。概括地說,就是 對于屬于裸類型參數(shù)的檢查類型,條件類型會在實例化時期自動分發(fā)到聯(lián)合類型上
原文: Conditional types in which the checked type is a naked type parameter are called distributive conditional types. Distributive conditional types are automatically distributed over union types during instantiation
先提取幾個關(guān)鍵詞,然后我們再通過例子理清這個概念:
裸類型參數(shù) 實例化 分發(fā)到聯(lián)合類型
// 使用上面的TypeName類型別名
// "string" | "function"
type T1 = TypeName<string | (() => void)>
// "string" | "object"
type T2 = TypeName<string | string[]>
// "object"
type T3 = TypeName<string[] | number[]>
我們發(fā)現(xiàn)在上面的例子里,條件類型的推導(dǎo)結(jié)果都是聯(lián)合類型(T3實際上也是,只不過相同所以被合并了),并且就是類型參數(shù)被依次進行條件判斷的結(jié)果。是不是get到了一點什么?我們再看另一個例子:
type Naked<T> = T extends boolean ? "Y" : "N";
type Wrapped<T> = [T] extends [boolean] ? "Y" : "N";
/*
* 先分發(fā)到 Naked<number> | Naked<boolean>
* 然后到 "N" | "Y"
*/
type Distributed = Naked<number | boolean>;
/*
* 不會分發(fā) 直接是 [number | boolean] extends [boolean]
* 然后是"N"
*/
type NotDistributed = Wrapped<number | boolean>;
現(xiàn)在我們可以來講講這幾個概念了:
裸類型參數(shù),沒有額外被接口/類型別名包裹過的,就像被 Wrapped包裹后就不能再被稱為裸類型參數(shù)。實例化,其實就是條件類型的判斷過程,在這里兩個例子的實例化過程實際上是不同的,具體會在下一點中介紹。 分發(fā)至聯(lián)合類型的過程: 對于TypeName,它內(nèi)部的類型參數(shù)T是沒有被包裹過的,所以 TypeName<string | (() => void)>會被分發(fā)為TypeName<string> | TypeName<(() => void)>,然后再次進行判斷,最后分發(fā)為"string" | "function"。抽象下具體過程:```typescript ( A | B | C ) extends T ? X : Y // 相當(dāng)于 (A extends T ? X : Y) | (B extends T ? X : Y) | (B extends T ? X : Y) 復(fù)制代碼
一句話概括:沒有被額外包裝的聯(lián)合類型參數(shù),在條件類型進行判定時會將聯(lián)合類型分發(fā),分別進行判斷。
infer關(guān)鍵字
infer是inference的縮寫,通常的使用方式是infer R,R表示 待推斷的類型。通常infer不會被直接使用,而是被放置在底層工具類型中,需要在條件類型中使用。看一個簡單的例子,用于獲取函數(shù)返回值類型的工具類型ReturnType:
const foo = (): string => {
return "linbudu";
};
// string
type FooReturnType = ReturnType<typeof foo>;
infer的使用思路可能不是那么好習(xí)慣,我們可以用前端開發(fā)中常見的一個例子類比,頁面初始化時先顯示占位交互,像Loading/骨架屏,在請求返回后再去渲染真實數(shù)據(jù)。infer也是這個思路,類型系統(tǒng)在獲得足夠的信息后,就能將infer后跟隨的類型參數(shù)推導(dǎo)出來,最后返回這個推導(dǎo)結(jié)果。
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;
類似的,借著這個思路我們還可以獲得函數(shù)入?yún)㈩愋汀㈩惖臉?gòu)造函數(shù)入?yún)㈩愋汀romise內(nèi)部的類型等,這些工具類型我們會在后面講到。infer其實沒有特別難消化的知識點,它需要的只是思路的轉(zhuǎn)變,你要理解 延遲推斷 的概念。
類型守衛(wèi) 與 is in關(guān)鍵字 Type Guards
前面的內(nèi)容可能不是那么符合人類直覺,需要一點時間消化,這一節(jié)我們來看點簡單(相對)且直觀的知識點:類型守衛(wèi)。假設(shè)有這么一個字段,它可能字符串也可能是數(shù)字:
numOrStrProp: number | string;
現(xiàn)在在使用時,你想將這個字段的聯(lián)合類型縮小范圍,比如精確到string,你可能會這么寫:
export const isString = (arg: unknown): boolean =>
typeof arg === "string";
看看這么寫的效果:
function useIt(numOrStr: number | string) {
if (isString(numOrStr)) {
console.log(numOrStr.length);
}
}
啊哦,看起來isString函數(shù)并沒有起到縮小類型范圍的作用,參數(shù)依然是聯(lián)合類型。這個時候就該使用is關(guān)鍵字了:
export const isString = (arg: unknown): arg is string =>
typeof arg === "string";
這個時候再去使用,就會發(fā)現(xiàn)在isString(numOrStr)為true后,numOrStr的類型就被縮小到了string。這只是以原始類型為成員的聯(lián)合類型,我們完全可以擴展到各種場景上,先看一個簡單的假值判斷:
export type Falsy = false | "" | 0 | null | undefined;
export const isFalsy = (val: unknown): val is Falsy => !val;
是不是還挺有用?這應(yīng)該是我日常用的最多的類型別名之一了。也可以在in關(guān)鍵字的加持下,進行更強力的類型判斷,思考下面這個例子,要如何將 " A | B " 的聯(lián)合類型縮小到"A"?
class A {
public a() {}
public useA() {
return "A";
}
}
class B {
public b() {}
public useB() {
return "B";
}
}
再聯(lián)想下for...in循環(huán),它遍歷對象的屬性名,而in關(guān)鍵字也是一樣:
function useIt(arg: A | B): void {
if ("a" in arg) {
arg.useA();
} else {
arg.useB();
}
}
再看一個使用字面量類型作為類型守衛(wèi)的例子:
interface IBoy {
name: "mike";
gf: string;
}
interface IGirl {
name: "sofia";
bf: string;
}
function getLover(child: IBoy | IGirl): string {
if (child.name === "mike") {
return child.gf;
} else {
return child.bf;
}
}
之前有個小哥問過一個問題,我想很多用TS寫接口的小伙伴可能都遇到過,即登錄與未登錄下的用戶信息是完全不同的接口:
interface IUserProps {
isLogin: boolean;
name: string; // 用戶名稱僅在登錄時有
from: string; // 用戶來源(一般用于埋點),僅在未登錄時有
}
這種時候使用字面量類型守衛(wèi):
function getUserInfo(user: IUnLogin | ILogined): string {
return user.isLogin ? user.id : user.from;
}
還可以使用instanceof來進行實例的類型守衛(wèi),建議聰明的你動手嘗試下~
工具類型Tool Type
這一章是本文的最后一部分,應(yīng)該也是本文“性價比”最高的一部分了,因為即使你還是不太懂這些工具類型的底層實現(xiàn),也不影響你把它用好。就像Lodash不會要求你每用一個函數(shù)就熟知原理一樣。這一部分包括TS內(nèi)置工具類型與社區(qū)的擴展工具類型,我個人推薦在完成學(xué)習(xí)后記錄你覺得比較有價值的工具類型,并在自己的項目里新建一個.d.ts文件存儲它。
在繼續(xù)閱讀前,請確保你掌握了上面的知識,它們是類型編程的基礎(chǔ)
內(nèi)置工具類型
在上面我們已經(jīng)實現(xiàn)了內(nèi)置工具類型中被使用最多的一個:
type Partial<T> = {
[K in keyof T]?: T[k];
};
它用于將一個接口中的字段變?yōu)槿靠蛇x,除了映射類型以外,它只使用了?可選修飾符,那么我現(xiàn)在直接掏出小抄(好家伙):
去除可選修飾符: -?只讀修飾符: readonly去除只讀修飾符: -readonly
恭喜,你得到了Required和Readonly(去除readonly修飾符的工具類型不屬于內(nèi)置的,我們會在后面看到):
type Required<T> = {
[K in keyof T]-?: T[K];
};
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
在上面我們實現(xiàn)了一個pick函數(shù):
function pick<T extends object, U extends keyof T>(obj: T, keys: U[]): T[U][] {
return keys.map((key) => obj[key]);
}
照著這種思路,假設(shè)我們現(xiàn)在需要從一個接口中挑選一些字段:
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
// 期望用法
type Part = Pick<A, "a" | "b">
還是映射類型,只不過現(xiàn)在映射類型的映射源是類型參數(shù)K。既然有了Pick,那么自然要有Omit,它和Pick的寫法非常像,但有一個問題要解決:我們要怎么表示T中剔除了K后的剩余字段?
Pick選取傳入的鍵值,Omit移除傳入的鍵值
這里我們又要引入一個知識點:never類型,它表示永遠(yuǎn)不會出現(xiàn)的類型,通常被用來將收窄聯(lián)合類型或是接口,詳細(xì)可以看 [尤大的知乎回答](<https://www.zhihu.com/search?type=content&q=ts never>), 在這里 我們不做展開介紹。上面的場景其實可以簡化為:
// "3" | "4" | "5"
type LeftFields = Exclude<"1" | "2" | "3" | "4" | "5", "1" | "2">;
可以用排列組合的思路考慮:"1"在"1" | "2"里面嗎("1" extends "1"|"2" -> true)?在啊, 那讓它爬,"3"在嗎?不在那就讓它留下來。這里實際上使用到了分布式條件類型的特性,假設(shè)Exclude接收T U兩個類型參數(shù),T聯(lián)合類型中的類型會依次與U類型進行判斷,如果這個類型參數(shù)在U中,就剔除掉它(賦值為never)
type Exclude<T, U> = T extends U ? never : T;
那么Omit:
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
劇透下,幾乎所有使用條件類型的場景,把判斷后的賦值語句反一下,就會有新的場景,比如Exclude移除掉鍵名,那反一下就是保留鍵名:
type Extract<T, U> = T extends U ? T : never;
再來看個常用的工具類型Record<Keys, Type>,通常用于生成以聯(lián)合類型為鍵名(Keys),鍵值類型為Type的新接口,比如:
type MyNav = "a" | "b" | "b";
interface INavWidgets {
widgets: string[];
title?: string;
keepAlive?: boolean;
}
const router: Record<MyNav, INavWidgets> = {
a: { widget: [''] },
b: { widget: [''] },
c: { widget: [''] },
}
其實很簡單,把Keys的每個鍵值拿出來,類型規(guī)定為Type即可
// K extends keyof any 約束K必須為聯(lián)合類型
type Record<K extends keyof any, T> = {
[P in K]: T;
};
在前面的infer一節(jié)中我們實現(xiàn)了用于獲取函數(shù)返回值的ReturnType:
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
其實把infer換個位置,比如放到返回值處,它就變成了獲取參數(shù)類型的Parameters:
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;
如果再大膽一點,把普通函數(shù)換成類的構(gòu)造函數(shù),那么就得到了獲取構(gòu)造函數(shù)入?yún)㈩愋偷?code style="margin-right: 2px;margin-left: 2px;padding: 2px 4px;font-size: 14px;border-radius: 4px;background-color: rgba(27, 31, 35, 0.047);font-family: "Operator Mono", Consolas, Monaco, Menlo, monospace;word-break: break-all;color: rgb(60, 112, 198);">ConstructorParameters:
type ConstructorParameters<T extends new (...args: any) => any> = T extends new (...args: infer P) => any ? P : never;
加上
new關(guān)鍵字來使其成為可實例化類型聲明
把待infer的類型放到其返回處,想想new一個類會得到什么?實例!所以我們得到了實例類型InstanceType:
type InstanceType<T extends new (...args: any) => any> = T extends new (...args: any) => infer R ? R : any;
這幾個例子看下來,你應(yīng)該已經(jīng)get到了那么一絲天機,類型編程的確沒有特別高深晦澀的語法,它考驗的是你對其中基礎(chǔ)部分如索引、映射、條件類型的掌握程度,以及舉一反三的能力。下面我們要學(xué)習(xí)的社區(qū)工具類型,本質(zhì)上還是各種基礎(chǔ)類型的組合,只是從常見場景下出發(fā),補充了官方?jīng)]有覆蓋到的部分。
社區(qū)工具類型
這一部分的工具類型大多來自于utility-types,其作者同時還有react-redux-typescript-guide 和 typesafe-actions這兩個優(yōu)秀作品。
我們由淺入深,先封裝基礎(chǔ)的類型別名和對應(yīng)的類型守衛(wèi),不對原理做講述:
export type Primitive =
| string
| number
| bigint
| boolean
| symbol
| null
| undefined;
export const isPrimitive = (val: unknown): val is Primitive => {
if (val === null || val === undefined) {
return true;
}
const typeDef = typeof val;
const primitiveNonNullishTypes = [
"string",
"number",
"bigint",
"boolean",
"symbol",
];
return primitiveNonNullishTypes.indexOf(typeDef) !== -1;
};
export type Nullish = null | undefined;
export type NonUndefined<A> = A extends undefined ? never : A;
// 實際上是TS內(nèi)置的
type NonNullable<T> = T extends null | undefined ? never : T;
Falsy和isFalsy我們已經(jīng)在上面體現(xiàn)了~
趁著對infer的記憶來熱乎,我們再來看一個常用的場景,提取Promise的實際類型:
const foo = (): Promise<string> => {
return new Promise((resolve, reject) => {
resolve("linbudu");
});
};
// Promise<string>
type FooReturnType = ReturnType<typeof foo>;
// string
type NakedFooReturnType = PromiseType<FooReturnType>;
如果你已經(jīng)熟練掌握了infer的使用,那么實際上是很好寫的,只需要用一個infer參數(shù)作為Promise的泛型即可:
export type PromiseType<T extends Promise<any>> = T extends Promise<infer U>
? U
: never;
使用infer R來等待類型系統(tǒng)推導(dǎo)出R的具體類型。
遞歸的工具類型
前面我們寫了個Partial Readonly Required等幾個對接口字段進行修飾的工具類型,但實際上都有局限性,如果接口中存在著嵌套呢?
type Partial<T> = {
[P in keyof T]?: T[P];
};
理一下邏輯:
如果不是對象類型,就只是加上 ?修飾符如果是對象類型,那就遍歷這個對象內(nèi)部 重復(fù)上述流程。
是否是對象類型的判斷我們見過很多次了, T extends object即可,那么如何遍歷對象內(nèi)部?實際上就是遞歸。
export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
utility-types內(nèi)部的實現(xiàn)實際比這個復(fù)雜,還考慮了數(shù)組的情況,這里為了便于理解做了簡化,后面的工具類型也同樣存在此類簡化。
那么DeepReadobly DeepRequired也就很簡單了:
export type DeepMutable<T> = {
-readonly [P in keyof T]: T[P] extends object ? DeepMutable<T[P]> : T[P];
};
// 即DeepReadonly
export type DeepImmutable<T> = {
-readonly [P in keyof T]: T[P] extends object ? DeepMutable<T[P]> : T[P];
};
export type DeepNonNullable<T> = {
[P in keyof T]: T[P] extends object ? DeepImmutable<T[P]> : NonNullable<T[P]>;
};
返回鍵名的工具類型
在有些場景下我們需要一個工具類型,它返回接口字段鍵名組成的聯(lián)合類型,然后用這個聯(lián)合類型進行進一步操作(比如給Pick或者Omit這種使用),一般鍵名會符合特定條件,比如:
可選/必選/只讀/非只讀的字段 (非)對象/(非)函數(shù)/類型的字段
來看個最簡單的函數(shù)類型字段FunctionTypeKeys:
export type FunctTypeKeys<T extends object> = {
[K in keyof T]-?: T[K] extends Function ? K : never;
}[keyof T];
{ [K in keyof T]: ... }[keyof T]這個寫法可能有點詭異,拆開來看:
interface IWithFuncKeys {
a: string;
b: number;
c: boolean;
d: () => void;
}
type WTFIsThis<T extends object> = {
[K in keyof T]-?: T[K] extends Function ? K : never;
};
type UseIt1 = WTFIsThis<IWithFuncKeys>;
很容易推導(dǎo)出UseIt1實際上就是:
type UseIt1 = {
a: never;
b: never;
c: never;
d: "d";
}
UseIt會保留所有字段,滿足條件的字段其鍵值為字面量類型(值為鍵名)
加上后面一部分:
// "d"
type UseIt2 = UseIt1[keyof UseIt1]
這個過程類似排列組合:never類型的值不會出現(xiàn)在聯(lián)合類型中
// string | number
type WithNever = string | never | number;
復(fù)制代碼
所以{ [K in keyof T]: ... }[keyof T]這個寫法實際上就是為了返回鍵名(準(zhǔn)備的說是鍵名組成的聯(lián)合類型)。那么非函數(shù)類型字段也很簡單了,這里就不做展示了,下面來看可選字段OptionalKeys與必選字段RequiredKeys,先來看個小例子:
type WTFAMI1 = {} extends { prop: number } ? "Y" : "N";
type WTFAMI2 = {} extends { prop?: number } ? "Y" : "N";
如果能繞過來,很容易就能得出來答案。如果一時沒繞過去,也很簡單,對于前面一個情況,prop是必須的,因此空對象{}并不能繼承自{ prop: number },而對于可選情況下則可以。因此我們使用這種思路來得到可選/必選的鍵名。
{} extends Pick<T, K>,如果K是可選字段,那么就留下(OptionalKeys,如果是RequiredKeys就剔除)。怎么剔除?當(dāng)然是用 never了。
export type RequiredKeys<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? never : K;
}[keyof T];
這里是剔除可選字段,那么OptionalKeys就是保留了:
export type OptionalKeys<T> = {
[K in keyof T]-?: {} extends Pick<T, K> ? K : never;
}[keyof T];
只讀字段IMmutableKeys與非只讀字段MutableKeys的思路類似,即先獲得:
interface MutableKeys {
readonlyKeys: never;
notReadonlyKeys: "notReadonlyKeys";
}
然后再獲得不為never的字段名即可。這里還是要表達(dá)一下對作者的敬佩,屬實巧妙啊,首先定義一個工具類型IfEqual,比較兩個類型是否相同,甚至可以比較修飾前后的情況下,也就是這里只讀與非只讀的情況。
type Equal<X, Y, A = X, B = never> = (<T>() => T extends X ? 1 : 2) extends <
T
>() => T extends Y ? 1 : 2
? A
: B;
不要被 <T>() => T extends X ? 1 : 2干擾,可以理解為就是用于比較的包裝,這一層包裝能夠區(qū)分出來只讀與非只讀屬性。實際使用時(非只讀),我們?yōu)閄傳入接口,為Y傳入去除了只讀屬性 -readonly的接口,為A傳入字段名,B這里我們需要的就是never,因此可以不填。
實例:
export type MutableKeys<T extends object> = {
[P in keyof T]-?: Equal<
{ [Q in P]: T[P] },
{ -readonly [Q in P]: T[P] },
P,
never
>;
}[keyof T];
幾個容易繞彎子的點:
泛型Q在這里不會實際使用,只是映射類型的字段占位。 X Y同樣存在著 分布式條件類型, 來依次比對字段去除readonly前后。
同樣的有:
export type IMmutableKeys<T extends object> = {
[P in keyof T]-?: Equal<
{ [Q in P]: T[P] },
{ -readonly [Q in P]: T[P] },
never,
P
>;
}[keyof T];
這里不是對 readonly修飾符操作,而是調(diào)換條件類型的判斷語句。
基于值類型的Pick與Omit
前面我們實現(xiàn)的Pick與Omit是基于鍵名的,假設(shè)現(xiàn)在我們需要按照值類型來做選取剔除呢?其實很簡單,就是T[K] extends ValueType即可:
export type PickByValueType<T, ValueType> = Pick<
T,
{ [Key in keyof T]-?: T[Key] extends ValueType ? Key : never }[keyof T]
>;
export type OmitByValueType<T, ValueType> = Pick<
T,
{ [Key in keyof T]-?: T[Key] extends ValueType ? never : Key }[keyof T]
>;
條件類型承擔(dān)了太多...
工具類型一覽
總結(jié)下我們上面書寫的工具類型:
全量修飾接口: PartialReadonly(Immutable)MutableRequired,以及對應(yīng)的遞歸版本裁剪接口: PickOmitPickByValueTypeOmitByValueType基于infer: ReturnTypeParamTypePromiseType獲取指定條件字段: FunctionKeysOptionalKeysRequiredKeys...
需要注意的是,有時候單個工具類型并不能滿足你的要求,你可能需要多個工具類型協(xié)作,比如用FunctionKeys+Pick得到一個接口中類型為函數(shù)的字段。如果你之前沒有關(guān)注過TS類型編程,那么可能需要一定時間來適應(yīng)思路的轉(zhuǎn)變。我的建議是,從今天開始,從現(xiàn)在的項目開始,從類型守衛(wèi)、泛型、最基本的Partial開始,讓你的代碼精準(zhǔn)而優(yōu)雅。
尾聲
在結(jié)尾說點我個人的理解吧,我認(rèn)為TypeScript項目實際上是需要經(jīng)過組織的,而不是這一個接口那一個接口,這里一個字段那里一個類型別名,更別說明明可以使用幾個工具類型輕松得到的結(jié)果卻自己重新寫了一遍接口。但很遺憾,要做到這一點實際上會耗費大量精力,并且對業(yè)務(wù)帶來的實質(zhì)提升是微乎其微的(長期業(yè)務(wù)倒是還好),畢竟頁面不會因為你的類型聲明嚴(yán)謹(jǐn)環(huán)環(huán)相扣就PVUV暴增。我目前的階段依然停留在尋求開發(fā)的效率和質(zhì)量間尋求平衡,目前的結(jié)論:多寫TS,寫到如臂指使,你的效率就會upup。那我們本篇就到這里了,下篇文章內(nèi)容是在Flutter中使用GraphQL,說實在的,這二者的結(jié)合給我一種十分詭異的感覺,像是在介紹前女友給現(xiàn)在的女朋友認(rèn)識...


“分享、點贊、在看” 支持一波 
