TypeScript 隨想 · 實際應用與技巧

Barrior發表於2022-03-04

目錄

  • 型別超程式設計
  • 內建工具型別窺探
  • 外部工具型別推薦
  • 新操作符
  • 宣告檔案

型別超程式設計

什麼是超程式設計:

維基百科是這樣描述的:超程式設計是一種程式設計技術,編寫出來的計算機程式能夠將其他程式作為資料來處理。意味著可以編寫出這樣的程式:它能夠讀取、生成、分析或者轉換其它程式,甚至在執行時修改程式自身。在某些情況下,這使程式設計師可以最大限度地減少表達解決方案的程式碼行數,從而減少開發時間。它還允許程式更靈活有效地處理新情況而無需重新編譯。

簡單的說,超程式設計能夠寫出這樣的程式碼:

  • 可以生成程式碼
  • 可以在執行時修改語言結構,這種現象被稱為反射程式設計(Reflective Metaprogramming)或反射(Reflection)

什麼是反射:

反射是超程式設計的一個分支,反射又有三個子分支:

  1. 自省(Introspection):程式碼能夠自我檢查、訪問內部屬性,我們可以據此獲得程式碼的底層資訊。
  2. 自我修改(Self-Modification):顧名思義,程式碼可以修改自身。
  3. 調解(Intercession):字面意思是「代他人行事」,在超程式設計中,調解的概念類似於包裝(wrapping)、捕獲(trapping)、攔截(intercepting)。

舉個實際一點的例子

  • ES6(ECMAScript 2015)中用 Reflect(實現自省)和 Proxy(實現調解) 進行編碼操作,稱之為是一種超程式設計。
  • ES6 之前利用 eval 生成額外的程式碼,利用 Object.defineProperty 改變某個物件的語義等。

TypeScript 的型別超程式設計

個人感覺「超程式設計」這個概念並沒有標準的明確的定義,所以本文這裡就把在 TypeScript 中使用 infer、keyof、in 等關鍵字進行操作,稱之為是 TypeScript 的型別超程式設計。或者說是「偏底層一點的特性」或者「騷操作」,大家明白其用途即可。

unknown

unknown type 是 TypeScript 中的 Top Type。符號是(⊤), 換句話說,就是任何型別都是 unknown 的子型別,unknown 是所有型別的父型別。換句最簡單的話說,就是 任何值都可以賦值給型別是 unkown 的變數,與其對應的是,我們不能把一個 unkown 型別的值賦值給任意非 unkown 型別的值。

let a: unknown = undefined
a = Symbol('deep dark fantasy')
a = {}
a = false
a = '114514'
a = 1919n

let b : bigint = a; // Type 'unknown' is not assignable to type 'bigint'.

never

never 的行為與 unknown 相反,never 是 TypeScript 中的 Bottom Type,符號是(⊥),換句話說,就是任何型別都是 never 的父型別,never 是所有型別的子型別。

也可以顧名思義,就是「永遠不會」=>「不要」的意思,never 與 infer 結合是常見體操姿勢,下文會介紹。

let a: never = undefined // Type 'undefined' is not assignable to type 'never'

keyof

可以用於獲取物件或陣列等型別的所有鍵,並返回一個聯合型別

interface Person {
  name: string
  age: number
}

type K1 = keyof Person  // "name" | "age"

type K2 = keyof []      // "length" | "toString" | "push" | "concat" | "join"

type K3 = keyof { [x: string]: Person }  // string | number

in

對映型別中,可以對聯合型別進行遍歷

type Keys = 'firstName' | 'lastName'

type Person = {
  [key in Keys]: string
}

// Person: { firstName: string; lastName: string; }

[]

索引操作符,使用 [] 操作符可以進行索引訪問,所謂索引,就是根據一定的指向返回相應的值,比如陣列的索引就是下標 0, 1, 2 等。TypeScript 裡的索引簽名有兩種:字串索引和數字索引。

字串索引(物件)

對於純物件型別,使用字串索引,語法:T[key]

interface Person {
  name: string
  age: number
}

type Name = Person['name']  // Name: string

索引型別本身也是一種型別,因此還可以使用聯合型別或者其他型別進行操作

type I1 = Person['name' | 'age']  // I1: string | number

type I2 = Person[keyof Person]    // I2: string | number

數字索引(陣列)

對於類陣列型別,使用數字索引,語法:T[number]

type MyArray = ['Alice', 'Bob', 'Eve']

type Alice = MyArray[0]       // 'Alice'

type Names = MyArray[number]  // 'Alice' | 'Bob' | 'Eve'

實際一點的例子

const PLAYS = [
  {
    value: 'DEFAULT',
    name: '支付送',
    desc: '使用者支付後即獲贈一張券',
  },
  {
    value: 'DELIVERY_FULL_AMOUNT',
    name: '滿額送',
    desc: '使用者支付滿一定金額可獲贈一張券',
    checkPermission: true,
    permissionName: 'fullAmount',
  },
]

type Play = typeof PLAYS[number]

/*
type Play = {
    value: string;
    name: string;
    desc: string;
    checkPermission?: undefined;
    permissionName?: undefined;
} | {
    value: string;
    name: string;
    desc: string;
    checkPermission: boolean;
    permissionName: string;
}
*/

泛型(generic)

軟體工程中,我們不僅要建立一致的定義良好的 API,同時也要考慮可重用性。元件不僅能夠支援當前的資料型別,同時也能支援未來的資料型別,這在建立大型系統時非常有用。

實際例子,封裝 ajax 請求庫,支援不同的介面返回它該有的資料結構。

function ajax<T>(options: AjaxOptions): Promise<T> {
  // actual logic...
}

function queryAgencyRole() {
  return ajax<{ isAgencyRole: boolean }>({
    method: 'GET',
    url: '/activity/isAgencyRole.json',
  })
}

function queryActivityDetail() {
  return ajax<{ brandName: string; }>({
    method: 'GET',
    url: '/activity/activityDetail.json',
  })
}

const r1 = await queryAgencyRole()

r1.isAgencyRole  // r1 裡可以拿到 isAgencyRole

const r2 = await queryActivityDetail()

r2.brandName     // r2 裡可以拿到 brandName

extends

在官方的定義中稱為條件型別(Conditional Types),可以理解為「三目運算」,T extends U ? X : Y,如果 T 是 U 的子集,那麼就返回 X 否則就返回 Y。

  • 一般與泛型配合使用。
  • extends 會遍歷聯合型別,返回的也是聯合型別。
type OnlyNumber<T> = T extends number ? T : never

type N = OnlyNumber<1 | 2 | true | 'a' | 'b'>  // 1 | 2

通常情況下,分佈的聯合型別是我們想要的, 但是也可以讓 extends 不遍歷聯合型別,而作為一個整體進行判斷與返回。只需要在 extends 關鍵字的左右兩側加上方括號 [] 進行修飾即可。

// 分佈的條件型別
type ToArray<T> = T extends any ? T[] : never;

type R = ToArray<string | number>;

// type R = string[] | number[]
// 不分佈的條件型別
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never;

type R = ToArrayNonDist<string | number>;

// type R = (string | number)[]

infer

infer 關鍵字可以對運算過程中的型別進行儲存,類似於定義一個變數。
內建的工具型別 ReturnType 就是基於此特性實現的。

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

type R1 = ReturnType<() => number>     // R1: number
type R2 = ReturnType<() => boolean[]>  // R2: boolean[]

遞迴(recursion)

在 TypeScript 中遞迴也是呼叫(或引用)自己,不過不一定需要跳出。
如下,定義 JSON 物件的標準型別結構。

// 定義基礎型別集
type Primitive = string | number | boolean | null | undefined | bigint | symbol

// 定義 JSON 值
type JSONValue = Primitive | JSONObject | JSONArray

// 定義以純物件開始的 JSON 型別
interface JSONObject {
  [key: string]: JSONValue
}

// 定義以陣列開始的 JSON 型別
type JSONArray = Array<JSONValue>

提個小問題:為什麼 TypeScript 不跳出遞迴也不會陷入死迴圈?

But apart from being computationally intensive, these types can hit an internal recursion depth limit on sufficiently-complex inputs. When that recursion limit is hit, that results in a compile-time error. In general, it’s better not to use these types at all than to write something that fails on more realistic examples.
--from https://www.typescriptlang.or...

typeof

概念:像 TypeScript 這樣的現代靜態型別語言,一般具備兩個放置語言實體的「空間」,即型別空間(type-level space)和值空間(value-level space),前者用於存放程式碼中的型別資訊,在執行時會被完全擦除掉;後者用於存放程式碼中的「值」,會保留到執行時。

  • 值空間:變數、物件、陣列、class、enum 等。
  • 型別空間:type、interface、class、enum 等。

typeof 的作用是把「值空間」的資料轉換成「型別空間」的資料。

const MARKETING_TYPE = {
  ISV: 'ISV_FOR_MERCHANT',
  ISV_SELF: 'ISV_SELF',
  MERCHANT: 'MERCHANT_SELF',
}

type MarketingType = typeof MARKETING_TYPE

/*
type MarketingType = {
  ISV: string;
  ISV_SELF: string;
  MERCHANT: string;
}
*/

as const

as const 是一個型別斷言,作用也是把「值空間」的資料轉換成「型別空間」的資料,並且設定成只讀。

let x = 'hello' as const;   // x: 'hello'

let y = [10, 20] as const;  // y: readonly [10, 20]

let z = { text: 'hello' } as const;  // z: { readonly text: 'hello' }

實際一點的例子:

const MARKETING_TYPE = {
  ISV: 'ISV_FOR_MERCHANT',
  ISV_SELF: 'ISV_SELF',
  MERCHANT: 'MERCHANT_SELF',
} as const

type MT = typeof MARKETING_TYPE

type MarketingType = MT[keyof MT]

/*
type MT = {
  readonly ISV: "ISV_FOR_MERCHANT";
  readonly ISV_SELF: "ISV_SELF";
  readonly MERCHANT: "MERCHANT_SELF";
}

type MarketingType = "ISV_FOR_MERCHANT" | "ISV_SELF" | "MERCHANT_SELF"
*/

內建工具型別窺探

TypeScript 內建了一些實用的工具型別,可以提高開發過程中型別轉換的效率。
基於上面的瞭解,再來閱讀內建工具型別就很輕鬆了,這裡我們就列舉幾個常用或者有代表性的工具型別。

Partial

作用:把物件的每個屬性都變成可選屬性。

interface Todo {
  title: string;
  description: string;
}

type NewTodo = Partial<Todo>

/*
type NewTodo = {
  title?: string;
  description?: string;
}
*/

原理:把每個屬性新增 ? 符號,使其變成可選屬性。

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

Required

作用:與 Partial 相反,把物件的每個屬性都變成必填屬性。

interface Todo {
  title?: string;
  description?: string;
}

type NewTodo = Required<Todo>

/*
type NewTodo = {
  title: string;
  description: string;
}
*/

原理:給每個屬性新增 -? 符號,- 指的是去除,-? 意思就是去除可選,就變成了 required 型別。

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

Readonly

作用:把物件的每個屬性都變成只讀屬性。

interface Todo {
  title: string;
  description: string;
}

type NewTodo = Readonly<Todo>

/*
type NewTodo = {
  readonly title: string;
  readonly description: string;
}
*/

const todo: Readonly<Todo> = {
  title: 'Delete inactive users'
}

// Cannot assign to 'title' because it is a read-only property.
todo.title = "Hello";

原理:給每個屬性新增 readonly 關鍵字,就變成了只讀屬性。

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

Pick

作用:與 lodash 的 pick 方法一樣,挑選物件裡需要的鍵值返回新的物件,不過這裡挑選的是型別。

interface Todo {
  title: string;
  description: string;
  completed: boolean;
}
 
type TodoPreview = Pick<Todo, 'title' | 'completed'>

/*
type TodoPreview = {
  title: string;
  completed: boolean;
}
*/

原理:使用條件型別約束傳入的聯合型別 K,然後再對符合條件的聯合型別 K 進行遍歷。

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

Omit

作用:與 Pick 工具方法相反,排除物件的某些鍵值。

interface Todo {
  title: string;
  description: string;
  completed: boolean;
}
 
type TodoPreview = Omit<Todo, 'description'>

/*
type TodoPreview = {
  title: string;
  completed: boolean;
}
*/

原理:與 Pick 類似,不過是先通過 Exclude 得到排除後的剩餘屬性,再遍歷生成新物件型別。

type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

Exclude

作用:排除聯合型別裡的一些成員型別。

type T0 = Exclude<'a' | 'b' | 'c', 'a'>        // T0: 'b' | 'c'

type T1 = Exclude<'a' | 'b' | 'c', 'a' | 'b'>  // T1: 'c'

原理:通過條件型別 extends 把不需要的型別排除掉。

type Exclude<T, U> = T extends U ? never : T;

Parameters

作用:獲取函式的引數型別,返回的是一個元組型別

type T0 = Parameters<() => string>         // T0: []
type T1 = Parameters<(s: string) => void>  // T1: [s: string]

原理:通過 infer 關鍵字獲取函式的引數型別並返回

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

ReturnType

作用:獲取函式的返回型別

type R1 = ReturnType<() => number>      // R1: number
type R2 = ReturnType<() => boolean[]>   // R2: boolean[]

原理:通過 infer 關鍵字獲取函式返回型別

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

Awaited

作用:取得無 Promise 包裹的原始型別。

type res = Promise<{ brandName: string }>

type R = Awaited<res>  // R: { brandName: string }

原理:如果是普通型別就返回該型別,如果是 Promise 型別,就用 infer 定義 then 的值,並返回。

type Awaited<T> =
  T extends null | undefined
    ? T
    : T extends object & { then(onfulfilled: infer F): any } // 檢查 Promise 型別
      ? F extends (value: infer V, ...args: any) => any 
        ? Awaited<V>  // 遞迴 value 型別
        : never       // 不符合規則的 Promise 型別丟棄
      : T;    // 不是 Promise 型別直接返回

Promise 型別形狀如下

/**
 * Represents the completion of an asynchronous operation
 */
interface Promise<T> {
    /**
     * Attaches callbacks for the resolution and/or rejection of the Promise.
     * @param onfulfilled The callback to execute when the Promise is resolved.
     * @param onrejected The callback to execute when the Promise is rejected.
     * @returns A Promise for the completion of which ever callback is executed.
     */
    then<TResult1 = T, TResult2 = never>(onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null): Promise<TResult1 | TResult2>;

    /**
     * Attaches a callback for only the rejection of the Promise.
     * @param onrejected The callback to execute when the Promise is rejected.
     * @returns A Promise for the completion of the callback.
     */
    catch<TResult = never>(onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | undefined | null): Promise<T | TResult>;
}

獲取 Promise 型別的另一種簡單實現:

type Awaited<T> = T extends PromiseLike<infer U> ? Awaited<U> : T

外部工具型別推薦

市面上有 2 款 star 比較多的開源工具庫
type-fest: https://github.com/sindresorh...
utility-types: https://github.com/piotrwitek...
type-fest 沒有用過,介紹一下 utility-types 的 ValuesType,比較常用。

ValuesType

獲取物件或陣列的值型別。

interface Person {
  name: string
  age: number
}

const array = [0, 8, 3] as const

type R1 = ValuesType<Person>          // string | number
type R2 = ValuesType<typeof array>    // 0 | 8 | 3
type R3 = ValuesType<[8, 7, 6]>       // 8 | 7 | 6

實際例子:獲取 JS 常量的值型別,避免重複勞動。

const MARKETING_TYPE = {
  ISV: 'ISV_FOR_MERCHANT',
  ISV_SELF: 'ISV_SELF',
  MERCHANT: 'MERCHANT_SELF',
} as const

type MarketingType = ValuesType<typeof MARKETING_TYPE>

// type MarketingType = "ISV_FOR_MERCHANT" | "ISV_SELF" | "MERCHANT_SELF"

實現原理:使用上文說到的「字串索引」和「數字索引」來取值。

type ValuesType<
  T extends ReadonlyArray<any> | ArrayLike<any> | Record<any, any>
> = T extends ReadonlyArray<any>
  ? T[number]
  : T extends ArrayLike<any>
  ? T[number]
  : T extends object
  ? T[keyof T]
  : never;

新操作符

[2.0] Non-null assertion operator(非空斷言符)

斷言某個值存在

function createGoods(value: number): { type: string } | undefined {
  if (value < 5) {
    return
  }
  return { type: 'apple' }
}

const goods = createGoods(10)

goods.type  // ERROR: Object is possibly 'undefined'. (2532)

goods!.type  // ✅

[3.7] Optional Chaining(可選鏈操作符)

可選鏈操作符可以跳過值為 null 和 undefined 的情況,只在值存在的情況下才會執行後面的表示式。

let x = foo?.bar()

編譯後的結果如下:

let x = foo === null || foo === void 0 ? void 0 : foo.bar();

實際場景的對比:

// before
if (user && user.address) {
  // ...
}

// after
if (user?.address) {
  // ...
}

// 語法:
obj.val?.prop     // 屬性訪問
obj.val?.[expr]   // 屬性訪問
obj.arr?.[index]  // 陣列訪問
obj.func?.(args)  // 函式呼叫

[3.7] Nullish Coalescing(雙問號操作符)

// before
const isBlack = params.isBlack || true   // ❌
const isBlack = params.hasOwnProperty('isBlack') ? params.isBlack : true  // ✅

// after
const isBlack = params.isBlack ?? true  // ✅

[4.0] Short-Circuiting Assignment Operators(複合賦值操作符)

在 JavaScript 和許多程式語言中,稱之為 Compound Assignment Operators(複合賦值操作符)
// Addition
// a = a + b
a += b;

// Subtraction
// a = a - b
a -= b;

// Multiplication
// a = a * b
a *= b;

// Division
// a = a / b
a /= b;

// Exponentiation
// a = a ** b
a **= b;

// Left Bit Shift
// a = a << b
a <<= b;

新增:

a &&= b   // a && (a = b)
a ||= b   // a || (a = b)
a ??= b   // a ?? (a = b)

示例:

let values: string[];

// Before
(values ?? (values = [])).push("hello");

// After
(values ??= []).push("hello");

宣告檔案

通常理解就是 .d.ts 檔案,按功能可以分為:變數宣告、模組宣告、全域性型別宣告、三斜線指令等。

變數宣告

假如我們想使用第三方庫 jQuery,一種常見的方式是在 html 中通過 <script> 標籤引入 jQuery,然後就可以使用全域性變數 $ 或 jQuery 了。假設要獲取一個 id 為 foo 的元素。

jQuery('#foo')  // ERROR: Cannot find name 'jQuery'.

TS 會報錯,因為編譯器不知道 $ 或 jQuery 是什麼,所以需要宣告這個全域性變數讓 TS 知道,通過 declare var 或 declare let/const 來宣告它的型別。

// 宣告變數 jQuery
declare var jQuery: (selector: string) => any;

// let 和 var 沒有區別,更建議使用 let
declare let jQuery: (selector: string) => any;

// const 宣告的變數不允許被修改
declare const jQuery: (selector: string) => any;

宣告函式

// 宣告函式
declare function greet(message: string): void;

// 使用
greet('hello')

宣告類

// 宣告類
declare class Animal {
  name: string;
  constructor(name: string);
  sayHi(): string;
}

// 使用
const piggy = new Animal('佩奇')
piggy.sayHi()

宣告物件

// 宣告物件
declare const jQuery: {
  version: string
  ajax: (url: string, settings?: any) => void
}

// 使用
console.log(jQuery.version)
jQuery.ajax('xxx')

還可以使用 namespace 名稱空間來宣告物件,早期 namespace 的出現是為了解決模組化而創造的關鍵字,隨著 ES6 module 關鍵字的出現,為了避免功能混淆,現在建議不使用。

declare namespace jQuery {
  const version: string
  function ajax(url: string, settings?: any): void;
}

模組宣告

通常我們引入 npm 包,它的宣告檔案可能來源於兩個地方:

  • 包內建的型別檔案,package.json 的 types 入口。
  • 安裝 @types/xxx 對應的包型別檔案。

假如上面兩種方式都沒有找到對應的宣告檔案,那麼就需要手動為它寫宣告檔案了,通過 declare module 來宣告模組。

例項:手動修復 @alipay/h5data 的型別支援。

interface H5DataOption {
  env: 'dev' | 'test' | 'pre' | 'prod';
  autoCache: boolean;
}

declare module '@alipay/h5data' {
  export function fetchData<T extends any>(
    path: string,
    option?: Partial<H5DataOption>,
  ): Promise<T>;
}

// 使用
import { fetchData } from '@alipay/h5data'

const res = await fetchData<{ data: 'xxx' }>('url/xxx')

擴充模組型別

某些情況下,模組已經有型別宣告檔案了,但引入了一些外掛,外掛沒有支援型別,這時就需要擴充套件模組的型別。還是通過 declare module 擴充套件,因為模組宣告的型別會合並。

declare module 'moment' {
  export function foo(): string
}

// 使用
import moment from 'moment'
import 'moment-plugin'

moment.foo()

全域性型別宣告

型別的作用域

在 Typescript 中,只要檔案存在 import 或 export 關鍵字,都被視為模組檔案。也就是不管 .ts 檔案還是 .d.ts 檔案,如果存在上述關鍵字之一,則型別的作用域為當前檔案;如果不存在上述關鍵字,檔案內的變數、函式、列舉等型別都是以全域性作用域存在於專案中的。

全域性作用域宣告全域性型別

全域性作用域內宣告的型別皆為全域性型別。

區域性作用域宣告全域性型別

區域性作用域內可以通過 declare global 宣告全域性型別。

import type { MarketingType } from '@/constants'

declare global {
  interface PageProps {
    layoutProps: {
      marketingType: MarketingType;
      isAgencyRole: boolean;
    };
  }
}

三斜線指令

三斜線指令必須放在檔案的最頂端,三斜線指令的前面只允許出現單行或多行註釋。
三斜線指令的作用是為了描述模組之間的依賴關係,通常情況下並不會用到,不過在以下場景,還是比較有用。

  • 當在書寫一個依賴其他型別的全域性型別宣告檔案時
  • 當需要依賴一個全域性變數的宣告檔案時
  • 當處理編譯後 .d.ts 檔案丟失的問題

當需要書寫一個依賴其他型別的全域性型別宣告檔案時

在全域性變數的宣告檔案中,是不允許出現 import, export 關鍵字的,一旦出現了,那麼當前的宣告檔案就不再是全域性型別的宣告檔案了,所以這時就需要用到三斜線指令。

/// <reference types="jquery" />

declare function foo(options: JQuery.AjaxSettings): string;

依賴一個全域性變數的宣告檔案

當需要依賴一個全域性變數的宣告檔案時,由於全域性變數不支援通過 import 匯入,所以就需要使用三斜線指令來引入了。

/// <reference types="node" />

export function foo(p: NodeJS.Process): string;

當處理編譯後 .d.ts 檔案丟失的問題

在寫專案的時候,專案裡編寫的 .d.ts 檔案在 tsc 編譯後,並不會放置到對應的 dist 目錄下,這時候就需要手動指定依賴的全域性型別。

/// <reference path="types/global.d.ts" />

// ValueOf 來自 global.d.ts
export declare type ComplexOptions = ValueOf<typeof complexOptions>;

reference

  • path: 指定型別檔案的路徑
  • types: 指定型別檔案對應的包,例如 對應的型別檔案是

參考

TypeScript Handbook:https://www.typescriptlang.or...
TypeScript Learning: https://github.com/Barrior/ty...
你不知道的 TypeScript 高階技巧:https://www.infoq.cn/article/...
TypeScript 入門教程:https://ts.xcatliu.com/basics...
讀懂型別體操:TypeScript 型別超程式設計基礎入門:https://zhuanlan.zhihu.com/p/...
JavaScript 超程式設計:https://chinese.freecodecamp....
其他資料

相關文章