目錄
- 型別超程式設計
- 內建工具型別窺探
- 外部工具型別推薦
- 新操作符
- 宣告檔案
型別超程式設計
什麼是超程式設計:
維基百科是這樣描述的:超程式設計是一種程式設計技術,編寫出來的計算機程式能夠將其他程式作為資料來處理。意味著可以編寫出這樣的程式:它能夠讀取、生成、分析或者轉換其它程式,甚至在執行時修改程式自身。在某些情況下,這使程式設計師可以最大限度地減少表達解決方案的程式碼行數,從而減少開發時間。它還允許程式更靈活有效地處理新情況而無需重新編譯。
簡單的說,超程式設計能夠寫出這樣的程式碼:
- 可以生成程式碼
- 可以在執行時修改語言結構,這種現象被稱為反射程式設計(Reflective Metaprogramming)或反射(Reflection)
什麼是反射:
反射是超程式設計的一個分支,反射又有三個子分支:
- 自省(Introspection):程式碼能夠自我檢查、訪問內部屬性,我們可以據此獲得程式碼的底層資訊。
- 自我修改(Self-Modification):顧名思義,程式碼可以修改自身。
- 調解(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....
其他資料