編寫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 RFunction 類型的作用是差不多的,這樣寫只是為了能夠在過程中拿到函數的返回值類型。 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] ,只要確保類型變量 KT 的索引即可

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: Tname: 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(中文版)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM