TypeScript 從入門到實踐

介紹

衆所周知 JavaScript 是一門弱類型語言,在前端進入工程化後,代碼倉庫越來越大,JavaScript 弱類型的缺點被無限放大,使其難以勝任開發大型項目。在一個多人維護的項目中,往往不知道別人寫的函數是什麼意思、需要傳入什麼參數、返回值是什麼,一個用法不小心就會導致線上出現 BUG,所以除了靠口口相傳以外還要維護大量的代碼註釋或者接口文檔來提供其他人瞭解。但是當我們使用 TypeScript 後,除了初期具有一定的學習成本以外,基本上可以很好的解決上述的問題。

TypeScriptJavaScript 的嚴格超集,這意味着任何合法的 JavaScript 代碼在 TypeScript 中都是合法的。TS 的作者是 安德斯 · 海爾斯伯格 [1],2012 年 10 月,微軟發佈了首個公開版本的 TypeScript,2013 年 6 月 19 日正式發佈了正式版 TypeScript。根據 Roadmap[2] 我們可以知道 TS 官方每隔三個月會更新一個正式的 minor 版本,比如會在今年的 5.27 發佈了 v4.7[3]。更多關於 TS 的故事可以查看 TypeScript 團隊成員 orta 的文章:Understanding TypeScript's Popularity[4]

當然在 TS 徹底大火之前,同期也存在很多相似的工具來輔助開發者做好類型提示,比如 Flow[5] 、JSDoc[6] 等。

JSDoc,通過註釋的方式給 add 函數添加類參數類型和返回值的類型。通過在編輯器頂部添加 @ts-check 的註釋開始 VSCode 對其的檢查。

Flow,類似 TS 的寫法添加類型註解,可以通過安裝插件或者命令掃描出當前代碼中有問題的地方。

根據 npm 的現在趨勢,目前 Flow 的使用率比 TS 低了很多。

經過衆多的開發者選擇,目前來看 TS已經是完全勝出了。許多著名的開源庫都採用 TS 進行編寫。比如 VueJS,其中 v2.x 版本是使用的 Flow 進行的類型編寫,但是 v3.x 版本已經全部遷移到了 TS。至於爲什麼選擇 TS 替代 Flow 可以參看 尤玉溪的回答 [7] 。

所以說 TS 逐漸的得到了社區的認可,就像 HTMLCSSJS 一樣快成爲一名前端開發者必備的技能。所以我們不得不去學習它、並將它靈活的運用在項目當中。

基礎使用

  1. 基本類型的介紹與適用場景

  2. 交叉和聯合類型

  3. 類型檢查機制:推斷、斷言、保護和守衛

  4. 全局類型、類型引入、類型重寫、類型合併(interface 和 type 的區別)

基礎類型

由於 TSJS 的嚴格超集,所以 JS 中支持的類型在 TS 中肯定支持,所以在 JS 代碼中使用什麼類型的變量,在 TS 中也使用該類型。

TS 具有類型自動推斷的能力,比如當我們使用 const 或者 let 聲明一個變量的時,如果直接有賦值,那麼就會給當前變量設置成這個賦值的類型。如下:

let str = 'hello'; // let str: string;
let num = 666;  // let num: number;
let bool = true; // let bool: boolean
let undef = undefined; // let undef: undefined
let nul = null; // let nul: null
let sym = Symbol(123); // let sym: symbol
const fn = () => 123; // const fn: () => number
let res = fn(); // let res: number
let arr = [1, 2, 'abc']; // let a: (string | number)[]
const obj = {
    a: 1,
    b: true,
}
/* const obj: {
    a: number;
    b: boolean;
}*/

在有些同學的嘗試過程中,當賦值爲 undefinednull 時,會自動推斷成 any 。這是由於 tsconfig.json 中的配置有問題,把 compilerOptions.strictNullChecks 設置爲 true 即可。目前發現團隊中有部分項目都沒有開啓該配置,那麼可能會在運行時出現意料之外的 BUG

新增的類型

當然爲了代碼的靈活編寫 TS 還具有一些獨特的類型:

類型間的關係

通過上文我們可以得出類型之間的關係:

以上表格是在嚴格模式下,即 strictNullCheckstrue 也是推薦的配置

聯合與交叉

我們在實際的開發過程中變量的類型並不是單一的,比如 array.find 既可以返回數據的類型也可以返回 undefined,或者說我們寫一個 mixin 的方法需要將 2 個類型合併。

交叉類型

交叉類型是使用 & 符合將多個類型合併成一個類型。如:type C = A & B 這樣 C 類型 就既有 A 的類型 也有 B 的類型。常見的如 Object.assign 方法,可以將對象進行合併,所以就需要這樣的方法將每個對象的類型進行合併,或者說我們在編寫 React 高階組件時,編寫的過程中就可以對已有類型進行拓展。

interface Props {
  name: string;
  age: number;
}
interface WithHOCProps {
  options: {
    size: number;
  }
}

const App: React.FC<Props & WithHOCProps> = ({
  options,
}) ={
  options.size // number
}

由於是類型合併,所以可能會遇到 2 個類型不兼容的情況,所以如果遇到不兼容的類型就會推導出 never

interface Item1 {
  id: string;
  name: string;
}
interface Item2 {
  id: number;
  age: number;
}

type C = Item1 & Item2;
type Id = C['id'] // never
type Name = C['name'] // string
type Age = C['age'] // number

因爲上文(類型間的關係)已經展示出了不一樣的類型也存在可以相互轉換的。所以 A & B的運算關係可以看成:

type T1 = number & string; // never
type T2 = number & unknown; // number
type T3 = number & any; // any
type T4 = number & never; // never

聯合類型

聯合類似是使用 | 符合將多個類型聯繫起來,如:C = A | B 表明 C 要麼等於類型 A 要麼等於類型 B。主要用於當我們一個變量的類型不固定時,比如一個函數運行過程中正常的運算結果返回 A ,運算失敗返回 B

const fn = (num: number): number | string ={
    if (num >=0) {
        return num;
    } else {
        return 'error';
    }
}
const res = fn(1);

當一個值是聯合類型時,只可以調用聯合類型的共有屬性。如上面的 res 類型是 number | string ,如果不加以判斷只能調用共有的 toStringvalueOf 等方法。

類型推斷與保護

正常情況下類型具有自動推斷的能力,比如我們聲明一個變量 const num = 1,TS 會自動將變量的類型推斷成 number,所以後面我們就可以對 num 變量使用 number 的一些操作方法。但是當使用聯合類型的時候,TS 在編譯階段就無法得知當前的變量類型是什麼,所以只可以使用共有的一些方法,所以我們需要使用類型保護的能力,比如可以通過一些判斷來縮小當前變量或者斷言當前變量的類型。

typeof

typeof 是判斷變量類型的一個操作符,我們可以通過typeof將類型縮小變成一個受保護的類型,如:

const fn = (value: number | string)void ={
  value // value is number | string

  if (typeof value === 'number') {
    value // value is number
    value.toFixed // no error
  } else {
    value // value is string
    value.length // no error
  }
}

instanceof

typeof 類似,instanceof 也是一個判斷變量類型的方式,比如一個函數可以同時接收不同的普通的對象,就會導致 typeof value === 'object' 分辨不出來。就可以使用 instanceof 來辨別對象是什麼。

class User {
  say(): void {
    console.log('hello');
  }
}

class Stu {
  read(): void {
    console.log('read');
  }
}

const fn = (value: Stu | User)void ={
  value // value is Stu | User

  if (value instanceof User) {
    value // value is User
    value.say() // no error
  } else {
    value // value is Stu
    value.read // no error
  }
}

in

in 可以檢查是否存在某個屬性,如:

type Item1 = {
  type: 'item1';
  name: string;
  age: number;
}

type Item2 = {
  type: 'item2',
  title: string;
  description: string;
}

const fn = (value: Item1 | Item2)void ={
  if ('name' in value) {
    value.age // no error
  } else {
    value.description // no error
  }
}

字面量判斷

如上面的例子,如果後期對 Item2添加name屬性就容易導致這裏類型判斷失效,導致獲取 value.age 就會存在問題。所以針對上述這種存在 type 區分的情況,可以直接使用字面量判斷。

const fn = (value: Item1 | Item2)void ={
  if (value.type === 'item1') {
    value.age // no error
  } else if (value.type === 'item2') {
    value.description // no error
  }
}

is 關鍵字自定義

使用上面的方法在簡單的場景下是十分有效,但是有時候類型的判斷是複雜的,或者這樣的判斷是通用的,所以爲了避免重複的編寫我們可能需要對這個類型的保護需要提取成函數,那麼就可以使用 is 來進行指定。

type Item1 = {
  type: 'item1';
  name: string;
  age: number;
}

type Item2 = {
  type: 'item2',
  title: string;
  description: string;
}

const isItem1Arr = (value: any): value is Item1[] ={
  if (!Array.isArray(value)) {
    return false;
  }
  if (value.length === 0) {
    return true;
  }
  return value.every(item => item.type === 'item1');
}

const fn = (value: Item1[] | Item2[])void ={
  if (isItem1Arr(value)) {
    value.forEach(item ={
      item.age // no error
    });
  }
}

其中 isItem1Arr 返回值是一個 boolean 如果返回的是 true 則說明當前類型是 is 後面的。

類型斷言

有了類型斷言我們可以輕鬆的遷移一個項目,但是類型斷言是有害的,因爲我們主動的給這個變量向 TS 類型檢查器做了背書而不是通過類型保護的方式。所以假設傳入的數據是有誤的,就會導致運行異常,所以我們需要謹慎的使用類型斷言,除非可以 100% 的保證這裏類型。

as 與 <>

有的時候 TS 的檢驗規則是存在缺陷的,不能完美的做好類型保護,比如下面的例子,雖然我們已經提前判斷過 item.parent 肯定不爲空,但是在一個閉包環境中使用,由於 TS 的缺陷,類型還是失效了(因爲我們是馬上運行的)。但是我們可以保證這裏的類型肯定是不會爲 null 的,所以我們就可以斷言它的類型。

interface Item {
  parent: Item | null;
}

const fn = (item: Item) ={
  if (!item.parent) {
    return;
  }
  const _fn = () ={
    item.parent // Item | null
    const parent1 = item.parent as Item;
    const parent2 = <Item>item.parent;
  }
  _fn();
}

第二種情況是,這個值是在運行過程中產生。所以我們沒有辦法在定義變量的時候進行初始化,這個時候就需要使用類型斷言了。但是這種方式存在弊端,假設後面 User 新增了一個屬性,就會導致返回的數據有缺省。所以不是很推薦這種方式,而是可以在初始化的時候設置成空值 / 默認值,這樣當新增一個屬性後,就會主動報錯,提醒我們需要處理額外的屬性。

interface User {
  type: 'student';
  name: string;
}

const createUser = (name: string)User ={
  // const result = {
  //   type: 'student'
  // } as User;
  const result = <User>{
    type: 'student'
  };

  if (name) {
    result.name = parseName(name);
  }

  return result;
}

! 非空斷言

顧名思義,主要是排除變量中 nullundefined 的類型。比如上面提到的 item.parent 我們可以很清楚的知道他不是一個 null 的,就可以使用這個簡單的方式。

interface Item {
  parent: Item | null;
}

const fn = (item: Item) ={
  if (!item.parent) {
    return;
  }
  const _fn = () ={
    const p1 = item.parent.parent; // error: (item.parent)對象可能爲 null
    const p2 = (<Item>item.parent).parent // ok,item.parent 整體斷言
    const p3 =item.parent!.parent; // ok,item.parent 非空,則排除 null
  }
}

雙重斷言

毫無根據的斷言是危險的,所以進行類型斷言時,TS 會提供額外的安全性,並不是每個變量間都可以斷言的,比如 'licy' as number 將一個字符串轉換成 number 肯定就是不行的。

如果 AB 直接存在賦值關係,即 AB 的子類型,或者 BA 的子類型就可以直接使用斷言。如果不存在時,可以找一箇中間的類型來做橋樑,通過上面【類型間的關係】可以得出 anyunknownnever 三個類型是最少都滿足上面 2 個規律之一。

const n1 = 'licy' as number; // error: string 與 number 不能充分重疊,轉換是錯誤的
const n2 = 'aa' as any as number;
const n3 = 'aa' as unknown as number; // 推薦
const n4 = 'aa' as never as number;

因爲斷言是具有危害性的,所以雙重斷言也是具有危害性的。我們需要儘量的少用。同時雙重斷言的使用場景很少很少,筆者只在一次跨 npm 包調用的時候,由於底層版本不一致,使用過一次。

全局類型

通常情況下,定義的類型需要使用 export 進行導出,在使用的地方再使用 import 導入。但是有時候在同一個項目中會有一些通用的類型或者類型方法,每次都進行導入是很繁瑣的。甚至需要在 window 掛載一些新的變量,所以我們需要了解全局類型的概念。聲明全局類型的方式有 2 種:

  1.  在一個 `.d.ts` 文件中寫變量類型,同時不要有 `export``import` 等導入導出語法。
// global.d.ts
type AnyFunction = (...args: any[]) => any;

值得注意的是,你需要在 tsconfig.jsoninclude 選項中包含該文件。另外需要注意的一點是,如果你是一個 npm 包的類型中,如果引入 npm 包的沒有引入你定義的全局類型,則會變成any

  1.  使用 `declare` 定義,比如需要給 `window` 新增類型,給某個包或者某一類文件添加類型說明等。
// global.d.ts
declare module 'react' {
  export const licy: string;
}

declare module 'npm-package' {
  export const props: { name: 'licy'; age: number }
  const App: React.FC<typeof props>;
  export default App;
}

declare module '*.svg' {
  const content: {
    id: string;
  }
  export default content;
}


// app.ts
import React from 'react';
import svg from './log.svg';
import { props } from 'npm-package';

React.licy // string
svg.id // string
props.name // 'licy'

當然很多時候,我們的類型還會引入一些已有類型進行組裝,所以就會破壞掉默認 .d.ts 是全局類型的約束,所以需要主動的導出。

// global.d.ts
import { ValueOf } from "./type";

declare namespace CommonNS {
  interface Props {
    name: 'licy';
    age: 24
  }
  type Value = ValueOf<Props>;
}

// 缺一不可,否則類型使用會加前綴
// 將 CommonNS 作爲全局類型,類似 UMD
export as namespace CommonNS;
// 將導出命名修改,否則就會使用 CommonNS.CommonNS.XXX 纔可以獲取
export = CommonNS;


// main.ts
const value: CommonNS.Value = 'licy';

在全局選項這裏需要注意 skipLibCheck 的配置,如果該選項配置爲 true 則會跳過庫文件的類型檢查,比如 node_moduels 中其他庫的類型檢查和當前項目的 .d.ts 檢查。所以會導致在編寫 .d.ts 文件的時候不一察覺錯誤,但是有不能保證引入的 npm 庫的類型文件都是正確的。所以可以在 tsconfig.json 將該選項設置爲 false 然後在編譯階段再將該選項設置爲 true

高級用法

函數重載

函數重載是靜態類型語言當中很重要的一個能力。很多時候編寫的函數可能會兼容多種參數類型,可能會根據傳入的參數會返回不同的數據。比如:

const data = { name: 'licy' };
const getData = (stringify: boolean = false): string | object ={
  if (stringify === true) {
    return JSON.stringify(data);
  } else {
    return data;
  }
}

const res1 = getData(); // string | object
const res2 = getData(true); // string | object

在上述的例子中調用 getData 方法得到一個聯合了聯合類型,還需要進行判斷將類型縮小或者使用 as 進行指定。但是如果作爲方法的編寫者,當確定傳入的參數後就可以很準確的得到返回值的類型,而不是得到這種模棱兩可的情況。所以藉助函數重載進行改造:

const data = { name: 'licy' };
function getData(stringify: true): string
function getData(stringify?: false): object
function getData(stringify: boolean = false): unknown {
  if (stringify === true) {
    return JSON.stringify(data);
  } else {
    return data;
  }
}

const res1 = getData(); // object
const res2 = getData(true); // string

函數重載的使用方法很簡單,就是在需要使用函數重載的地方,多聲明幾個函數的類型。然後在最後一個函數中進行實現,特別要注意的是,最後實現函數中的類型一定要與上面的類型兼容。

值得注意的是由於 TS 是在編譯後會將類型抹去生成 JS 代碼,而 JS 是沒有函數重載這樣的能力,所以說這裏的函數重載只是類型的重載,方便做類型的提示,實際上還是要在實現函數中進行傳入參數的判別,然後返回不同的結果。

泛型

泛型是 TS 一個比較高級的用法,在日常的開發中也是使用比較多的。當你的函數,接口或者類需要支持多種類型的時候就可以使用泛型,比如上面函數重載的例子,也可以使用泛型進行改造。

// 泛型函數
const data = { name: 'licy' };
function getData<T extends boolean = false, R = T extends true ? string : object>(stringify?: T): R {
  if (stringify === true) {
    return JSON.stringify(data) as unknown as R;
  } else {
    return data as unknown as R;
  }
}

const res1 = getData(); // object
const res2 = getData(true); // string

// 泛型類型
type ValueOf<T> = T[keyof T];

interface User {
  name: 'licy';
}

type A =  keyof User; // 'name'
type B = ValueOf<User>; // 'licy'

亦或者需要對傳入進來的參數進行保存時,比如編寫 React 中的 HOC

type AnyObject = Record<string, any>;
type ExtraProps = {
  name: 'licy'
};
const withItem = <
  T extends AnyObject
>(Comp: React.FC<T>): React.FC<T & ExtraProps> ={
  const NewComp: React.FC<T & ExtraProps> = (props) ={
    props.name // 'licy'
    return <Comp {...props} />
  }
  NewComp.displayName = 'with-item';
  return NewComp;
}

const Demo: React.FC<{ age: number }= () => null;

const NewDemo = withItem(Demo);

const res = (
  <>
    <Demo age={24} /> no error
    <NewDemo age={24}  /> no error
    <NewDemo age={24} /> error: 缺少屬性 "name"
  </>
)

內置的高級函數

爲了方便類型編寫,TS 官方內置了許多通用的高級類型方法,這些方法可以幫助我們完成程序中大部分的類型轉換。但是如果我們掌握了這些類型方法的實現方式,也可以很輕鬆的寫出符合業務邏輯規範的高級方法。所有的內置方法可以參考:utility-types[8] 本文只介紹一些典型的。

通過上面 TS 內部實現的高級類型可以發現,extendsinfer 是特別重要的。extends 可以實現類似三元表達式的判斷,判斷傳入的泛型是什麼類型的,然後返回定義好的類型。infer 可以在判斷是什麼類型後,可以提取其中的類型並在子句中使用。和我們正常寫代碼一樣,說明我們可以多個 extendsinfer 進行嵌套使用,這樣就可以把一個傳入的泛型進行一次次分解。

協變與逆變

在瞭解協變與逆變之前我們需要知道一個概念——子類型。我們前面提到過 string 可以賦值給 unknown 那麼就可以理解爲 stringunknown 的子類型。正常情況下這個關係即子類型可以賦值給父類型是不會改變的我們稱之爲協變,但是在某種情況下兩者會出現顛倒我們稱這種關係爲逆變。如:

interface Animal {
  name: string;
}

interface Cat extends Animal {
  catBark: string;
}

interface OrangeCat extends Cat {
  color: 'orange'
}

// ts 中不一定要使用繼承關係,只要是 A 的類型在 B 中全部都有,且 B 比 A 還要多一些類型
// 類似集合 A 屬於 B 一樣,這樣就可以將 B 叫做 A 的子類型。

// 以上從屬關係
// OrangeCat 是 Cat 的子類型
// Cat 是 Animal 的子類型
// 同理 OrangeCat 也是 Animal 的子類型

const cat: Cat = {
  name: '貓貓',
  catBark: '喵~~'
}
const animal: Animal = cat; // no error

假設我有類型 type FnCat = (value: Cat) => Cat; 請問下面四個誰是它的子類型,即以下那個類型可以賦值給它。

type FnAnimal = (value: Animal) => Animal;
type FnOrangeCat = (value: OrangeCat) => OrangeCat;
type FnAnimalOrangeCat = (value: Animal) => OrangeCat;
type FnOrangeCatAnima = (value: OrangeCat) => Animal;

type RES1 = FnAnimal extends FnCat ? true : false; // false
type RES2 = FnOrangeCat extends FnCat ? true : false; // false
type RES3 = FnAnimalOrangeCat extends FnCat ? true : false; // true
type RES4 = FnOrangeCatAnima extends FnCat ? true : false; // false

爲什麼 RES3 是可以的吶?

返回值:假設使用了 FnCat 返回值的 cat.catBark 屬性,如果返回值是 Animal 則不會有這個屬性,會導致調用出錯。估計返回值只能是 OrangeCat

參數:假設傳入的函數中使用了 orangeCat.color 但是,對外的類型參數還是 Cat 沒有 color 屬性,就會導致該函數運行時內部報錯。

故可以得出結論:返回值是協變,入參是逆變。

注意如果 tsconfig.json 中的 strictFunctionTypesfalse 則上述的 RES2 也是 true ,這就表明當前函數是支持雙向協變的。當然 TS 默認是關閉此選項的,主要是爲了方便 JS 代碼快速遷移到 TS 中,詳情可以見 why-are-function-parameters-bivariant[9] ,當然如果是一個新項目,建議打開 strictFunctionTypes 選項。

允許雙向協變是有風險的,可能會在運行時報錯。比如在 ESLint 中有 method-signature-style[10] 規則,簡單的來說該規則默認是使用 property 來聲明方法,比如:

interface T1<T> {
  wrapFn: (value: T) => void;
}

interface T2<T> {
  wrapFn(value: T): void; // eslint error, 聲明的方式是 method 形式
}

假設我們忽略 eslint 警告,強制 T2 的方法進行聲明就會潛在的雙向協變的風險,如下列代碼:

declare let animalT1: T1<Animal>;
declare let catT1: T1<Cat>

animalT1 = catT1; // error, Animal 不能分配給 Cat
catT1 = animalT1; // no error

declare let animalT2: T2<Animal>;
declare let catT2: T2<Cat>

animalT2 = catT2; // no error
catT2 = animalT2; // no error

真實案例

聯合類型轉交叉類型

題目描述

type Value = { a: string } | { b: number }
type Res = UnionToIntersection<Value> // type Res= {  a: string } & { b: number };

思路

  1.  [Distributive Conditional Types](https://www.typescriptlang.org/docs/handbook/2/conditional-types.html#distributive-conditional-types "Distributive Conditional Types") :當條件類型作用於泛型類型時,它們在給定聯合類型時變得可分配。
// extend 中的分配
type ToArray<Type> = Type extends any ? Type[] : never;
type StrArrOrNumArr = ToArray<string | number>; // string[] | number[]
  1.  [逆變的特性:在逆變位置時推斷出交叉類型](<https://github.com/Microsoft/TypeScript/pull/21496#:~:text=Likewise, multiple candidates for the same type variable in contra-variant positions causes an intersection type to be inferred:> "逆變的特性:在逆變位置時推斷出交叉類型")
// 逆變推斷出交叉
type Bar<T> = T extends { a: (x: infer U) => void, b: (x: infer U) => void } ? U : never;
type T20 = Bar<{ a: (x: string) => void, b: (x: string) => void }>;  // string
type T21 = Bar<{ a: (x: string) => void, b: (x: number) => void }>;  // string & number

結合二者

type ToUnionFunction<T> = T extends unknown ? (x: T) => void : never;
type UnionToIntersection<T> = ToUnionFunction<T> extends (x: infer R) => unknown
        ? R
        : never

測試

type Res = UnionToIntersection<Value> // type Res= {  a: string } & { b: number };

增強版 Omit

題目描述

假如我們要輕微的修改某個組件亦或者是拓展組件的某個屬性,比如下面代碼中的 size ,想給他增加一個 default 的配置項,但是其他的 props 都不會改變。首先肯定不想進行復制粘貼,其次也爲了後續內部的組件 props 變更後,外層的邏邏輯不改。

第一印象就是繼承然後修改 size 的值,但是很遺憾因爲新的類型與已有的類型不兼容,所以不能覆蓋成功。所以謀生了第二種思路,想把 size 給排除出去,然後重寫。但是也不行,因爲拓展的組件爲了方便添加了 [key: string]: unknown,導致 omit 有問題。

interface Props {
  title: string;
  size: 'small' | 'large';
  [key: string]: unknown;
}

interface NewProps1 extends Props {
  size: 'small' | 'large' | 'default'; // error: 不能將 default 分配給 'small' 和 'large'
}

interface NewProps2 extends Omit<Props, 'size'{
  size: 'small' | 'large' | 'default';
}

const a: NewProps2 = {
  title: 123, // no error, 但是我們知道 title 類型丟失了
  size: 'default',
}

思路

因爲是添加了 [key: string]: unknown 後才導致 omit 失效的,根據上面 omit 的實現我們可以知道實際上是 keyof 方法不能處理 [key: string] 這樣的索引簽名,根據常識對象索引簽名的類型只支持 string、number、symbol 和字面量。所以只需要保留字面量的 key 就行了。

/**
 * 我們已知
 *  1. K 只會爲 string | number | symbol | 字面量
 *  2. string extends K 時,只有 K 爲 string 時纔是 true, 同理這裏可以檢查出 number 和 symbol 然後 as 爲 never.
 */
type KnownKeys<T> = keyof {
  [K in keyof T as (
    string extends K
      ? never
      : number extends K
        ? never
        : symbol extends K
          ? never
          : K)
  ]: never;
};

/**
 * 因爲 Pick 的第二個參數需要 K extends keyof T
 * 所以這裏需要判斷 KnownKeys<T> extends keyof T,
 */
export type ObtainKeyOmit<T, K extends KnownKeys<T>> = KnownKeys<T> extends keyof T ? Pick<T, Exclude<KnownKeys<T>, K>> : never;

測試

interface NewProps3 extends ObtainKeyOmit<Props, 'size'{
  size: 'small' | 'large' | 'default';
  [key: string]: unknown; // 因爲 ObtainKeyOmit 去掉了,所以需要加回來
}

const a: NewProps3 = {
  title: 123, // error, number 不能賦值給 string
  size: 'default',
  name: 'licy',
}

下劃線轉駝峯

題目描述

根據目前大多數的規範來說,服務端開發的同學大多數是使用下劃線命名,而前端的同學是用小駝峯命名。所以大部分同學會在 axios 請求數據回來的 hooks 中使用 camelcase-keys 將數據中的下劃線轉換成小駝峯。但是大多數接口的類似是使用 thrift 進行轉換的,所以生成的類型文件也是下劃線構造的。所以可以完成一個方法將下劃線轉換成小駝峯。

// thrift 的構造,key 爲下劃線,其中值有可能是數組或者對象的嵌套
interface User {
  user_id: string;
  name: string;
  user_status?: number;
  avatar_url: {
    default_url: string;
    small_url?: string;
    large_url?: string;
  };
  colleague: {
    user_name: string;
    user_id: string;
    user_status?: number;
  }[];
};

思路

  1. 首先需要完成下劃線字符串轉駝峯的方法 Under2camel

  2. 使用 T extends `${infer F}_${infer L}` 的方式可以獲取 _ 前後的值

  3. 使用 Capitalize 可以將字符串的首字母大寫

  4. 注意兼容 _xx 開頭的數據

  5.  `Under2camelDeep` 的方法其實只是根據值的類型添加的遞歸處理
  6. key 進行轉換,如果是字符串就使用 Under2camel 方法,如果是 numbersymbol 就跳過。

  7. 使用 T[K] extends (infer O)[] 判斷是數組,並且還可以提取其中的值

// 只做字符串的轉換
type Under2camel<T extends string> = T extends `${infer F}_${infer L}`
  ? F extends ''
    ? Under2camel<L>
    :`${F}${Under2camel<Capitalize<L>>}`
  : T;
type AAA = Under2camel<'_user_id_ddd_aaa_'>; // userIdDddAaa

// 遞歸遍歷
type Under2camelDeep<T> = T extends (infer U)[]
  ? Under2camelDeep<U>[]
  : {
    [K in keyof T as (K extends string ? Under2camel<K> : never)]: T[K] extends Record<string, unknown>
      ? Under2camelDeep<T[K]>
        : T[K] extends (infer O)[]
          ? Under2camelDeep<O>[]
          : T[K]
  }

測試

const data: Under2camelDeep<User> = {
  userId: 'id001',
  name: 'licy',
  userStatus: 0,
  avatarUrl: {
    smallUrl: 'https://xxx',
    defaultUrl: 'https://xxx'
  },
  colleague: [
    {
      userName: 'zhangsan',
      userId: 'id002',
      userStatus: 0,
    }
  ]
};

課後作業

編寫一個 Under2camelDeep 方法的相反方法,Camel2underDeep 可以將小駝峯命名轉換成 _ 連接。

加強版 Pick

題目描述

很多時候我們會使用 lodashpick 方法進行深度的選取,如 _.pick(o, ['a', 'b.c', 'b.e[0].a'])。所以也希望可以提供一個 DeepPick 的方法可以進行深度的選取。

interface User {
  name: string;
  address: {
    country?: string;
    city: string;
  };
  spend: {
    price: number;
    description?: string;
  }[];
}

type NewUser = DeepPick<User, 'name' | 'address.city' | 'spend[0].price'>;

思路

  1. 前文提到過,聯合類型具有分配的性質,可以想象成一個數組,每一次只會有一個值代入表達式計算,最後的結果也是一個聯合類型。

  2. 使用 infer 的方式去提取字符串

  3. 由於 [0].. 有共同的部分,需要先判斷 [0].

  4. 遞歸即可

  5. 得到的數據是 {name: string} | { address: { city: BJ } } 的形式,並不是一個交叉的類型。

  6.  使用前文提到的 `UnionToIntersection` 方法將聯合類型轉換成交叉類型。
type _DeepPick<T, U> = U extends `${infer F}[0].${infer Rest}`
  ? F extends keyof T
    ? T[F] extends (infer O)[]
      ? { [P in F]: DeepPick<O, Rest>[] }
      : never
    : never
  : U extends `${infer F}.${infer Rest}`
    ? F extends keyof T
      ? { [P in F]: DeepPick<T[F], Rest> }
      : never
    : U extends keyof T
      ? { [P in U]: T[U] }
      : never;

type DeepPick<T, U> = UnionToIntersection<_DeepPick<T, U>>;

測試

type NewUser = DeepPick<User, 'name' | 'address.city' | 'spend[0].price'>;
// {
//   name: string;
//   address: {
//     city: string;
//   };
//   spend: {
//     price: number;
//   }[]
// }

const a: NewUser = {
  name: 'licy',
  address: {
    city: 'BJ',
  },
  spend: [
    {
      price: 100,
    },
  ],
};

課後作業

  1.  如果加入 `address.country` 後,由於該項是一個可選項,所以可以不寫,但是目前解析成了 `string | undefinde` 所以不寫會進行報錯,如何修復。
type NewUser = DeepPick<User, 'name' | 'address.city' | 'spend[0].price'>;
// {
//   name: string;
//   address: {
//     country: string | undefined;
//     city: string;
//   };
//   spend: {
//     price: number;
//   }[]
// }

const a: NewUser = {
  name: 'licy',
  address: { // 報錯,缺少 country 類型
    city: 'BJ',
  },
  spend: [
    {
      price: 100,
    },
  ],
};
  1. 修改 DeepPick<T, U> 中的 U 可以讓輸入的時候和 Pick 方法一樣,有提示。

Object.assign 類型提示

題目描述

Object.assign 是一個常用 API 方法,但是目前來說這個方法自動推斷的類型是有問題的,因爲他是採用 type1 & type2 的方式,所以如果 2 個類型沒有共有屬性,就會得到 never

type O1 = {
  id: string;
  value: string;
  age?: number;
  extra?: {
    a: number;
    type: 'a';
  };
};

type O2 = {
  name: string;
  value: number;
  city?: string;
  extra?: {
    type: 'b';
  };
};

type O3 = {
  city: string[];
};

const o1: O1 = {
  id: 'id1',
  value: 'abc',
  age: 24,
  extra: {
    a: 1,
    type: 'a',
  },
};

const o2: O2 = {
  name: 'licy',
  value: 0,
  city: 'BJ',
  extra: {
    type: 'b',
  },
};

const o3: O3 = {
  city: ['bj'],
};

const res = Object.assign({}, o1, o2, o3);
// 類型丟失了
res.value // never
res.extra // never
res.city // string & string[]

思路

因爲 & 是取交集,而這裏 assign 的能力是覆蓋所以不能直接 &。應該判斷如果後面的類型中有,則前面的類型就不需要有了。所以在遍歷 T1 時,如果該 key 存在於 T2 中,則不應該有此項。

type Merge<T1 extends Record<string, unknown>, T2 extends Record<string, unknown>> = {
  [K in keyof T1 as K extends keyof T2 ? never : K]: T1[K];
} & {
  [K in keyof T2]: T2[K];
};

type Assign<T extends Record<string, unknown>, U> = U extends [infer F, ...infer Rest]
  ? F extends Record<string, unknown>
    ? Assign<Merge<T, F>, Rest>
    : Assign<T, Rest>
  : T;

測試

const res: Assign<O1, [O2, O3]= Object.assign({}, o1, o2, o3);
// 類型推斷正確
res.value // number
res.extra // { type: 'b' }
res.city // string[]

課後作業

  1.  目前這個方法不是完美的,存在一定的缺陷,假設 `O3` 的類型中有未必填的 `key` 值,然後就會導致類型推斷出現問題。如:
type O3 = {
  id?: number;
  city: string[];
};

const res: Assign<O1, [O2, O3]= Object.assign({}, o1, o2, o3);
res.id // id?: number | undefined ,有問題 因爲 O1 是肯定有 id,所以推斷出了問題

如何完善 Merge 方法,使得上面的 res.id 自動推斷爲 string | number

  1. 如果是深度的 assgin 如何實現?比如我期望上文中的 res.extra 推斷爲 { a: number; type:'b' }

周邊工具

推薦工具

  1. 在線代碼練習:TS 代碼演練場 [11]

  2. 本地練習:VSCode 編輯器

  3. 默認情況下是使用 VSCode 的 TS 版本進行,所以可能造成編寫時的提示和編譯版本不一致。可以點擊圖中的 {} ,然後選擇 TS 版本。也可以通過 command+shift+p 調出命令界面,輸入 typescript 進行選擇。

  1. 很多時候我們在代碼中編寫工具函數,然後等着頁面刷新是很麻煩的,我們可以在一個測試 TS 中間中進行編寫,編寫完成後直接把代碼複製過去。直接運行 TS 的工具 ts-node[12],可以監聽變化熱更新 ts-node-dev[13]。當然也可以用目前最新的工具 bun[14]。

  1. 很多常用的 TS 類型工具庫,可以看成和 lodash 類似,如果有不知道如何寫的可以參考。TS 類型工具庫 [15]

  2. Lint 檢查工具,以前有專門的 TS Lint 但是由於 TS LintES Lint 過程高度的相似,所以目前 TSLint 被併入了 ES lintTS Lint 也被官方標記爲了放棄維護。所以可以安裝:@typescript-eslint/eslint-plugin[16] 和 @typescript-eslint/parser[17],使用 ESLint 進行代碼風格的檢測。

  3. 類型覆蓋檢查工具,可以使用 type-coverage[18] 進行項目中類型覆蓋度的檢測。特別適合從一個 JS 項目遷移到 TS 項目的過程中,得到階段性的數據,當然也可以作爲一個 MR 的准入標準。比如下面就是目前低代碼三個核心包的類型覆蓋率,還是有一點提升空間的。

參考資料

[1]

安德斯 · 海爾斯伯格: https://baike.baidu.com/item/%E5%AE%89%E5%BE%B7%E6%96%AF%C2%B7%E6%B5%B7%E5%B0%94%E6%96%AF%E4%BC%AF%E6%A0%BC

[2]

Roadmap: https://github.com/microsoft/TypeScript/wiki/Roadmap

[3]

v4.7: https://github.com/microsoft/TypeScript/issues/48027

[4]

Understanding TypeScript's Popularity: https://orta.io/notes/js/why-typescript

[5]

Flow: https://flow.org/

[6]

JSDoc: https://jsdoc.app/

[7]

尤玉溪的回答: https://www.zhihu.com/question/46397274/answer/101193678

[8]

utility-types: https://www.typescriptlang.org/docs/handbook/utility-types.html

[9]

why-are-function-parameters-bivariant: https://github.com/Microsoft/TypeScript/wiki/FAQ#why-are-function-parameters-bivariant

[10]

method-signature-style: https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/docs/rules/method-signature-style.md

[11]

TS 代碼演練場: https://www.typescriptlang.org/zh/play

[12]

ts-node: https://www.npmjs.com/package/ts-node

[13]

ts-node-dev: https://www.npmjs.com/package/ts-node-dev

[14]

bun: https://bun.sh/

[15]

TS 類型工具庫: https://github.com/millsp/ts-toolbelt

[16]

@typescript-eslint/eslint-plugin: https://www.npmjs.com/package/@typescript-eslint/eslint-plugin

[17]

@typescript-eslint/parser: https://www.npmjs.com/package/@typescript-eslint/parser

[18]

type-coverage: https://www.npmjs.com/package/type-coverage

[19]

TypeScript 官方使用手冊: https://www.typescriptlang.org/docs/handbook/

[20]

深入理解 TypeScript: https://jkchao.github.io/typescript-book-chinese/

[21]

type-challenges: https://github.com/type-challenges/type-challenges

[22]

類型體操天花板是怎樣煉成的 - Web Infra 團隊定期技術分享: https://bytedance.feishu.cn/minutes/obcnbl4cae2792wz7vw78lom

[23]

用 TypeScript 類型運算實現一箇中國象棋程序: https://zhuanlan.zhihu.com/p/426966480

[24]

TypeScript 類型體操天花板,用類型運算寫一個 Lisp 解釋器: https://zhuanlan.zhihu.com/p/427309936

[25]

[⭐全技巧解析史詩典藏⭐] 用 TypeScript 類型寫一個 Lisp 解釋器 Pro (尾遞歸優化版): https://bytedance.feishu.cn/docs/doccnf74joJlJKkfOVNjsCyy6Gb

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