從零成爲 TypeScript 體操運動員,高級類型完全指南

作者:林不渡

https://juejin.cn/post/6885672896128090125

前言

作爲前端開發的趨勢之一,TypeScript 正在越來越普及,很多人像我一樣寫了 TS 後再也回不去了,比如寫再小的 demo 也要用 TS(得益於 ts-node[1]),JS 只有在配置文件如 Webpack(實際上,接下來肯定會有用 TS 寫配置文件的趨勢,如 Vite)、ESLint 等時纔會用到。但同樣,也有部分開發者對 TS 持有拒絕的態度,如 nodemon 的作者就曾表示自己從來沒有使用過 TS(見 #1565[2])。但同樣還有另外一部分人認爲 TS 學習成本太高,所以一直沒有開始學習的決心。

然而嚴謹的來說,TS 的學習成本實際上並不高,我認爲它可以被分成兩個部分:

預實現的 ES 提案,如 裝飾器(我之前的一篇文章 走近 MidwayJS:初識 TS 裝飾器與 IoC 機制 [3] 中講了一些關於 TS 裝飾器的歷史, 有興趣的可以看看), 可選鏈?. ,空值合併運算符??(和可選鏈一起在 TypeScript3.7[4] 中引入),類的私有成員private等。除了部分極端不穩定的語法(說的就是你,裝飾器)以外,大部分的 TS 實現實際上就是未來的 ES 語法。

對於這一部分來說,無論你先前是隻學習過 JS(就像我一樣),還是有過 Java、C# 的使用經歷,都能非常快速地上手,這也是實際開發中使用最多的部分,畢竟和另一塊 - 類型編程比起來,還是這一部分更接地氣。

類型編程,無論是一個普通接口 (interface),還是密密麻麻的T extends SomeType ,或者是各種奇奇怪怪的工具類型(PartialRequired等),其實都屬於類型編程的範疇。這一塊對代碼的功能層面沒有任何影響,即使你一行代碼十個 any,遇到類型錯誤就@ts-ignore,代碼該咋樣還是咋樣。

然而這也就是類型編程一直不受到太多重視的原因:相比於語法,它會帶來代碼量大大增多(類型定義可能接近甚至超過業務代碼量),上手成本較高等問題,但好處也是顯而易見的,那就是類型安全,如果你所在的團隊使用 Sentry 或是類似的監控平臺,對於 JS 代碼來說最常見的錯誤就是Cannot read property 'xxx' of undefinedundefined is not a function這種(如果有興趣瞭解更多,可以閱讀 top-10-javascript-errors[5])。雖然 TS 不可能把這個錯誤直接完全抹消,但也能解決十之八九了。

另外一個特點是,在類型編程這一方面上,假設你花費 1 單位腦力使用基礎的 TS 以及簡單的類型編程(即 interface、type 等),你就能夠獲得 5 個單位的回饋。但接下來,有可能你花費 10 個單位腦力,也只能再獲得 2 個單位的回饋。所以類型編程往往不會受到過多重視。另外一個類型編程不受重視的重要原因則是,實際業務中並不會需要多麼苛刻的類型定義,通常只會對接口數據以及應用狀態等進行定義。通常是底層框架類庫纔會需要大量的條件類型、泛型、重載等。

前言鋪墊完畢,接下來就進入正文部分。這篇文章的主要面向對象是還沒有走出新手村的同學,可以把本文當成你們的新手任務。

推薦在閱讀過程中跟着敲一遍文中的代碼,畢竟 TS 的東西我自己幾個月沒寫都能忘個乾淨。

正文部分包括:

泛型 Generic Type

假設我們有這麼一個函數:

function foo(args: unknown): unknown { ... }

上面這些場景有一個共同點,即函數的返回值與入參是同一類型.

如果這時候需要類型定義,是否要把unknown替換爲string | number | object?這樣固然可以,但別忘記我們需要的是 入參與返回值類型相同 的效果。這個時候泛型就該登場了,泛型使得代碼段的類型定義易於重用(比如後續又多了一種接收布爾值返回布爾值的函數實現),並提升了靈活性與嚴謹性:

工程層面當然不會寫這樣的代碼了... 但就當個例子看吧:-)

function foo<T>(arg: T): T {
  return arg;
}

我們使用T來表示一個未知的類型,它是入參與返回值的類型,在使用時我們可以顯示指定泛型:

通常泛型只會使用單個字母。如 T U K V S 等。我的推薦做法是在項目達到一定複雜度後,使用有具體含義的泛型,如BasicSchema

foo<string>("linbudu");
const [count, setCount] = useState<number>(1);

當然也可以不指定,因爲 TS 會自動推導出泛型的實際類型。

泛型在箭頭函數下的書寫:

const foo = <T>(arg: T) => arg;

如果你在 TSX 文件中這麼寫,<T>可能會被識別爲 JSX 標籤,因此需要顯式告知編譯器:

const foo = <T extends {}>(arg: T) => arg;

除了用在函數中,泛型也可以在類中使用:

class Foo<T, U> {
  constructor(public arg1: T, public arg2: U) {}

  public method(): T {
    return this.arg1;
  }
}

泛型除了單獨使用,也經常與其他類型編程語法結合使用,可以說泛型就是 TS 類型編程最重要的基石。單獨對於泛型的介紹就到這裏(因爲單純的講泛型實在沒有什麼好講的),在接下來我們會講解更多泛型的高級使用技巧。

索引類型與映射類型

在閱讀這一部分前,你需要做好思維轉變的準備,需要認識到 類型編程實際也是編程。就像你寫業務代碼的時候常常會遍歷一個對象,而在類型編程中我們也會經常遍歷一個接口。因此,你可以將一部分編程思路複用過來。我們實現一個簡單的函數:

// 假設key是obj鍵名
function pickSingleValue(obj, key) {
  return obj[key];
}

要爲其進行類型定義的話,有哪些需要定義的地方?

這三樣之間是否存在關聯?

因此我們初步得到這樣的結果:

function pickSingleValue<T>(obj: T, key: keyof T) {
  return obj[key];
}

keyof索引類型查詢的語法, 它會返回後面跟着的類型參數的鍵值組成的字面量類型(literal types),舉個例子:

interface foo {
  a: number;
  b: string;
}

type A = keyof foo; // "a" | "b"

是不是就像Object.keys()?

字面量類型是對類型的進一步限制,比如你的狀態碼只可能是 0/1/2,那麼你就可以寫成status: 0 | 1 | 2的形式。

字面量類型包括字符串字面量數字字面量布爾值字面量

這一類細碎的基礎知識會被穿插在文中各個部分進行講解,以此避免單獨講解時缺少特定場景讓相關概念顯得過於單調。

還少了返回值,如果你此前沒有接觸過此類語法,應該會卡住,我們先聯想下for...in語法,遍歷對象時我們可能會這麼寫:

const fooObj = { a: 1, b: "1" };

for (const key in fooObj) {
  console.log(key);
  console.log(fooObj[key]);
}

和上面的寫法一樣,我們拿到了 key,就能拿到對應的 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

你用鍵名可以取出對象上的鍵值,自然也就可以取出接口上的鍵值(也就是類型)啦~

但這種寫法很明顯有可以改進的地方:keyof出現了兩次,以及泛型 T 應該被限制爲對象類型,就像我們平時會做的那樣:用一個變量把多處出現的存起來,在類型編程裏,泛型就是變量

function pickSingleValue<T extends object, U extends keyof T>(
  obj: T,
  key: U
): T[U] {
  return obj[key];
}

這裏又出現了新東西extends... 它是啥?你可以暫時把T extends object理解爲 T 被限制爲對象類型U extends keyof T理解爲泛型 U 必然是泛型 T 的鍵名組成的聯合類型(以字面量類型的形式,比如 T 的鍵包括 a b c,那麼 U 的取值只能是 "a" "b" "c" 之一)。具體的知識我們會在下一節條件類型講到。

假設現在不只要取出一個值了,我們要取出一系列值,即參數 2 將是一個數組,成員均爲參數 1 的鍵名組成:

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'])

有兩個重要變化:

索引簽名 Index Signature

索引簽名用於快速建立一個內部字段類型相同的接口,如

interface Foo {
  [keys: string]: string;
}

那麼接口 Foo 就被認定爲字段全部爲 string 類型。

等同於Record<string, string>

值得注意的是,由於 JS 可以同時通過數字與字符串訪問對象屬性,因此keyof Foo的結果會是string | number

const o: Foo = {
    1: "蕪湖!",
};

o[1] === o["1"]; // true

但是一旦某個接口的索引簽名類型爲number,那麼使用它的對象就不能再通過字符串索引訪問,如o['1'],將會拋出Element implicitly has an 'any' type because index expression is not of type 'number'錯誤。

映射類型 Mapped Types

映射類型同樣是類型編程的重要底層組成,通常用於在舊有類型的基礎上進行改造,包括接口包含字段、字段的類型、修飾符(只讀 readonly 與 可選?)等等。

從一個簡單場景入手:

interface A {
  a: boolean;
  b: string;
  c: number;
  d: () => void;
}

現在我們有個需求,實現一個接口,它的字段與接口 A 完全相同,但是其中的類型全部爲 string,你會怎麼做?直接重新聲明一個然後手寫嗎?這樣就很離譜了,我們可是機智的程序員。

如果把接口換成對象再想想,假設要拷貝一個對象(假設沒有嵌套),new 一個新的空對象,然後遍歷原先對象的鍵值對來填充新對象。再回到接口,其實也一樣:

type StringifyA<T> = {
  [K in keyof T]: string;
};

是不是很熟悉?重要的就是這個in操作符,你完全可以把它理解爲for...in/for...of這種遍歷的思路,獲取到鍵名之後,鍵值就簡單了!

type Clone<T> = {
  [K in keyof T]: T[K];
};

掌握這種思路,其實你已經接觸到一些工具類型的底層實現了:

你可以把工具類型理解爲你平時放在 utils 文件夾下的公共函數,提供了對公用邏輯(在這裏則是類型編程邏輯)的封裝,比如上面的兩個類型接口就是~

先寫個最常用的Partial嚐嚐鮮,工具類型的詳細介紹我們會在專門的章節展開:

// 將接口下的字段全部變爲可選的
type Partial<T> = {
  [K in keyof T]?: T[k];
};

是不是特別簡單,讓你已經脫口而出 “就這!”,類似的,還可以實現個Readonly,把接口下的字段全部變爲只讀的。

條件類型 Conditional Types

條件類型的語法實際上就是三元表達式,看一個最簡單的例子:

T extends U ? X : Y

如果你覺得這裏的 extends 不太好理解,可以暫時簡單理解爲 U 中的屬性在 T 中都有。

爲什麼會有條件類型?可以看到通常條件類型通常是和泛型一同使用的,聯想到泛型的使用場景,我想你應該明白了些什麼。對於類型無法即時確定的場景,使用條件類型來在運行時動態的確定最終的類型(運行時可能不太準確,或者可以理解爲,你提供的函數被他人使用時,根據他人使用時傳入的參數來動態確定需要被滿足的類型約束)。

條件類型理解起來更直觀,唯一需要有一定理解成本的就是 何時條件類型系統會收集到足夠的信息來確定類型,也就是說,條件類型有時不會立刻完成判斷。

在瞭解這一點前,我們先來看看條件類型常用的一個場景:泛型約束,實際上就是我們上面的例子:

function pickSingleValue<T extends object, U extends keyof T>(
  obj: T,
  key: U
): T[U] {
  return obj[key];
}

這裏的T extends objectU extends keyof T都是泛型約束,分別將 T 約束爲對象類型將 U 約束爲 T 鍵名的字面量聯合類型。我們通常使用泛型約束來 收窄類型約束

以一個使用條件類型作爲函數返回值類型的例子:

declare function strOrNum<T extends boolean>(
  x: T
): T extends true ? string : number;

在這種情況下,條件類型的推導就會被延遲,因爲此時類型系統沒有足夠的信息來完成判斷。

只有給出了所需信息(在這裏是入參 x 的類型),纔可以完成推導。

const strReturnType = strOrNum(true);
const numReturnType = strOrNum(false);

同樣的,就像三元表達式可以嵌套,條件類型也可以嵌套,如果你看過一些框架源碼,也會發現其中存在着許多嵌套的條件類型,無他,條件類型可以將類型約束收攏到非常精確的範圍內。

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

官方文檔對分佈式條件類型的講解內容甚至要多於條件類型,因此你也知道這玩意沒那麼簡單了吧~

分佈式條件類型實際上不是一種特殊的條件類型,而是其特性之一。先上概念:對於屬於裸類型參數的檢查類型,條件類型會在實例化時期自動分發到聯合類型上

原文: 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

先提取幾個關鍵詞,然後我們再通過例子理清這個概念:

// 使用上面的TypeName類型別名

// "string" | "function"
type T1 = TypeName<string | (() => void)>;

// "string" | "object"
type T2 = TypeName<string | string[]>;

// "object"
type T3 = TypeName<string[] | number[]>;

我們發現在上面的例子裏,條件類型的推導結果都是聯合類型(T3 實際上也是,只不過相同所以被合併了),並且其實就是類型參數被依次進行條件判斷後,再使用|組合得來的結果。

是不是 get 到了一點什麼?我們再看另一個例子:

type Naked<T> = T extends boolean ? "Y" : "N";
type Wrapped<T> = [T] extends [boolean] ? "Y" : "N";

/*
 * 先分發到 Naked<number> | Naked<boolean>
 * 所以結果是"N" | "Y"
 */
type Distributed = Naked<number | boolean>;

/*
 * 不會分發 直接是 [number | boolean] extends [boolean]
 * 這樣當然就是"N"啦~
 */
type NotDistributed = Wrapped<number | boolean>;

現在我們可以來講講這幾個概念了:

一句話概括:沒有被額外包裝的聯合類型參數,在條件類型進行判定時會將聯合類型分發,分別進行判斷。

infer 關鍵字

inferinference的縮寫,通常的使用方式是infer RR表示 待推斷的類型。如果說,通常infer不會被直接使用,而是與條件類型一起,被放置在底層工具類型中,用於

看一個簡單的例子,用於獲取函數返回值類型的工具類型ReturnType:

const foo = ()string ={
  return "linbudu";
};

// string
type FooReturnType = ReturnType<typeof foo>;

infer的使用思路可能不是那麼好習慣,我們可以用前端開發中常見的一個例子類比,頁面初始化時先顯示佔位交互,像 Loading / 骨架屏,在請求返回後再去渲染真實數據。infer也是這個思路,類型系統在獲得足夠的信息後,就能將 infer 後跟隨的類型參數推導出來,最後返回這個推導結果。

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

類似的,藉着這個思路我們還可以獲得函數入參類型、類的構造函數入參類型、甚至 Promise 內部的類型等,這些工具類型我們會在後面講到。

infer 其實沒有特別難消化的知識點,它需要的只是思路的轉變,你要理解 延遲推斷 的概念。

類型守衛 與 is in 關鍵字 Type Guards

前面的內容可能不是那麼符合人類直覺,需要一點時間消化,這一節我們來看點簡單(相對)且直觀的知識點:類型守衛。

假設有這麼一個字段,它可能字符串也可能是數字:

numOrStrProp: number | string;

現在在使用時,你想將這個字段的聯合類型縮小範圍,比如精確到string,你可能會這麼寫:

export const isString = (arg: unknown)boolean => typeof arg === "string";

看看這麼寫的效果:

function useIt(numOrStr: number | string) {
  if (isString(numOrStr)) {
    console.log(numOrStr.length);
  }
}

image

啊哦,看起來isString函數並沒有起到縮小類型範圍的作用,參數依然是聯合類型。這個時候就該使用is關鍵字了:

export const isString = (arg: unknown): arg is string =>
  typeof arg === "string";

這個時候再去使用,就會發現在isString(numOrStr)爲 true 後,numOrStr的類型就被縮小到了string。這只是以原始類型爲成員的聯合類型,我們完全可以擴展到各種場景上,先看一個簡單的假值判斷:

export type Falsy = false | "" | 0 | null | undefined;

export const isFalsy = (val: unknown): val is Falsy => !val;

是不是還挺有用?這應該是我日常用的最多的類型別名之一了。

也可以在 in 關鍵字的加持下,進行更強力的類型判斷,思考下面這個例子,要如何將 "A | B" 的聯合類型縮小到 "A"?

class A {
  public a() {}

  public useA() {
    return "A";
  }
}

class B {
  public b() {}

  public useB() {
    return "B";
  }
}

再聯想下for...in循環,它遍歷對象的屬性名,而in關鍵字也是一樣:

function useIt(arg: A | B): void {
  'a' in arg ? arg.useA() : arg.useB();
}

如果參數中存在a屬性,由於 A、B 兩個類型的交集並不包含 a,所以這樣能立刻縮小範圍到 A。

再看一個使用字面量類型作爲類型守衛的例子:

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 寫接口的小夥伴可能都遇到過,即登錄與未登錄下的用戶信息是完全不同的接口,其實也可以使用in關鍵字解決。

interface ILogInUserProps {
  isLogin: boolean;
  name: string;
}

interface IUnLoginUserProps {
  isLogin: boolean;
  from: string;
}

type UserProps = ILogInUserProps | IUnLoginUserProps;

function getUserInfo(user: ILogInUserProps | IUnLoginUserProps): string {
  return 'name' in user ? user.name : user.from;
}

同樣的思路,還可以使用instanceof來進行實例的類型守衛,建議聰明的你動手嘗試下~

工具類型 Tool Type

這一章是本文的最後一部分,應該也是本文 “性價比” 最高的一部分了,因爲即使你還是不太懂這些工具類型的底層實現,也不影響你把它用好。就像 Lodash 不會要求你每用一個函數都熟知原理一樣。這一部分包括 TS 內置工具類型與社區的擴展工具類型,我個人推薦在完成學習後記錄你覺得比較有價值的工具類型,並在自己的項目裏新建一個.d.ts文件(或是/utils/tool-types.ts)存儲它。

在繼續閱讀前,請確保你掌握了上面的知識,它們是類型編程的基礎。

內置工具類型

在上面我們已經實現了內置工具類型中被使用最多的一個:

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

它用於將一個接口中的字段變爲全部可選,除了映射類型以外,它只使用了?可選修飾符,那麼我現在直接掏出小抄(好傢伙):

恭喜,你得到了RequiredReadonly(去除 readonly 修飾符的工具類型不屬於內置的,我們會在後面看到):

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

type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};

在上面我們實現了一個 pick 函數:

function pick<T extends object, U extends keyof T>(obj: T, keys: U[]): T[U][] {
  return keys.map((key) => obj[key]);
}

照着這種思路,假設我們現在需要從一個接口中挑選一些字段:

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

// 期望用法
type Part = Pick<A, "a" | "b">;

還是映射類型,只不過現在映射類型的映射源是類型參數K

既然有了Pick,那麼自然要有Omit(一個是從對象中挑選部分,一個是排除部分),它和Pick的寫法非常像,但有一個問題要解決:我們要怎麼表示T中剔除了K後的剩餘字段?

Pick 選取傳入的鍵值,Omit 移除傳入的鍵值

這裏我們又要引入一個知識點:never類型,它表示永遠不會出現的類型,通常被用來將收窄聯合類型或是接口,詳細可以看 尤大的知乎回答 [6], 在這裏 我們不做展開介紹。

在類型守衛一節,我們提到了一個用戶登錄狀態決定類型接口的例子,實際上也可以用 never 實現。

上面的場景其實可以簡化爲:

// "3" | "4" | "5"
type LeftFields = Exclude<"1" | "2" | "3" | "4" | "5""1" | "2">;

Exclude,字面意思看起來是排除,那麼第一個參數應該是要進行篩選的,第二個應該是篩選條件!先按着這個思路試試:

用排列組合的思路考慮:"1""1" | "2"裏面嗎 ("1" extends "1"|"2" -> true)?在啊, 那讓它爬,"3" 在嗎?不在那就讓它留下來。

這裏實際上使用到了分佈式條件類型的特性,假設 Exclude 接收 T U 兩個類型參數,T 聯合類型中的類型會依次與 U 類型進行判斷,如果這個類型參數在 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>,通常用於生成以聯合類型爲鍵名(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的每個鍵值拿出來,類型規定爲Type即可

// K extends keyof any 約束K必須爲聯合類型
type Record<K extends keyof any, T> = {
  [P in K]: T;
};

在前面的 infer 一節中我們實現了用於獲取函數返回值的ReturnType

type ReturnType<T extends (...args: any) => any> = T extends (
  ...args: any
) => infer R
  ? R
  : any;

其實把 infer 換個位置,比如放到返回值處,它就變成了獲取參數類型的Parameters:

type Parameters<T extends (...args: any) => any> = T extends (
  ...args: infer P
) => any
  ? P
  : never;

如果再大膽一點,把普通函數換成類的構造函數,那麼就得到了獲取構造函數入參類型的ConstructorParameters

type ConstructorParameters<
  T extends new (...args: any) => any
> = T extends new (...args: infer P) => any ? P : never;

加上new關鍵字來使其成爲可實例化類型聲明,也就是此處的泛型約束需要一個類。

這個是獲得類的構造函數入參類型,如果把待 infer 的類型放到其返回處,想想 new 一個類的返回值是什麼?實例!所以我們得到了實例類型InstanceType

type InstanceType<T extends new (...args: any) => any> = T extends new (
  ...args: any
) => infer R
  ? R
  : any;

這幾個例子看下來,你應該已經 get 到了那麼一絲天機,類型編程的確沒有特別高深晦澀的語法,它考驗的是你對其中基礎部分如索引映射條件類型的掌握程度,以及舉一反三的能力。下面我們要學習的社區工具類型,本質上還是各種基礎類型的組合,只是從常見場景下出發,補充了官方沒有覆蓋到的部分。

模板類型相關

TypeScript 4.1[7] 中引入了模板字面量類型,使得可以使用${} 這一語法來構造字面量類型,如:

type World = 'world';

// "hello world"
type Greeting = `hello ${World}`;

隨之而來的還有四個新的工具類型:

type Uppercase<S extends string> = intrinsic;

type Lowercase<S extends string> = intrinsic;

type Capitalize<S extends string> = intrinsic;

type Uncapitalize<S extends string> = intrinsic;

它們的作用就是字面意思,不做解釋了。相關的 PR 見 40336[8],作者 Anders Hejlsberg 是 C# 與 Delphi 的首席架構師,同時也是 TS 的作者之一。

intrinsic代表了這些工具類型是由 TS 編譯器內部實現的,其實也很好理解,我們無法通過類型編程來改變字面量的值,但我想按照這個趨勢,TS 類型編程以後會支持調用 Lodash 方法也說不定。

社區工具類型

這一部分的工具類型大多來自於 utility-types[9],其作者同時還有 react-redux-typescript-guide[10] 和 typesafe-actions[11] 這兩個優秀作品。

同時,也推薦 type-fest[12] 這個庫,和上面相比更加接地氣一些。其作者的作品...,我保證你直接或間接的使用過(如果不信,一定要去看看... 我剛看到的時候是真的震驚的不行)。

我們由淺入深,先封裝基礎的類型別名和對應的類型守衛:

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也內置了
type NonNullable<T> = T extends null | undefined ? never : T;

FalsyisFalsy我們已經在上面體現了~

趁着對 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>;

如果你已經熟練掌握了infer的使用,那麼實際上是很好寫的,只需要用一個infer參數作爲 Promise 的泛型即可:

export type PromiseType<T extends Promise<any>> = T extends Promise<infer U>
  ? U
  : never;

使用infer R來等待類型系統推導出R的具體類型。

遞歸的工具類型

前面我們寫了個Partial Readonly Required等幾個對接口字段進行修飾的工具類型,但實際上都有侷限性,如果接口中存在着嵌套呢?

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

理一下邏輯:

是否是對象類型的判斷我們見過很多次了, T extends object即可,那麼如何遍歷對象內部?實際上就是遞歸。

export type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

utility-types內部的實現實際比這個複雜,還考慮了數組的情況,這裏爲了便於理解做了簡化,後面的工具類型也同樣存在此類簡化。

那麼DeepReadoblyDeepRequired也就很簡單了:

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 ? DeepImmutable<T[P]> : T[P];
};

export type DeepRequired<T> = {
  [P in keyof T]-?: T[P] extends object | undefined ? DeepRequired<T[P]> : T[P];
};

尤其注意下DeepRequired,它的條件類型判斷的是 T[P] extends object | undefined,因爲嵌套的對象類型可能是可選的(undefined),如果僅使用 object,可能會導致錯誤的結果。

另外一種省心的方式是不進行條件類型的判斷,直接全量遞歸所有屬性~

返回鍵名的工具類型

在有些場景下我們需要一個工具類型,它返回接口字段鍵名組成的聯合類型,然後用這個聯合類型進行進一步操作(比如給 Pick 或者 Omit 這種使用),一般鍵名會符合特定條件,比如:

來看個最簡單的函數類型字段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>;

很容易推導出UseIt1實際上就是:

type UseIt1 = {
  a: never;
  b: never;
  c: never;
  d: "d";
};

UseIt會保留所有字段,滿足條件的字段其鍵值爲字面量類型(值爲鍵名)

加上後面一部分:

// "d"
type UseIt2 = UseIt1[keyof UseIt1];

這個過程類似排列組合:never類型的值不會出現在聯合類型中

// string | number
type WithNever = string | never | number;

所以{ [K in keyof T]: ... }[keyof T]這個寫法實際上就是爲了返回鍵名(準備的說是鍵名組成的聯合類型)。

那麼非函數類型字段也很簡單了,這裏就不做展示了,下面來看可選字段OptionalKeys與必選字段RequiredKeys,先來看個小例子:

type WTFAMI1 = {} extends { prop: number } ? "Y" : "N";
type WTFAMI2 = {} extends { prop?: number } ? "Y" : "N";

如果能繞過來,很容易就能得出來答案。如果一時沒繞過去,也很簡單,對於前面一個情況,prop是必須的,因此空對象{}並不能滿足extends { prop: number },而對於 prop 爲可選的情況下則可以。因此我們使用這種思路來得到可選 / 必選的鍵名。

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的字段名即可。

這裏還是要表達一下對作者的敬佩,屬實巧妙啊,首先定義一個工具類型IfEqual,比較兩個類型是否相同,甚至可以比較修飾前後的情況下,也就是這裏只讀與非只讀的情況。

type Equal<X, Y, A = X, B = never> = (<T>() => T extends X ? 1 : 2) extends <
  T
>() => T extends Y ? 1 : 2
  ? A
  : B;

實例:

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];

幾個容易繞彎子的點:

同樣的有:

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];

基於值類型的 Pick 與 Omit

前面我們實現的 Pick 與 Omit 是基於鍵名的,假設現在我們需要按照值類型來做選取剔除呢?

其實很簡單,就是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]
>;

條件類型承擔了太多...

工具類型一覽

總結下我們上面書寫的工具類型:

需要注意的是,有時候單個工具類型並不能滿足你的要求,你可能需要多個工具類型協作,比如用FunctionKeys+Pick得到一個接口中類型爲函數的字段。

如果你之前沒有關注過 TS 類型編程,那麼可能需要一定時間來適應思路的轉變。我的建議是,從今天開始,從現在的項目開始,從類型守衛、泛型、最基本的Partial開始,讓你的代碼精準而優雅

尾聲

在結尾說點我個人的理解吧,我認爲 TypeScript 項目實際上是需要經過組織的,而不是這一個接口那一個接口,這裏一個字段那裏一個類型別名,更別說明明可以使用幾個工具類型輕鬆得到的結果卻自己重新寫了一遍接口。但很遺憾,要做到這一點實際上會耗費大量精力,並且對業務帶來的實質提升是微乎其微的(長期業務倒是還好),畢竟頁面不會因爲你的類型聲明嚴謹環環相扣就 PVUV 暴增。我目前的階段依然停留在尋求開發的效率和質量間尋求平衡,目前的結論:多寫 TS,腳本 / 爬蟲 / 配置 / demo,能用 TS 的就用 TS 寫,寫到如臂使指,你的效率就會 upup

參考資料

[1]

ts-node: https://github.com/TypeStrong/ts-node

[2]

#1565: https://github.com/remy/nodemon/issues/1565#issuecomment-490429334

[3]

走近 MidwayJS:初識 TS 裝飾器與 IoC 機制: https://juejin.im/post/6859314697204662279

[4]

TypeScript3.7: https://devblogs.microsoft.com/typescript/announcing-typescript-3-7/

[5]

top-10-javascript-errors: https://rollbar.com/blog/top-10-javascript-errors/

[6]

尤大的知乎回答: https://www.zhihu.com/search?type=content&q=ts%20never

[7]

TypeScript 4.1: https://devblogs.microsoft.com/typescript/announcing-typescript-4-1/

[8]

40336: https://github.com/microsoft/TypeScript/pull/40336

[9]

utility-types: https://github.com/piotrwitek/utility-types

[10]

react-redux-typescript-guide: https://github.com/piotrwitek/react-redux-typescript-guide

[11]

typesafe-actions: https://github.com/piotrwitek/typesafe-actions

[12]

type-fest: https://github.com/sindresorhus/type-fest

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/wQhBbnqzXmP8CTsGacIi5w