TypeScript 的另外一面:類型編程

做者:穹心javascript

前言

做爲前端開發的趨勢之一,TypeScript 正在爲愈來愈多的開發者所喜好,從大的方面來講,幾乎九成的框架與工具庫都以其寫就(或者就是相似的類型方案,如 Flow);而從小的方面來講,即便是寫個配置文件(如 vite 的配置文件)或者小腳本(感謝 ts-node),TypeScript 也是一大助力。同樣事物不可能作到每一個人都喜歡,如 nodemon 的做者 Remy Sharp 就曾表示本身歷來沒有使用過 TS(見 #1565),在之後也不會去學習 TS,這多是由於語言習慣的問題。而一般阻礙新人上手 TypeScript 的還有另一座大山:學習成本高。前端

在學習 TypeScript 的開始階段,不少同窗對它是又愛又恨的,離不開它的類型提示和工程能力,卻又常常爲類型錯誤困擾,最後不得不用個 any 了事,這樣的狀況多了,TypeScript 就慢慢寫成了 AnyScript...java

這個問題的罪魁禍首其實就是部分同窗在開始學習 TypeScript 時,要麼是被逼上梁山,在一片空白的狀況下接手 TS 項目,要麼是不知道如何學習,那麼良心的官方文檔不看,看了幾篇相關文章就以爲本身會了,最後遇到問題仍是一頭霧水。node

這篇文章就是爲了解決這後者的問題,嘗試專一於 TypeScript 的類型編程部分(TS 還有幾個部分?請看下面的解釋),從最基礎的泛型開始,到索引、映射、條件等類型,再到 is、in、infer 等關鍵字,最後是壓軸的工具類型。打開你的 IDE,跟着筆者一節節的敲完代碼,幫助你的 TypeScript 水平邁上新的臺階。react

須要注意的是,本文並不是 TypeScript 入門文章,並不適用於對 TypeScript 暫時沒有任何經驗的同窗。若是你仍處於新手期,筆者在這裏推薦 xcatliu 的 TypeScript 入門教程 以及 官方文檔,從我我的的經驗來看,你能夠在初期閱讀入門教程,並在感到困惑時前往官方文檔對應部分查閱。git

在完成 TypeScript 的基礎入門後,歡迎再次回到本篇文章。程序員

TypeScript = 類型編程 + ES 提案

筆者一般將 TypeScript 劃分紅兩個部分:github

  • 預實現的 ES 提案,如 裝飾器、 可選鏈?. 、空值合併運算符??(和可選鏈一塊兒在 TypeScript3.7 中引入)、類的私有成員 private 等。除了部分極端不穩定的語法(說的就是你,裝飾器)之外,大部分的 TS 實現實際上就是將來的 ES 語法。chrome

    嚴謹的來講,如今的 ES 版本裝飾器和 TS 版本裝飾器已是兩個東西了,筆者先前在 走近 MidwayJS:初識 TS 裝飾器與 IoC 機制 這篇文章中介紹了一些關於 TS 裝飾器的歷史,有興趣的同窗不妨一讀。typescript

    對於這一部分來講,不管你先前是隻有 JavaScript 這門語言的使用經驗,仍是有過 Java、C#的使用經歷,都能很是快速地上手,畢竟主要仍是語法糖爲主嘛。固然,這也是實際開發中使用最多的部分,畢竟和另外一部分:類型編程比起來,仍是這一部分更接地氣。

  • 類型編程,從一個簡簡單單的interface,到看起來挺高級的T extends SomeType ,再到各類不明覺厲的工具類型PartialRequired等,這些都屬於類型編程的範疇。這一部分對代碼實際的功能層面沒有任何影響,即便你一行代碼十個 any,遇到類型錯誤就 @ts-ignore (相似於@eslint-ignore,將會禁用掉下一行的類型檢查),甚至直接開啓 --transpileOnly (這一選項會禁用掉 TS 編譯器的類型檢查能力,僅編譯代碼,會得到更快的編譯速度·),也不會影響你代碼自己的邏輯。
    然而,這也就是類型編程一直不受到太多重視的緣由:相比於語法,它會帶來許多額外的代碼量(類型定義代碼甚至可能超過業務代碼量)等問題。並且實際業務中並不會須要多麼苛刻的類型定義,一般只會對接口數據、應用狀態流等進行定義,一般是底層框架類庫纔會須要大量的類型編程代碼。
    若是說,上一部分讓你寫的代碼更甜,那麼這一部分,最重要的做用是讓你的代碼變得更優雅健壯(是的,優雅和健壯並不衝突)。若是你所在的團隊使用 Sentry 這一類監控平臺,對於 JS 代碼來講最多見的錯誤就是Cannot read property 'xxx' of undefinedundefined is not a function這種(見top-10-javascript-errors),雖然即便是 TS 也不可能把這個錯誤直接徹底抹消,但也能解決十之八九了。

好了,作了這麼多鋪墊,是時候開始進入正題了,本文的章節分佈以下,若是你已經有部分前置知識的基礎(如泛型),能夠直接跳過。

  • 類型編程的基礎:泛型

  • 類型守衛與 is、in 關鍵字

  • 索引類型與映射類型

  • 條件類型、分佈式條件類型

  • infer 關鍵字

  • 工具類型

  • TypeScript 4.x 新特性

泛型

之因此上來就放泛型,是由於在 TypeScript 的整個類型編程體系中,它是最基礎的那部分,全部的進階類型都基於它書寫。就像編程時咱們不能沒有變量,類型編程中的變量就是泛型。

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

function foo(args: unknown): unknown { ... }
複製代碼
  • 若是它接收一個字符串,返回這個字符串的部分截取。

  • 若是接收一個數字,返回這個數字的 n 倍。

  • 若是接收一個對象,返回鍵值被更改過的對象(鍵名不變)。

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

若是在這裏要得到精確地類型定義,應該怎麼作?

  • unknown 替換爲 string | number | object ?但這樣表明的意思是這個函數接受任何值,其返回類型均可能是 string / number / object,雖然有了類型定義,但徹底稱不上是精確。

別忘記咱們須要的是 入參與返回值類型相同 的效果。這個時候泛型就該登場了,咱們先用一個泛型收集參數的類型值,再將其做爲返回值,就像這樣:

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

這樣在咱們使用 foo 函數時,編輯器就能實時根據咱們傳入的參數肯定此函數的返回值了。就像編程時,程序中變量的值會在其運行時才被肯定,泛型的值(類型)也是在方法被調用、類被實例化等相似的執行過程實際發生時纔會被肯定的。

泛型使得代碼段的類型定義易於重用(好比後續又多了一種接收 boolean 返回 boolean 的函數實現),並提高了靈活性與嚴謹性。

另外,你可能曾經見過 Array<number> Map<string, ValueType> 這樣的使用方式,一般咱們將上面例子中 T 這樣的未賦值形式成爲 類型參數變量 或者說 泛型類型,而將 Array<number> 這樣已經實例化完畢的稱爲 實際類型參數 或者是 參數化類型

一般泛型只會使用單個字母。如 T U K V S等。個人推薦作法是在項目達到必定複雜度後,使用帶有具體意義的泛型變量聲明,如 BasicBusinessType 這種形式。

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

上面的例子也能夠不指定,由於 TS 會自動推導出泛型的實際類型,在部分 Lint 規則中,實際上也不推薦添加可以被自動推導出的類型值。

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

const foo = <T>(arg: T) => arg;
複製代碼

若是你在 TSX 文件中這麼寫,<T>可能會被識別爲 JSX 標籤,所以須要顯式告知編譯器:

const foo = <T extends SomeBasicType>(arg: T) => arg;
複製代碼

除了用在函數中,泛型也能夠在類中使用:

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

  public method(): T {
    return this.arg1;
  }
}
複製代碼

單獨對於泛型的介紹就到這裏(由於單純的講泛型實在沒有什麼好講的),在接下來的進階類型篇章中,咱們會講解更多泛型的使用。

類型守衛、is in關鍵字

咱們來從相對簡單直觀的知識點:類型守衛 開始,由淺入深的瞭解基於泛型的類型編程。

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

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

這應該是我平常用的最多的類型別名之一了,相似的,還有 isPrimitiveisFunction這樣的類型守衛。

而使用 in 關鍵字,咱們能夠進一步收窄類型(Type Narrowing),思考下面這個例子,要如何將 " 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 身上。

因爲A、B兩個類型的交集並不包含 a 這個屬性,因此這裏的 in 判斷會精確地將類型對應收窄到三元表達式的先後。即 A 或者 B。

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

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;
  }
}
複製代碼

關於字面量類型literal types,它是對類型的進一步限制,好比你的狀態碼只多是 0/1/2,那麼你就能夠寫成 status: 0 | 1 | 2 的形式,而不是用一個 number 來表達。

字面量類型包括 字符串字面量數字字面量布爾值字面量,以及4.1版本引入的模板字面量類型(這個咱們會在後面展開講解)。

  • 字符串字面量,常見如 mode: "dev" | "prod"

  • 布爾值字面量一般與其餘字面量類型混用,如 open: true | "none" | "chrome"

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

基於字段區分接口

我在平常常常看到有同窗在問相似的問題:登陸與未登陸下的用戶信息是徹底不一樣的接口,或者是

以前有個小哥問過一個問題,我想不少用 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;
}
複製代碼

或者經過字面量類型:

interface ICommonUserProps {
  type: "common",
  accountLevel: string
}

interface IVIPUserProps {
  type: "vip";
  vipLevel: string;
}

type UserProps = ICommonUserProps | IVIPUserProps;

function getUserInfo(user: ICommonUserProps | IVIPUserProps): string {
  return user.type === "common" ? user.accountLevel : user.vipLevel;
}
複製代碼

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

索引類型與映射類型

索引類型

在閱讀這一部分前,你須要作好思惟轉變的準備,須要真正認識到 類型編程實際也是編程,由於從這裏開始,咱們就將真正將泛型做爲變量進行各類花式操做了。

就像你寫業務代碼的時候經常會遍歷一個對象,而在類型編程中咱們也會常常遍歷一個接口。所以,你徹底能夠將一部分編程思路複用過來。首先實現一個簡單的函數,它返回一個對象的某個鍵值:

// 假設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索引類型查詢 的語法, 它會返回後面跟着的類型參數的鍵值組成的字面量聯合類型,舉個例子:

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

type A = keyof foo; // "a" | "b"
複製代碼

是否是就像 Object.keys() 同樣?區別就在於它返回的是聯合類型。

聯合類型 Union Type 一般使用 | 語法,表明多個可能的取值,實際上在最開始咱們就已經使用過了。聯合類型最主要的使用場景仍是 條件類型 部分,這在後面會有一個完整的章節來進行講解。

還少了返回值,若是你此前沒有接觸過此類語法,應該會卡住,咱們先聯想下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"之一,即 "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'])
複製代碼

有兩個重要變化:

  • keys: U[] 咱們知道 U 是 T 的鍵名組成的聯合類型,那麼要表示一個內部元素均是 T 鍵名的數組,就可使用這種方式,具體的原理請參見下文的 分佈式條件類型 章節。

  • T[U][] 它的原理實際上和上面一條相同,首先是T[U],表明參數1的鍵值(就像Object[Key]),我認爲它是一個很好地例子,表現了 TS 類型編程的組合性,你不感受這種寫法就像搭積木同樣嗎?

索引簽名 Index Signature

在JavaScript中,咱們一般使用 arr[1] 的方式索引數組,使用 obj[key] 的方式索引對象。說白了,索引就是你獲取一個對象成員的方式,而在類型編程中,索引簽名用於快速創建一個內部字段類型相同的接口,如

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'],將會拋出錯誤, 元素隱式具備 "any" 類型,由於索引表達式的類型不爲 "number"。

映射類型 Mapped Types

在開始映射類型前,首先想一想 JavaScript 中數組的 map 方法,經過使用map,咱們從一個數組按照既定的映射關係得到一個新的數組。在類型編程中,咱們則會從一個類型定義(包括但不限於接口、類型別名)映射獲得一個新的類型定義。一般會在舊有類型的基礎上進行改造,如:

  • 修改原接口的鍵值類型

  • 爲原接口鍵值類型新增修飾符,如 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 ClonedA<T> = {
  [K in keyof T]: T[K];
};
複製代碼

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

你能夠把工具類型理解爲你平時放在 utils 文件夾下的公共函數,提供了對公用邏輯(在這裏則是類型編程邏輯)的封裝,好比上面的兩個類型接口就是。關於更多工具類型,參考 工具類型 一章。

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

// 將接口下的字段所有變爲可選的
type Partial<T> = {
  [K in keyof T]?: T[k];
};
複製代碼

key?: value 意爲這一字段是可選的,在大部分狀況下等同於 key: value | undefined

條件類型 Conditional Types

在編程中遇到條件判斷,咱們經常使用 If 語句與三元表達式實現,我我的偏心後者,即便是:

if (condition) {
  execute()
}
複製代碼

這種沒有 else 的 If 語句,我也習慣寫成:

condition ? execute() : void 0;
複製代碼

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

T extends U ? X : Y
複製代碼

若是你以爲這裏的 extends 不太好理解,能夠暫時簡單理解爲 U 中的屬性在 T 中都有。

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

類比到編程語句中,其實就是根據條件判斷來動態的賦予變量值:

let unknownVar: string;

unknownVar = condition ? "淘系前端" : "淘寶FED";

type LiteralType<T> = T extends string ? "foo" : "bar";
複製代碼

條件類型理解起來其實也很直觀,惟一須要有必定理解成本的就是 什麼時候條件類型系統會收集到足夠的信息來肯定類型,也就是說,條件類型有時不會馬上完成判斷,好比工具庫提供的函數,須要用戶在使用時傳入參數纔會完成 條件類型 的判斷。

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

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 鍵名的字面量聯合類型(不記得了?提示:1 | 2 | 3)。咱們一般使用泛型約束來 收窄類型約束,簡單的說,泛型自己是來者不拒的,全部類型都能被 顯式傳入(如 Array<number>) 或者 隱式推導 (如 foo(1)),這樣其實不是咱們想要的,就像咱們有時會檢測函數的參數:

function checkArgFirst(arg){
  if(typeof arg !== "number"){
    throw new Error("arg must be number type!")
  }
}
複製代碼

在 TS 中,咱們經過泛型約束,要求傳入的泛型只能是固定的類型,如 T extends {} 約束泛型至對象類型,T extends number | string將泛型約束至數字與字符串類型。

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

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

// "N" | "Y"
type Distributed = Naked<number | boolean>;

// "N"
type NotDistributed = Wrapped<number | boolean>;
複製代碼
  • 其中,Distributed類型別名,其類型參數(number | boolean)會正確的分發,即
    先分發到 Naked<number> | Naked<boolean>,再進行判斷,因此結果是"N" | "Y"

  • 而 NotDistributed 類型別名,第一眼看上去感受TS應該會自動按數組進行分發,結果應該也是 "N" | "Y" ?但實際上,它的類型參數(number | boolean)不會有分發流程,直接進行[number | boolean] extends [boolean]的判斷,因此結果是"N"

如今咱們能夠來說講這幾個概念了:

  • 裸類型參數,沒有額外被[]包裹過的,就像被數組包裹後就不能再被稱爲裸類型參數。

  • 實例化,其實就是條件類型的判斷過程,就像咱們前面說的,條件類型須要在收集到足夠的推斷信息以後才能進行這個過程。在這裏兩個例子的實例化過程其實是不一樣的,具體會在下一點中介紹。

  • 分發到聯合類型:

  • 對於 TypeName,它內部的類型參數 T 是沒有被包裹過的,因此 TypeName<string | (() => void)> 會被分發爲 TypeName<string> | TypeName<(() => void)>,而後再次進行判斷,最後分發爲"string" | "function"

  • 抽象下具體過程:

    ( A | B | C ) extends T ? X : Y
    // 至關於
    (A extends T ? X : Y) | (B extends T ? X : Y) | (B extends T ? X : Y)
    
    // 使用[]包裹後,不會進行額外的分發邏輯。
    [A | B | C] extends [T] ? X : Y
    複製代碼

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

這兩種行爲沒有好壞之分,區別只在因而否進行聯合類型的分發,若是你須要走分佈式條件類型,那麼注意保持你的類型參數爲裸類型參數。若是你想避免這種行爲,那麼使用 [] 包裹你的類型參數便可(注意在 extends 關鍵字的兩側都須要)。

infer 關鍵字

在條件類型中,咱們展現瞭如何經過條件判斷來延遲肯定類型,但僅僅使用條件類型也有必定不足:它沒法從條件上獲得類型信息。舉例來講,T extends Array<PrimitiveType> ? "foo" : "bar"這一例子,咱們不能從做爲條件的 Array<PrimitiveType> 中獲取到 PrimitiveType 的實際類型。

而這樣的場景又是十分常見的,如獲取函數返回值的類型、拆箱Promise / 數組等,所以這一節咱們來介紹下 infer 關鍵字。

inferinference 的縮寫,一般的使用方式是用於修飾做爲類型參數的泛型,如: infer RR表示 待推斷的類型。一般 infer不會被直接使用,而是與條件類型一塊兒,被放置在底層工具類型中。若是說條件類型提供了延遲推斷的能力,那麼加上 infer 就是提供了基於條件進行延遲推斷的能力。

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

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

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

// string
type FooReturnType = ReturnType<typeof foo>;
複製代碼
  • (...args: any[]) => infer R 是一個總體,這裏函數的返回值類型的位置被 infer R 佔據了。

  • ReturnType 被調用,類型參數 T 、R 被顯式賦值(T爲 typeof fooinfer R被總體賦值爲string,即函數的返回值類型),若是 T 知足條件類型的約束,就返回 infer 完畢的R 的值,在這裏 R 即爲函數的返回值實際類型。

  • 實際上爲了嚴謹,應當約束泛型T爲函數類型,即:

    // 第一個 extends 約束可傳入的泛型只能爲函數類型
    // 第二個 extends 做爲條件判斷
    type ReturnType<T extends (...args: any[]) => any> = T extends (...args: any[]) => infer R ? R : never;
    複製代碼

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

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

另外,對於 TS 中函數重載的狀況,使用 infer (如上面的 ReturnType)不會爲全部重載執行推導過程,只有最後一個重載(由於通常來講最後一個重載一般是最普遍的狀況)會被使用。

工具類型 Tool Type

這一章應該是本文「性價比」最高的一部分了,由於即便你在閱讀完這部分後,仍是不太懂這些工具類型是如何實現的,也不影響你把它用的恰到好處,就像 Lodash 不會要求你對每一個使用的函數都熟知原理同樣。

這一部分包括 TS 內置工具類型 與社區的 擴展工具類型,我我的推薦在完成學習後挑選一部分工具類型記錄下來,好比你以爲比較有價值、現有或者將來業務可能會使用,或者僅僅是以爲很好玩的工具類型,並在本身的項目裏新建一個.d.ts文件(或是 /utils/tool-types.ts 這樣)存儲它。

在繼續閱讀前,最好確保你掌握了上面的知識,它們是工具類型的基礎。

內置工具類型

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

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

它用於將一個接口中的字段所有變爲可選,除了索引類型以及映射類型之外,它只使用了?可選修飾符,那麼我如今直接掏出小抄:

  • 去除可選修飾符:-?,位置與 ? 一致

  • 只讀修飾符:readonly,位置在鍵名,如 readonly key: string

  • 去除只讀修飾符:-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];
};

// 指望用法
// 指望結果 A["a"]類型 | A["b"]類型
type Part = Pick<A, "a" | "b">;
複製代碼

仍是映射類型,只不過如今映射類型的映射源是傳入給 Pick 的類型參數K。

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

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

這裏咱們又要引入一個知識點:never類型,它表示永遠不會出現的類型,一般被用來將收窄聯合類型或是接口,或者做爲條件類型判斷的兜底。詳細能夠看 尤大的知乎回答, 在這裏咱們不作展開介紹。

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

// "3" | "4" | "5"
type LeftFields = Exclude<"1" | "2" | "3" | "4" | "5", "1" | "2">;
複製代碼

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

這裏實際上使用到了分佈式條件類型的特性,假設 Exclude 接收 T U 兩個類型參數,T 聯合類型中的類型會依次與 U 類型進行判斷,若是這個類型參數在 U 中,就剔除掉它(賦值爲 never)

接地氣的版本:"1""1" | "2" 裏面嗎( "1" extends "1"|"2" -> true )? 在的話,就剔除掉它(賦值爲never),不在的話就保留。

type Exclude<T, U> = T extends U ? never : T;
複製代碼

那麼 Omit就很簡單了,對原接口的成員,剔除掉傳入的聯合類型成員,應用 Pick 便可。

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;
};
複製代碼

注意,Record也支持 Record<string, unknown> 這樣的使用方式, string extends keyof any 也是成立的,由於 keyof 的最終結果必然是 string 組成的聯合類型(除了使用數字做爲鍵名的狀況...)。

在前面的 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 這兩個優秀做品。

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

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

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會保留全部字段,知足條件的字段其鍵值爲字面量類型(即鍵名),不知足的則爲never。

加上後面一部分:

// "d"
type UseIt2 = UseIt1[keyof UseIt1];
複製代碼

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

// 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爲可選的狀況下則能夠。

所以,咱們使用這種思路來獲得可選/必選的鍵名。

  • {} extends Pick<T, K>,若是K是可選字段,那麼就留下(OptionalKeys,若是是 RequiredKeys 就剔除)。

  • 怎麼剔除?固然是用never了。

    export type RequiredKeys = { [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干擾,能夠理解爲就是用於比較的包裝,這一層包裝可以區分出來只讀與非只讀屬性。即 (<T>() => T extends X ? 1 : 2) 這一部分,只有在類型參數 X 徹底一致時,兩個 (<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 獲得一個接口中類型爲函數的字段。

另外,實際上上面的部分工具類型是能夠用重映射能力實現的更加簡潔優雅的,這不嘗試下?

受限於篇幅(本文到這裏已經1.3w字了),原本還想放上來的 type-fest 的工具類型就只能遺憾退場了,但我仍是建議你們去讀一讀它的源碼。相比於上面的 utility-types 更加接地氣,實現思路也更加有趣。

TypeScript 4.x 中的部分新特性

這一部分是相對於以前的版本新增的部分,主要包括了4.1 - 4.4(Beta)版本中引入的一部分與本文介紹內容有關的新特性,包括 模板字面量類型 與 重映射。

模板字面量類型

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

type World = 'world';

// "hello world"
type Greeting = `hello ${World}`;
複製代碼

模板字面量類型一樣支持分佈式條件類型,如:

export type SizeRecord<Size extends string> = `${Size}-Record`

// "Small-Record"
type SmallSizeRecord = SizeRecord<"Small">
// "Middle-Record"
type MiddleSizeRecord = SizeRecord<"Middle">
// "Huge-Record"
type HugeSizeRecord = SizeRecord<"Huge">


// "Small-Record" | "Middle-Record" | "Huge-Record"
type UnionSizeRecord = SizeRecord<"Small" | "Middle" | "Huge">
複製代碼

還有個有趣的地方,模板插槽(${})中能夠傳入聯合類型,而且同一模板中若是存在多個插槽,各個聯合類型將會被分別排列組合。

// "Small-Record" | "Small-Report" | "Middle-Record" | "Middle-Report" | "Huge-Record" | "Huge-Report"
type SizeRecordOrReport = `${"Small" | "Middle" | "Huge"}-${"Record" | "Report"}`;
複製代碼

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

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,做者Anders Hejlsberg 是 C# 與 Delphi 的首席架構師,同時也是TS的做者之一。

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

TS 的實現代碼:

function applyStringMapping(symbol: Symbol, str: string) {
 switch (intrinsicTypeKinds.get(symbol.escapedName as string)) {
     case IntrinsicTypeKind.Uppercase: return str.toUpperCase();
     case IntrinsicTypeKind.Lowercase: return str.toLowerCase();
     case IntrinsicTypeKind.Capitalize: return str.charAt(0).toUpperCase() + str.slice(1);
     case IntrinsicTypeKind.Uncapitalize: return str.charAt(0).toLowerCase() + str.slice(1);
 }
 return str;
}
複製代碼

你可能會想到,模板字面量若是想截取其中的一部分要怎麼辦?這裏可無法調用 slice 方法。其實思路就在咱們上面提到過的 infer,使用 infer 佔位後,便可以提取出字面量的一部分,如:

type CutStr<Str extends string> = Str extends `${infer Part}budu` ? Part : never

// "lin"
type Tmp = CutStr<"linbudu">
複製代碼

再進一步,[1,2,3]這樣的字符串,若是咱們提供 [${infer Member1}, ${infer Member2}, ${infer Member}] 這樣的插槽匹配,就能夠實現神奇的提取字符串數組成員效果:

type ExtractMember<Str extends string> = Str extends `[${infer Member1}, ${infer Member2}, ${infer Member3}]` ? [Member1, Member2, Member3] : unknown;

// ["1", "2", "3"]
type Tmp = ExtractMember<"[1, 2, 3]">
複製代碼

注意,這裏的模板插槽被使用 , 分隔開了,若是多個帶有 infer 的插槽緊挨在一塊兒,那麼前面的 infer 只會得到單個字符,最後一個 infer 會得到全部的剩餘字符(若是有的話),好比咱們把上面的例子改爲這樣:

type ExtractMember<Str extends string> = Str extends `[${infer Member1}${infer Member2}${infer Member3}]` ? [Member1, Member2, Member3] : unknown;

// ["1", ",", " 2, 3"]
type Tmp = ExtractMember<"[1, 2, 3]">
複製代碼

這一特性使得咱們可使用多個相鄰的 infer + 插槽,對最後一個 infer得到的值進行遞歸操做,如:

type JoinArrayMember<T extends unknown[], D extends string> =
  T extends [] ? '' :
  T extends [any] ? `${T[0]}` :
  T extends [any, ...infer U] ? `${T[0]}${D}${JoinArrayMember<U, D>}` :
  string;

// ""
type Tmp1 = JoinArrayMember<[], '.'>;
// "1"
type Tmp3 = JoinArrayMember<[1], '.'>;
// "1.2.3.4"
type Tmp2 = JoinArrayMember<[1, 2, 3, 4], '.'>;
複製代碼

原理也很簡單,每次將數組的第一個成員添加上.,在最後一個成員時不做操做,在最後一次匹配([])返回空字符串,便可。

又或者反過來?把 1.2.3.4 迴歸到數組形式?

type SplitArrayMember<S extends string, D extends string> =
  string extends S ? string[] :
  S extends '' ? [] :
  S extends `${infer T}${D}${infer U}` ? [T, ...SplitArrayMember<U, D>] :
  [S];

type Tmp11 = SplitArrayMember<'foo', '.'>;  // ['foo']
type Tmp12 = SplitArrayMember<'foo.bar.baz', '.'>;  // ['foo', 'bar', 'baz']
type Tmp13 = SplitArrayMember<'foo.bar', ''>;  // ['f', 'o', 'o', '.', 'b', 'a', 'r']
type Tmp14 = SplitArrayMember<any, '.'>;  // stri
複製代碼

最後,看到 a.b.c 這樣的形式,你應該想到了 Lodash 的 get 方法,即經過 get({},"a.b.c") 的形式快速得到嵌套屬性。可是這樣要怎麼提供類型聲明?有了模板字面量類型後,只須要結合 infer + 條件類型便可。

type PropType<T, Path extends string> =
    string extends Path ? unknown :
    Path extends keyof T ? T[Path] :
    Path extends `${infer K}.${infer R}` ? K extends keyof T ? PropType<T[K], R> : unknown :
    unknown;

declare function getPropValue<T, P extends string>(obj: T, path: P): PropType<T, P>;
declare const s: string;

const obj = { a: { b: {c: 42, d: 'hello' }}};
getPropValue(obj, 'a');  // { b: {c: number, d: string } }
getPropValue(obj, 'a.b');  // {c: number, d: string }
getPropValue(obj, 'a.b.d');  // string
getPropValue(obj, 'a.b.x');  // unknown
getPropValue(obj, s);  // unknown
複製代碼

重映射

這一能力在 TS 4.1 中引入,提供了在映射類型中重定向映射源至新類型的能力,這裏的新類型能夠是工具類型的返回結果、字面量模板類型等,用於解決在使用映射類型時,咱們想要過濾/新增拷貝的接口成員,一般會將原接口成員的鍵做爲新的轉換方法參數,如:

type Getters<T> = {
    [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
};

interface Person {
    name: string;
    age: number;
    location: string;
}

type LazyPerson = Getters<Person>;
複製代碼

轉換後的結果:

type LazyPerson = {
    getName: () => string;
    getAge: () => number;
    getLocation: () => string;
}
複製代碼

這裏的 string & k 是由於重映射的轉換方法(即 as 後面的部分)必須是可分配給 string | number | symbol 的,而 K 來自於 keyof,可能包含 symbol 類型,這樣的話是不能交給模板字面量類型使用的。

若是轉換方法返回了never,那麼這個成員就被除去了,因此咱們可使用這個方法來過濾掉成員。

type RemoveKindField<T> = {
    [K in keyof T as Exclude<K, "kind">]: T[K]
};

interface Circle {
    kind: "circle";
    radius: number;
}

// type KindlessCircle = {
//     radius: number;
// }
type KindlessCircle = RemoveKindField<Circle>;
複製代碼

最後,當與模板字面量一同使用時,因爲其排列組合的特性,若是重映射的轉換方法是一個由 模板字面量類型 組成的 聯合類型,那麼就會從排列組合獲得多個成員。

type DoubleProp<T> = { [P in keyof T & string as `${P}1` | `${P}2`]: T[P] }
type Tmp = DoubleProp<{ a: string, b: number }>;  // { a1: string, a2: string, b1: number, b2: number }
複製代碼

尾聲

這篇文章確實很長很長,因不建議一次性囫圇吞棗的讀完,建議選取幾段有必定長度的連續時間,給它掰開了揉碎了好好讀懂。寫文不易,尤爲是寫這麼長的文章,可是若是能幫助你的 TypeScript 更上一層樓,就徹底值得了。

若是在以前,你從未關注過類型編程方面,那麼閱讀完畢後可能須要必定時間來適應思路的轉變。仍是那句話,認識到 類型編程的本質也是編程。固然,你也能夠漸進式的開始實踐這一點,好比從今天開始,從如今手頭裏的項目開始,從泛型到類型守衛,從索引/映射類型到條件類型,從使用工具類型到封裝工具類型,一步步變成 TypeScript 高高手。

相關文章
相關標籤/搜索