TypeScript的另外一面:類型編程

TypeScript的另外一面:類型編程

前言

做爲前端開發的趨勢之一,TypeScript正在愈來愈普及,不少人像我同樣寫了TS後再也回不去了,好比寫算法題寫demo都用TS,JS只有在Webpack配置(實際上這也能夠用TS寫)等少的可憐的狀況下才會用到(有了ts-node後,我連爬蟲都用ts寫了)。前端

TS的學習成本實際上並不高(的確是,具體緣由我在下面會講,別急着錘我),我我的認爲它能夠被分紅兩個部分:node

  • 預實現的ES提案,如 裝飾器(我以前的一篇文章 走近MidwayJS:初識TS裝飾器與IoC機制 中講了一些關於TS裝飾器的歷史, 有興趣的能夠看看), 可選鏈?. ,空值合併運算符??,類的私有成員private等。除了部分語法如裝飾器之外,大部分的預實現實際上就是將來的ES語法。對於這一部分來講,不管你先前是隻學習過JS(就像我同樣),仍是有過Java、C#的使用經歷,都能很是快速地上手,這也是實際開發中使用最多的部分,畢竟和另外一塊-類型編程比起來,仍是這一部分更接地氣。
  • 類型編程,不管是一個普通接口(interface)或是類型別名type,仍是密密麻麻的extends infer 工具類型blabla...(下文會展開介紹),我我的認爲都屬於類型編程的範疇。這一塊實際上對代碼的功能層面沒有任何影響,即便你把它寫成anyscript,代碼該咋樣仍是咋樣。而這也就是類型編程一直不受到太多重視的緣由:相比於語法,它會帶來代碼量大大增多(可能接近甚至超過業務代碼),編碼耗時增加(頭髮--)等問題,而帶來的惟一好處就是 類型安全 , 包括如臂使指的類型提示(VS Code YES!),進一步減小可能存在的調用錯誤,以及下降維護成本。看起來彷佛有得有失,但實際上,假設你花費1單位腦力使用基礎的TS以及簡單的類型編程,你就可以得到5個單位的回饋。但接下來,有可能你花費10個單位腦力,也只能再得到2個單位的回饋。另一個類型編程不受重視的緣由則是實際業務中並不會須要多麼苛刻的類型定義,一般是底層框架類庫纔會有此類需求,這一點就見仁見智了,但我想沒人會想永遠當業務仔吧(沒有陰陽怪氣的意思)。

正文部分包括:react

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

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

爲了適配全部基礎的讀者,本文會講解的儘量細緻,若是你已經熟悉某部分知識,請跳過~程序員

泛型 Generic Type

假設咱們有這麼一個函數:github

function foo(args: unknown): unknown { ... }
複製代碼

若是它接收一個字符串,返回這個字符串的部分截取,若是接收一個數字,返回這個數字的n倍,若是接收一個對象,返回鍵值被更改過的對象(鍵名不變),若是這時候須要類型定義,是否要把unknown替換爲string | number | object? 這樣當然能夠,但別忘記咱們須要的是 入參與返回值類型相同 的效果。這個時候泛型就該登場了,泛型使得代碼段的類型定義易於重用(好比咱們上面提到的場景又多了一種接收布爾值返回布爾值的場景後的修改),並提高了靈活性與嚴謹性:算法

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

function foo<T>(arg: T): T {
  return arg;
}
複製代碼

咱們使用T來表示一個未知的類型,它是入參與返回值的類型,在使用時咱們能夠顯示指定泛型:編程

foo<string>("linbudu")
const [count, setCount] = useState<number>(1)
複製代碼

固然也能夠不指定,由於TS會自動推導出泛型的實際類型。redux

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

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 objectU 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關鍵字

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 {
  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

恭喜,你獲得了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類型,它表示永遠不會出現的類型,一般被用來將收窄聯合類型或是接口,詳細能夠看 尤大的知乎回答, 在這裏 咱們不作展開介紹。

上面的場景其實能夠簡化爲:

// "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-guidetypesafe-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;
複製代碼

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內部的實現實際比這個複雜,還考慮了數組的狀況,這裏爲了便於理解作了簡化,後面的工具類型也一樣存在此類簡化。

那麼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,說實在的,這兩者的結合給我一種十分詭異的感受,像是在介紹前女朋友給如今的女友認識...

相關文章
相關標籤/搜索