編寫TypeScript工具型別,你需要知道的知識
什麼是工具型別
用 JavaScript 編寫中大型程式是離不開 lodash
工具的,而用 TypeScript 程式設計同樣離不開工具型別的幫助,工具型別就是型別版的 lodash
。簡單的來說,就是把已有的型別經過型別轉換構造一個新的型別。工具型別本身也是型別,得益於泛型的幫助,使其能夠對型別進行抽象的處理。工具型別主要目的是簡化型別程式設計的過程,提高生產力。
使用工具型別的好處
先來看看一個場景,體會下工具型別帶來什麼好處。
// 一個使用者介面 interface User { name: string avatar: string country:string friend:{ name: string sex: string } }
現在業務要求 User
接口裡的成員都變為可選,你會怎麼做?再定義一個介面,為成員都加上可選修飾符嗎?這種方法確實可行,但接口裡有幾十個成員呢?此時,工具型別就可以派上用場。
type Partial<T> = {[K in keyof T]?: T[K]} type PartialUser = Partial<User> // 此時PartialUser等同於 type PartialUser = { name?: string | undefined; avatar?: string | undefined; country?: string | undefined; friend?: { name: string; sex: string; } | undefined; }
通過工具型別的處理,我們得到一個新的型別。即使成員有成千上百個,我們也只需要一行程式碼。由於 friend
成員是物件,上面的 Partial
處理只對第一層新增可選修飾符,假如需要將物件成員內的成員也新增可選修飾符,可以使用 Partial
遞迴來解決。
type partial<T> = {
[K in keyof T]?: T[K] extends object ? partial<T[K]> : T[K]
}
如果你是第一次看到以上的寫法,可能會很懵逼,不知道發生了什麼操作。不慌,且往下看,或許當你看完這篇文章再回過頭來看時,會發現原來是這麼一回事。
關鍵字
TypeScript 中的一些關鍵字對於編寫工具型別必不可缺
keyof
語法: keyof T 。返回聯合型別,為 T
的所有 key
interface User{
name: string
age: number
}
type Man = {
name:string,
height: 180
}
type ManKeys = keyof Man // "name" | "height"
type UserKeys = keyof User // "name" | "age"
typeof
語法: typeof T 。返回 T
的成員的型別
let arr = ['apple', 'banana', 100]
let man = {
name: 'Jeo',
age: 20,
height: 180
}
type Arr = typeof arr // (string | number)[]
type Man = typeof man // {name: string; age: number; height: number;}
infer
相比上面兩個關鍵字, infer
的使用可能會有點難理解。在有條件型別的 extends
子語句中,允許出現 infer
宣告,它會引入一個待推斷的型別變數。這個推斷的型別變數可以在有條件型別的 true
分支中被引用。
簡單來說,它可以把型別處理過程的某個部分抽離出來當做型別變數。以下例子需要結合高階型別,如果不能理解,可以選擇跳轉這部分,把高階型別看完後再回來。
下面程式碼會提取函式型別的返回值型別:
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;
(...args: any[]) => infer R
和 Function
型別的作用是差不多的,這樣寫只是為了能夠在過程中拿到函式的返回值型別。 infer
在這裡相當於把返回值型別宣告成一個型別變數,提供給後面的過程使用。
有條件型別可以巢狀來構成一系列的匹配模式,按順序進行求值:
type Unpacked<T> =
T extends (infer U)[] ? U :
T extends (...args: any[]) => infer U ? U :
T extends Promise<infer U> ? U :
T;
type T0 = Unpacked<string>; // string
type T1 = Unpacked<string[]>; // string
type T2 = Unpacked<() => string>; // string
type T3 = Unpacked<Promise<string>>; // string
type T4 = Unpacked<Promise<string>[]>; // Promise<string>
type T5 = Unpacked<Unpacked<Promise<string>[]>>; // string
高階型別
交叉型別
語法: A & B ,交叉型別可以把多個型別合併成一個新型別,新型別將擁有所有型別的成員。
interface Shape {
size: string
color: string
}
interface Brand {
name: string
price: number
}
let clothes: Shape&Brand = {
name: 'Uniqlo',
color: 'blue',
size: 'XL',
price: 200
}
聯合型別
語法: typeA | typeB ,聯合型別是包含多種型別的型別,被繫結聯合型別的成員只需滿足其中一種型別。
function pushItem(item:string|number){
let array:Array<string|number> = ['apple','banana','cherry']
array.push(item)
}
pushItem(10) // ok
pushItem('durian') // ok
通常,刪除使用者資訊需要提供 id
,建立使用者則不需要 id
。這種型別應該如何定義?如果選擇為 id
欄位提供新增可選修飾符的話,那就太不明智了。因為在刪除使用者時,即使不填寫 id
屬性也不會報錯,這不是我們想要的結果。
可辨識聯合型別能幫助我們解決這個問題:
type UserAction = {
action: 'create'
}|{
id:number
action: 'delete'
}
let userAction:UserAction = {
id: 1,
action: 'delete'
}
字面量型別
字⾯量型別主要分為 真值字⾯量型別,數字字⾯量型別,列舉字⾯量型別,⼤整數字⾯量型別、字串字⾯量型別。
const a: 2333 = 2333 // ok
const b: 0b10 = 2 // ok
const c: 0x514 = 0x514 // ok
const d: 'apple' = 'apple' // ok
const e: true = true // ok
const f: 'apple' = 'banana' // 不能將型別“"banana"”分配給型別“"apple"”
下面以字串字面量型別作為例子:
字串字面量型別允許指定的字串作為型別。如果使用 JavaScript 的模式中看下面的例子,會把 level
當成一個值。但在 TypeScript 中,千萬不要用這種思維去看待, level
表示的就是一個字串 coder
的型別,被繫結這個型別的變數,它的值只能是 coder
。
type Level = 'coder'
let level:Level = 'coder' // ok
let level2:Level = 'programmer' // 不能將型別“"programmer"”分配給型別“"coder"”
字串和聯合型別搭配,可以實現類似列舉型別的字串
type Level = 'coder' | 'leader' | 'boss'
function getWork(level: Level){
if(level === 'coder'){
console.log('打程式碼、摸魚')
}else if(level === 'leader'){
console.log('造輪子、架構')
}else if(level === 'boss'){
console.log('喝茶、談生意')
}
}
getWork('coder')
getWork('user') // 型別“"user"”的引數不能賦給型別“Level”的引數
索引型別
語法: T[K] ,使用索引型別,編譯器就能夠檢查使用動態屬性名的程式碼。在 JavaScript 中,物件可以用屬性名獲取值,而在 TypeScript 中,這一切被抽象化,變成通過索引獲取型別。就像 person[name]
被抽象成型別 Person[name]
,在以下例子中代表的就是 string
型別。
interface Person {
name: string;
age: number;
}
let person: Person = {
name: 'Jeo',
age: 20
}
let name = person['name'] // 'Jeo'
type str = Person['name'] // string
我們可以在普通的上下文裡使用 T[K]
,只要確保型別變數 K
為 T
的索引即可
function getProperty<T, K extends keyof T>(o: T, name: K): T[K] {
return o[name]; // o[name] is of type T[K]
}
getProperty
裡的 o: T
和 name: K
,意味著 o[name]: T[K]
let name: string = getProperty(person, 'name');
let age: number = getProperty(person, 'age');
let unknown = getProperty(person, 'unknown'); // 型別“"unknown"”的引數不能賦給型別“"name" | "age"”的引數
K
不僅可以傳成員,成員的字串聯合型別也是有效的
type Union = Person[keyof Person] // "string" | "number"
對映型別
語法: [K in Keys] 。TypeScript 提供了從舊型別中建立新型別的一種方式 。在對映型別裡,新型別以相同的形式去轉換舊型別裡每個屬性。根據 Keys
來建立型別, Keys
有效值為 string | number | symbol 或 聯合型別。
type Keys = 'name'|10
type User = {
[K in Keys]: string
}
該語法可以理解為內部使用了迴圈
- K: 依次繫結到每個屬性,相當於 Keys 的項
- Keys: 包含要迭代的屬性名的集合
因此以上的例子等同於:
type User = {
name: string;
10: string;
}
需要注意的是這個語法描述的是型別而非成員。若想新增額外的成員,需使用交叉型別:
// 這樣使用
type ReadonlyWithNewMember<T> = {
readonly [P in keyof T]: T[P];
} & { newMember: boolean }
// 不要這樣使用
// 這會報錯!
type ReadonlyWithNewMember<T> = {
readonly [P in keyof T]: T[P];
newMember: boolean;
}
在真正應用中,對映型別結合索引訪問型別是一個很好的搭配。因為轉換過程會基於一些已存在的型別,且按照一定的方式轉換欄位。你可以把這過程理解為 JavaScript 中陣列的 map
方法,在原本的基礎上擴充套件元素( TypeScript 中指型別),當然這種理解過程可能有點粗糙。
文章開頭的 Partial
工具型別正是使用這種搭配,為原有的型別新增可選修飾符。
條件型別
語法: T extends U ? X : Y ,若 T
能夠賦值給 U
,那麼型別是 X
,否則為 Y
。條件型別以條件表示式推斷型別關係,選擇其中一個分支。相對上面的型別,條件型別很好理解,類似 JavaScript 中的三目運算子。
再來看看文章開頭遞迴的操作,你就會發現能看懂這段處理過程。過程:使用對映型別遍歷,判斷 T[K]
屬於 object
型別,則把 T[K]
傳入 partial
遞迴,否則返回型別 T[K]
。
type partial<T> = {
[K in keyof T]?: T[K] extends object ? partial<T[K]> : T[K]
}
小結
關於一些常用的高階型別相信大家都瞭解得差不多,下面將應用這些型別來編寫一個工具型別。
該工具型別實現的功能為篩選出兩個 interface
的公共成員:
interface PersonA{
name: string
age: number
boyfriend: string
car: {
type: 'Benz'
}
}
interface PersonB{
name: string
age: string
girlfriend: string
car: {
type: 'bicycle'
}
}
type Filter<T,U> = T extends U ? T : never
type Common<A, B> = {
[K in Filter<keyof A, keyof B>]: A[K] extends B[K] ? A[K] : A[K]|B[K]
}
通過 Filter
篩選出公共的成員聯合型別 "name"|"age"
作為對映型別的集合,公共部分可能會存在型別不同的情況,因此要為成員保留兩者的型別。
type CommonMember = Common<PersonA, PersonB>
// 等同於
type CommonMember = {
name: string;
age: string | number;
car: {
type: "Benz";
} | {
type: "bicycle";
};
}
內建工具型別
為了滿足常見的型別轉換需求, TypeScript 也提供一些內建工具型別,這些型別是全域性可見的。
Partial
構造型別 T
,並將它所有的屬性設定為可選的。它的返回型別表示輸入型別的所有子型別。
interface Todo {
title: string;
description: string;
}
function updateTodo(todo: Todo, fieldsToUpdate: Partial<Todo>) {
return { ...todo, ...fieldsToUpdate };
}
const todo1 = {
title: 'organize desk',
description: 'clear clutter',
};
const todo2 = updateTodo(todo1, {
description: 'throw out trash',
});
Readonly
構造型別T,並將它所有的屬性設定為readonly,也就是說構造出的型別的屬性不能被再次賦值。
interface Todo {
title: string;
}
const todo: Readonly<Todo> = {
title: 'Delete inactive users',
};
todo.title = 'Hello'; // Error: cannot reassign a readonly property
Record<K, T>
構造一個型別,其屬性名的型別為K,屬性值的型別為T。這個工具可用來將某個型別的屬性對映到另一個型別上。
interface PageInfo {
title: string;
}
type Page = 'home' | 'about' | 'contact';
const x: Record<Page, PageInfo> = {
about: { title: 'about' },
contact: { title: 'contact' },
home: { title: 'home' },
};
Pick<T, K>
從型別T中挑選部分屬性K來構造型別。
interface Todo {
title: string;
description: string;
completed: boolean;
}
type TodoPreview = Pick<Todo, 'title' | 'completed'>;
const todo: TodoPreview = {
title: 'Clean room',
completed: false,
};
Omit<T, K>
從型別T中剔除部分屬性K來構造型別,與Pick相反。
interface Todo {
title: string;
description: string;
completed: boolean;
}
type TodoPreview = Omit<Todo, 'title' | 'completed'>;
const todo: TodoPreview = {
description: 'I am description'
};
Exclude<T, U>
從型別T中剔除所有可以賦值給U的屬性,然後構造一個型別。
type T0 = Exclude<"a" | "b" | "c", "a">; // "b" | "c"
type T1 = Exclude<"a" | "b" | "c", "a" | "b">; // "c"
type T2 = Exclude<string | number | (() => void), Function>; // string | number
Extract<T, U>
從型別T中提取所有可以賦值給U的型別,然後構造一個型別。
type T0 = Extract<"a" | "b" | "c", "a" | "f">; // "a"
type T1 = Extract<string | number | (() => void), Function>; // () => void
NonNullable
從型別T中剔除null和undefined,然後構造一個型別。
type T0 = NonNullable<string | number | undefined>; // string | number
type T1 = NonNullable<string[] | null | undefined>; // string[]
ReturnType
由函式型別T的返回值型別構造一個型別。
type T0 = ReturnType<() => string>; // string
type T1 = ReturnType<(s: string) => void>; // void
type T2 = ReturnType<(<T>() => T)>; // {}
type T3 = ReturnType<(<T extends U, U extends number[]>() => T)>; // number[]
type T5 = ReturnType<any>; // any
type T6 = ReturnType<never>; // any
type T7 = ReturnType<string>; // Error
type T8 = ReturnType<Function>; // Error
InstanceType
由建構函式型別T的例項型別構造一個型別。
class C {
x = 0;
y = 0;
}
type T0 = InstanceType<typeof C>; // C
type T1 = InstanceType<any>; // any
type T2 = InstanceType<never>; // any
type T3 = InstanceType<string>; // Error
type T4 = InstanceType<Function>; // Error
let t0:T0 = {
x: 10,
y: 2
}
Required
構造一個型別,使型別T的所有屬性為required。
interface Props {
a?: number;
b?: string;
};
const obj: Props = { a: 5 }; // OK
const obj2: Required<Props> = { a: 5 }; // Error: property 'b' missing
寫在最後
除了介紹編寫工具型別所需要具備的一些知識點,以及 TypeScript 內建的工具型別。更重要的是抽象思維能力,不難發現上面的例子大部分沒有具體的值運算,都是使用型別在程式設計。想要理解這些知識,必須要進入到抽象邏輯裡思考。還有高階型別的搭配和型別轉換的處理,也要通過大量的實踐才能玩好。說實話,自己學習這些知識時,真正感受到 TypeScript 的深不可測,也瞭解到自身的不足之處。突然想起在某篇文章的一句話:技術是無止盡的,接觸的越多,越能感到自己的渺小。
參考資料
Typescript Hankbook(中文版