天天看點

你所不了解的TypeScript 類型程式設計

點選上方“前端開發部落格”,選擇“設為星标”

回複“2”加入前端群

前言

作為前端開發的趨勢之一,TypeScript正在越來越普及,很多人像我一樣寫了TS後再也回不去了,比如寫算法題寫demo都用TS,JS隻有在Webpack配置(實際上這也可以用TS寫)等少的可憐的情況下才會用到(有了

ts-node

後,我連爬蟲都用ts寫了)。TS的學習成本實際上并不高(的确是,具體原因我在下面會講,别急着錘我),我個人認為它可以被分成兩個部分:

  • 預實作的ES提案,如 裝飾器(我之前的一篇文章 走近MidwayJS:初識TS裝飾器與IoC機制 中講了一些關于TS裝飾器的曆史, 有興趣的可以看看), 可選鍊

    ?.

    ,空值合并運算符

    ??

    ,類的私有成員

    private

    等。除了部分文法如裝飾器以外,大部分的預實作實際上就是未來的ES文法。對于這一部分來說,無論你先前是隻學習過JS(就像我一樣),還是有過Java、C#的使用經曆,都能非常快速地上手,這也是實際開發中使用最多的部分,畢竟和另一塊-類型程式設計比起來,還是這一部分更接地氣。
  • 類型程式設計,無論是一個普通接口(

    interface

    )或是類型别名

    type

    ,還是密密麻麻的

    extends

    infer

    工具類型blabla...(下文會展開介紹),我個人認為都屬于類型程式設計的範疇。這一塊實際上對代碼的功能層面沒有任何影響,即使你把它寫成anyscript,代碼該咋樣還是咋樣。而這也就是類型程式設計一直不受到太多重視的原因:相比于文法,它會帶來代碼量大大增多(可能接近甚至超過業務代碼),編碼耗時增長(頭發--)等問題,而帶來的唯一好處就是 類型安全 , 包括如臂使指的類型提示(VS Code YES!),進一步減少可能存在的調用錯誤,以及降低維護成本。看起來似乎有得有失,但實際上,假設你花費1機關腦力使用基礎的TS以及簡單的類型程式設計,你就能夠獲得5個機關的回饋。但接下來,有可能你花費10個機關腦力,也隻能再獲得2個機關的回饋。另外一個類型程式設計不受重視的原因則是實際業務中并不會需要多麼苛刻的類型定義,通常是底層架構類庫才會有此類需求,這一點就見仁見智了,但我想沒人會想永遠當業務仔吧(沒有陰陽怪氣的意思)。

正文部分包括:

  • 基礎泛型
  • 索引類型 & 映射類型
  • 條件類型 & 分布式條件類型
  • infer關鍵字
  • 類型守衛 is in 關鍵字
  • 内置工具類型機能與原理
  • 内置工具類型增強
  • 更多通用工具類型

這些名詞可能看着有點勸退,但我會盡可能描述的通俗易懂,讓你在閱讀時不斷發出“就這?”的感慨:)

為了适配所有基礎的讀者,本文會講解的盡可能細緻,如果你已經熟悉某部分知識,請跳過~

泛型 Generic Type

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

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

如果它接收一個字元串,傳回這個字元串的部分截取,如果接收一個數字,傳回這個數字的n倍,如果接收一個對象,傳回鍵值被更改過的對象(鍵名不變),如果這時候需要類型定義,是否要把

unknown

替換為

string | number | object

?這樣固然可以,但别忘記我們需要的是 入參與傳回值類型相同 的效果。這個時候泛型就該登場了,泛型使得代碼段的類型定義易于重用(比如我們上面提到的場景又多了一種接收布爾值傳回布爾值的場景後的修改),并提升了靈活性與嚴謹性:

工程層面當然不會寫這樣的代碼了... 但就當個例子看吧hhh
function foo<T>(arg: T): T {
  return arg;
}
           

我們使用

T

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

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

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

  • 參數

    obj

  • 參數

    key

  • 傳回值

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

  • key

    必然是

    obj

    中的鍵值名之一,一定為

    string

    類型
  • 傳回的值一定是obj中的鍵值

是以我們初步得到這樣的結果:

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"
           
字面量類型是對類型的進一步限制,比如你的狀态碼隻可能是0/1/2,那麼你就可以寫成

status: 0 | 1 | 2

的形式。字面量類型包括字元串字面量、數字字面量、布爾值字面量。

還少了傳回值,如果你此前沒有接觸過此類文法,應該會卡住,我們先聯想下

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,就能拿到對應的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的鍵名組成的聯合類型(以字面量類型的形式)。具體的知識我們會在下一節條件類型講到。假設現在我們不隻要取出一個值了,我們要取出一系列值:

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的鍵名組成的聯合類型,那麼要表示一個内部元素均是T鍵名的數組,就可以使用這種方式,具體的原理請參見下文的 分布式條件類型 章節。
  • T[U][]

    它的原理實際上和上面一條相同,之是以單獨拿出來是因為我認為它是一個很好地例子:簡單的表現了TS類型程式設計的組合性,你不感覺這種寫法就像搭積木一樣嗎?

索引簽名 Index Signature

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

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

那麼接口Foo就被認定為字段全部為string類型。值得注意的是,由于JS可以同時通過數字與字元串通路對象屬性,是以

keyof Foo

的結果會是

string | number

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

o[1] === o["1"];
複制代碼
           

但是一旦某個接口的索引簽名類型為

number

,那麼它就不能再通過字元串索引通路,如

o['1']

這樣。

映射類型 Mapped Types

映射類型同樣是類型程式設計的重要底層組成,通常用于在舊有類型的基礎上進行改造,包括接口包含字段、字段的類型、修飾符(readonly與?)等等。從一個簡單場景入手:

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

現在我們有個需求,實作一個接口,它的字段與接口A完全相同,但是其中的類型全部為string,你會怎麼做?直接重新聲明一個然後手寫嗎?我們可是聰明的程式員诶,那必不可能這麼笨。如果把接口換成對象再想想,其實很簡單,new一個新對象,然後周遊A的鍵名(

Object.keys()

)來填充這個對象。

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

是不是很熟悉?重要的就是這個

in

操作符,你完全可以把它了解為就是

for...in

,也就是說你還可以擷取到接口鍵值類型,比如我們複制接口!

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 object

U extends keyof T

都是泛型限制,分别将T限制為對象類型和将U限制為T鍵名的字面量聯合類型。我們通常使用泛型限制來**“使得泛型收窄”**。以一個使用條件類型作為函數傳回值類型的例子:

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

在這種情況下,條件類型的推導就會被延遲(deferred),因為此時類型系統沒有足夠的資訊來完成判斷。隻有給出了所需資訊(在這裡是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>; 
           

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

  • 裸類型參數,沒有額外被接口/類型别名包裹過的,就像被

    Wrapped

    包裹後就不能再被稱為裸類型參數。
  • 執行個體化,其實就是條件類型的判斷過程,在這裡兩個例子的執行個體化過程實際上是不同的,具體會在下一點中介紹。
  • 分發至聯合類型的過程:
    • 對于TypeName,它内部的類型參數T是沒有被包裹過的,是以

      TypeName<string | (() => void)>

      會被分發為

      TypeName<string> | TypeName<(() => void)>

      ,然後再次進行判斷,最後分發為

      "string" | "function"

    • 抽象下具體過程:```typescript ( A | B | C ) extends T ? X : Y // 相當于 (A extends T ? X : Y) | (B extends T ? X : Y) | (B extends T ? X : Y)
      複制代碼
                 

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

infer關鍵字

infer

inference

的縮寫,通常的使用方式是

infer R

R

表示 待推斷的類型。通常

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);
  }
}
           
你所不了解的TypeScript 類型程式設計

啊哦,看起來

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 {
  if ("a" in arg) {
    arg.useA();
  } else {
    arg.useB();
  }
}
           

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

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; // 使用者來源(一般用于埋點),僅在未登入時有
}
           

這種時候使用字面量類型守衛:

function getUserInfo(user: IUnLogin | ILogined): string {
  return user.isLogin ? user.id : user.from;
}
           

還可以使用

instanceof

來進行執行個體的類型守衛,建議聰明的你動手嘗試下~

工具類型Tool Type

這一章是本文的最後一部分,應該也是本文“成本效益”最高的一部分了,因為即使你還是不太懂這些工具類型的底層實作,也不影響你把它用好。就像Lodash不會要求你每用一個函數就熟知原理一樣。這一部分包括TS内置工具類型與社群的擴充工具類型,我個人推薦在完成學習後記錄你覺得比較有價值的工具類型,并在自己的項目裡建立一個

.d.ts

檔案存儲它。

在繼續閱讀前,請確定你掌握了上面的知識,它們是類型程式設計的基礎

内置工具類型

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

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

它用于将一個接口中的字段變為全部可選,除了映射類型以外,它隻使用了

?

可選修飾符,那麼我現在直接掏出小抄(好家夥):

  • 去除可選修飾符:

    -?

  • 隻讀修飾符:

    readonly

  • 去除隻讀修飾符:

    -readonly

恭喜,你得到了

Required

Readonly

(去除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

類型,它表示永遠不會出現的類型,通常被用來将收窄聯合類型或是接口,詳細可以看 [尤大的知乎回答](<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"在嗎?不在那就讓它留下來。這裡實際上使用到了分布式條件類型的特性,假設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到了那麼一絲天機,類型程式設計的确沒有特别高深晦澀的文法,它考驗的是你對其中基礎部分如索引、映射、條件類型的掌握程度,以及舉一反三的能力。下面我們要學習的社群工具類型,本質上還是各種基礎類型的組合,隻是從常見場景下出發,補充了官方沒有覆寫到的部分。

社群工具類型

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

我們由淺入深,先封裝基礎的類型别名和對應的類型守衛,不對原理做講述:

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;
           

Falsy

isFalsy

我們已經在上面展現了~

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

内部的實作實際比這個複雜,還考慮了數組的情況,這裡為了便于了解做了簡化,後面的工具類型也同樣存在此類簡化。

那麼

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

傳回鍵名的工具類型

在有些場景下我們需要一個工具類型,它傳回接口字段鍵名組成的聯合類型,然後用這個聯合類型進行進一步操作(比如給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

是必須的,是以空對象

{}

并不能繼承自

{ prop: number }

,而對于可選情況下則可以。是以我們使用這種思路來得到可選/必選的鍵名。

  • {} extends Pick<T, K>

    ,如果

    K

    是可選字段,那麼就留下(OptionalKeys,如果是RequiredKeys就剔除)。
  • 怎麼剔除?當然是用

    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

的字段名即可。這裡還是要表達一下對作者的敬佩,屬實巧妙啊,首先定義一個工具類型

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

    幹擾,可以了解為就是用于比較的包裝,這一層包裝能夠區分出來隻讀與非隻讀屬性。
  • 實際使用時(非隻讀),我們為X傳入接口,為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

    修飾符操作,而是調換條件類型的判斷語句。

基于值類型的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]
>;
           
條件類型承擔了太多...

工具類型一覽

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

  • 全量修飾接口:

    Partial

    Readonly(Immutable)

    Mutable

    Required

    ,以及對應的遞歸版本
  • 裁剪接口:

    Pick

    Omit

    PickByValueType

    OmitByValueType

  • 基于infer:

    ReturnType

    ParamType

    PromiseType

  • 擷取指定條件字段:

    FunctionKeys

    OptionalKeys

    RequiredKeys

    ...

需要注意的是,有時候單個工具類型并不能滿足你的要求,你可能需要多個工具類型協作,比如用

FunctionKeys

+

Pick

得到一個接口中類型為函數的字段。如果你之前沒有關注過TS類型程式設計,那麼可能需要一定時間來适應思路的轉變。我的建議是,從今天開始,從現在的項目開始,從類型守衛、泛型、最基本的

Partial

開始,讓你的代碼精準而優雅。

尾聲

在結尾說點我個人的了解吧,我認為TypeScript項目實際上是需要經過組織的,而不是這一個接口那一個接口,這裡一個字段那裡一個類型别名,更别說明明可以使用幾個工具類型輕松得到的結果卻自己重新寫了一遍接口。但很遺憾,要做到這一點實際上會耗費大量精力,并且對業務帶來的實質提升是微乎其微的(長期業務倒是還好),畢竟頁面不會因為你的類型聲明嚴謹環環相扣就PVUV暴增。我目前的階段依然停留在尋求開發的效率和品質間尋求平衡,目前的結論:多寫TS,寫到如臂指使,你的效率就會upup。那我們本篇就到這裡了,下篇文章内容是在Flutter中使用GraphQL,說實在的,這二者的結合給我一種十分詭異的感覺,像是在介紹前女友給現在的女朋友認識...

作者:林不渡

https://juejin.cn/post/6885672896128090125

推薦閱讀

Vue3 + TypeScript 複盤總結

忙碌的一周過去了,一起來彙總一下前端開發部落格最近一周有哪些值得閱讀的東西吧。

文章排行

  1. 前端程式員履歷制作建議
  2. 前端程式員履歷模闆整理和下載下傳
  3. 基于Vite2+Vue3的項目複盤總結
  4. 66 道前端算法面試題附思路分析助你查漏補缺
  5. 如何在面試中介紹自己的項目經驗
  6. 郁悶!我上不了Github了
  7. 畢業不到一年的前端開發同學的焦慮

END

你所不了解的TypeScript 類型程式設計

關注下方「前端開發部落格」,回複 “履歷模闆”

領取33個精選前端履歷模闆

❤️ 看完兩件事

如果你覺得這篇内容對你挺有啟發,我想邀請你幫我兩個小忙:

  1. 點個「在看」,讓更多的人也能看到這篇内容(喜歡不點在看,都是耍流氓 -_-)
  2. 關注公衆号「前端開發部落格」,每周重點攻克一個前端面試重難點
你所不了解的TypeScript 類型程式設計

如果覺得這篇文章還不錯,來個【分享、點贊、在看】三連吧,讓更多的人也看到~

你所不了解的TypeScript 類型程式設計

公衆号也開始通過互動率推送了,互動少了可能就很晚或者收不到文章了。

大家點個在看,星标我的公衆号,就可以及時獲得推文。

你所不了解的TypeScript 類型程式設計

點個在看少個Bug