Skip to main content

[TS] Type Manipulation

keywords: generics, conditional type, Mapped Types, keyof, typeof, infer

Conditional Type

Conditional Types @ TypeScript > Handbook > Type Manipulation

// 如果 SomeType 能夠滿足 OtherType 則 SomeType 為 TrueType;否則為 FalseType
NewType = SomeType extends OtherType ? TrueType : FalseType;

要看是否符合某個條件,可以用:

// type T = [想檢驗的條件] ? true : false
type T = 30 extends number ? true : false;

基本語法

sample code

// https://www.typescriptlang.org/docs/handbook/2/conditional-types.html
interface IdLabel {
id: number /* some fields */;
}
interface NameLabel {
name: string /* other fields */;
}

type NameOrId<T extends number | string> = T extends number ? IdLabel : NameLabel;

function createLabel<T extends number | string>(idOrName: T): NameOrId<T> {
throw 'unimplemented';
}

let a = createLabel('typescript'); // a is NameLabel
let b = createLabel(2.8); // b is IdLabel
let c = createLabel(Math.random() ? 'Hello' : 42); // c is NameLabel | IdLabel
let d = createLabel(['foo']); // Type 'string[]' is not assignable to type 'string'
tip

T extends K 可以想成是 T 能夠滿足 K,也就是 T 是 K 的子集合(subset),K 的條件會比 T 來的寬鬆

Conditional Type Constraints

Example: MessageOf

Sample code

如果想要建立一個名為 MessageOf 的 Type,且其 Type 會是 T 中 message property 的型別:

// 因為 TypeScript 無法確定 T 一定有 message 這個 property 所以會噴錯
type MessageOfWithError<T> = T['message'];

// 透過 extends 確保 T 一定會有 message 這個 property
// 如此 MessageOf 這個 type 的型別就是會 T 當中 message property 的型別
type MessageOf<T extends { message: unknown }> = T['message'];

根據定義好的 MessageOf 這個 utility type,可以產生新的 Type:

// 因為 TypeScript 無法確定 T 一定有 message 這個 property 所以會噴錯
type MessageOfWithError<T> = T['message'];

// 透過 extends 確保 T 一定會有 message 這個 property
// 如此 MessageOf 這個 type 的型別就是會 T 當中 message property 的型別
type MessageOf<T extends { message: unknown }> = T['message'];

interface Email {
message: string;
}

interface Dog {
bark(): void;
}

// EmailMessageContents 會取得 Email 這個 Type 中 message property 的型別
type EmailMessageContents = MessageOf<Email>; // string

Example: Flatten

sample code - flatten

建立一個 utility type 可以取得另一個陣列型別內元素的型別:

// 如果 T 是陣列,則使用陣列元素的型別,否則使用 T
// 這裡 T[number] 使用的是 indexed 的方式來存取某陣列型別內元素的型別
type Flatten<T> = T extends any[] ? T[number] : T;

type Str = Flatten<string[]>; // Str 會是 string type;

type Num = Flatten<number>; // Num 會是 number type;

Example: Wrap

type Wrap<T> = T extends { length: number } ? [T] : T;

type stringArr = Wrap<string>;

extends 蠻特別的地方

type S = 'foo' | 'bar' | 35;

// 如果是直接使用 Extends 的話,會是直接比較 "'foo' | 'bar' | 35 extends string"
type T = S extends string ? true : false; // false

// 但如果是用泛型當成參數帶入的話,則是會一一比較
type T<C> = C extends string ? true : any;
type K = T<S>; // any。實際上是 true | true | any => 最後得到 any

Inferring within Conditional Types

infer 這個關鍵字只能用在 conditional type declaration 中。

透過 infer 關鍵字來推論函式回傳的型別,並使用該型別來定義新的型別:

// https://www.typescriptlang.org/docs/handbook/2/conditional-types.html
type GetReturnType<T> = T extends (...args: never[]) => infer R ? R : never;

onXXX 變成 handleXXX 的型別:

type Handler<S extends string> = S extends `on${infer P}` ? `handle${P}` : S;
type Foo = Handler<'onClick'>; // type Foo = "handleClick"

如果搭配 Mapped Type 還可以更多樣:

interface IEventHandlers {
onClick: () => void;
onChange: () => void;
}

// 把 onXXX 變成 handleXXX
type Handler<S extends string> = S extends `on${infer P}` ? `handle${P}` : S;

// 把物件的 key 都做轉換
type handleEvents<T> = {
[K in keyof T as `${Handler<string & K>}`]: T[keyof T];
};

type HandleEvents = handleEvents<IEventHandlers>;
// type HandleEvents = {
// handleClick: (() => void) | (() => void);
// handleChange: (() => void) | (() => void);
// }

Distributive Conditional Types

// ToArray 是一個 utility type,會產生帶有特定型別元素的陣列
type ToArray<T> = T extends any ? T[] : never;

// 預設會使用分配律(distributive law)
// 所以型別會變成 ToArray<string> | ToArray<number>
// 因此等同於 string[] | number[]
type StrArrOrNumArr = ToArray<string | number>;

// 如果想要避免分配律發生,可以在 extends 前後的型別都加上 []
type ToArrayNotDist<T> = [T] extends [any] ? T[] : never;
type StrOrNumArr = ToArrayNotDist<string | number>; // (string | number)[]

keyof

Keyof Type Operator @ TypeScript > Handbook > Type Manipulation

keyof 可以很方便的把物件類的型別的 key 給取出:

enum MarriedStatus {
MARRIED = 'married',
SINGLE = 'single',
}

type Person = {
firstName: string;
lastName: string;
age: number;
marriedStatus: MarriedStatus;
};

type P = keyof Person; // "firstName" | "lastName" | "age" | "marriedStatus"

keyof 搭配 & string

另外,keyof 很適合搭配 & string 來使用,如此可以確定取出來的 type 一定是 string type 而不會是 symbol 或其他可能,而且 VSCode 在滑鼠以上去時,也會直接顯示對應的 type,例如, "firstName" | "lastName" | "age" | "marriedStatus",而不是單純寫 keyof Person

type P = keyof Person & string; // "firstName" | "lastName" | "age" | "marriedStatus"

keyof TypeScript

typeof

typeof Type Operator @ TypeScript > Handbook > Type Manipulation

透過 typeof 可以直接取得 TS 針對 value 所推論出的型別,並拿來產生新的 type 使用:

const me = {
firstName: 'Aaron',
lastName: 'Chen',
email: 'pjchender@gmail.com',
};

type Me = typeof me;
// type Me = {
// firstName: string;
// lastName: string;
// email: string;
// }

typeof 取出 array 中的元素變成 Type(array to type)

const colors = ['red', 'green', 'blue'] as const;

type Color = Uppercase<typeof colors[number]>; // "RED" | "GREEN" | "BLUE"

type ActionType = `ADJUST_${Color}`; // "ADJUST_RED" | "ADJUST_GREEN" | "ADJUST_BLUE"

Indexed Access Types

Indexed Access Types @ TypeScript > Handbook > Type Manipulation

我們可以直接透過 index 的方式取得物件類型別的 value 的型別:

type Device = {
name: string;
model: string;
price: number;
isLaunch: boolean;
};

// indexed access types
type DeviceName = Device['name']; // type DeviceName = string

// 在裡面可以使用聯集(union)
type T = Device['name' | 'isLaunch']; // type T = string | boolean

搭配 keyof

如果搭配 keyof 的話,可以直接取出該物件所有 value 的型別:

type Device = {
name: string;
model: string;
price: number;
isLaunch: boolean;
};

type ValueOfDevice = Device[keyof Device]; // type ValueOfDevice = string | number | boolean

另一個使用的例子:

interface CommunicationMethods {
email: HasEmail;
phone: HasPhoneNumber;
fax: { fax: number };
}

type AllCommKeys = keyof CommunicationMethods; // "email" | "phone" | "fax"
type AllCommValues = CommunicationMethods[keyof CommunicationMethods]; // HasEmail | HasPhoneNumber | {fax: number;}

搭配 typeof 與 Array

如果是陣列的話,則可以搭配 typeof 取出陣列所有可能的值:

const arr = ['number', true, 3];
type A = typeof arr[number]; // type A = string | number | boolean

如果該陣列是 Readonly Array 的話,則可以很方便的組出 union string:

// brands 是 Readonly Array
const brands = ['apple', 'samsung', 'xiaomi', 'google'] as const;
type B = typeof brands[number]; // type B = "apple" | "samsung" | "xiaomi" | "google"

如果是 array of object 的話,則可以方便的取出 Object 的型別:

const brands = [
{ name: 'apple', model: 'iphone' },
{ name: 'samsung', model: 'galaxy' },
{ name: 'google', model: 'pixel' },
];

type B = typeof brands[number];
// type B = {
// name: string;
// model: string;
// }

Index Signatures, Mapped Types & In Operator

Mapped Types @ TypeScript > Handbook > Type Manipulation

Index Signatures

在 TypeScript 中可以使用 index signatures 來建立類似 dictionary 的型別:

type Device = {
name: string;
model: string;
price: number;
isLaunch: boolean;
};

// index signature
type DeviceDict = {
[key: string]: Device | undefined;
};

// Dict:寫成泛型
type Dict<T> = {
[key: string]: T | undefined;
};

如此可以讓某個物件的 key 一定是 string,value 一定是 Device | undefined

但如果我們不想要讓 key 的範圍這麼廣泛,除了可以一樣用 index signature 一一列舉之外,並不能透過 union 的方式來寫:

// ⭕️ 這樣列舉是可以的
type DeviceDict = {
[key: string]: Device | undefined;

// 列舉可被接受的 key
apple: Device;
samsung: Device;
};

// ❌ 不能這樣寫,會出現錯誤
// An index signature parameter type cannot be a literal type or generic type.
// Consider using a mapped object type instead
type DeviceDict = {
[key: 'apple' | 'samsung']: Device | undefined;
};

而 Mapped Types 則提供更方便的方式來定義和操作型別。

note

Index signature 和 Mapped Type 很大的差別在於,index signature 的 key 適用的是 stringnumber;Mapped Type 的 key 則是限縮後的 string (subset of string)或 number。

Mapped Types & in operator

透過 Mapped Type 將可以更容易的操作物件類的型別,也可以使用 Mapped Typed 來產生新的型別。

tip

操作物件型別時,要很快聯想到 Mapped Types,不論是想改變物件屬性 key 的名稱或 value 的型別,都可以透過。Mapped Types 達到。

in:loop over 的概念

Mapped Type 搭配 in 時,要把它想成類似 JavaScript 中的 for...infor...of 使用,它會列舉 in 後面的所有值。

當我們寫 [P in K],就類似 for (item of arr) 的感覺,P 對應的是 item,K 對應到的則是 arr

  • P 表示的是每次疊代時的元素值,也就是會多一個 P (Parameter) 可以使用,P 可以換成是任何名字。這裡依序會是 apple, samsung, ...。
type T = {
[P in 'apple' | 'samsung' | 'xiaomi' | 'google']: any;
};

所以當使用 [key in 'apple' | 'samsung' | 'xiaomi' | 'google'],會產生出來的型別是 apple, Samsung 等都需要出現在物件中,而每次 loop 的 element 則會保存在 key 這個 type 中:

type DeviceRecord = {
// 搭配 in operator,key 會是每次 loop 的 element
[key in 'apple' | 'samsung' | 'xiaomi' | 'google']: Device;

// 如果不同的 key 的 value 對應到不同的型別,可以寫像這樣
// K 會是每次 loop 的 element
// [K in 'apple' | 'samsung' | 'xiaomi' | 'google']: K;
};

// 會變成
type DeviceRecord = {
apple: Device;
samsung: Device;
xiaomi: Device;
google: Device;
};
tip

P in T 的使用可以想成是 JS 中 for...offor...in 的方法,它會疊代(iterate)in 後面的每個元素,而 P 是多產生的一個變數,表示的是每次迴圈跑到的元素。

Mapped Type 可以被視為 index signatures 的子集合(subset)。搭配 in operator 時,可以在 key 的地方使用 union type:

type Device = {
name: string;
model: string;
price: number;
isLaunch: boolean;
};

// Mapped Type
type DeviceRecord = {
// 搭配 in operator
[key in 'apple' | 'samsung' | 'xiaomi' | 'google']: Device;
};
note

in 後面可以放的是 stringnumbersymbol 型別,也就會是 keyof any

in:取出 enum 的 value 產生新的 Object Type

enum GENDER {
MALE = 'male',
FEMALE = 'female',
}

type Gender = { [P in GENDER]: string };

// type Gender = {
// male: string;
// female: string;
// }

in keyof:取出 object 的 key 產生新的 Object Type

in keyof 的概念是同時使用了 indexed access type 中「用 key 來取得 value 的型別」,以及 Mapped Typein 的概念而成:

interface IGender {
Male: string;
Female: string;
}

type Gender = { [P in keyof IGender]: string };
// type Gender = {
// Male: string;
// Female: string;
// };

remapping

可以利用 Mapped Types 的特性來建立新的 type:

/**
* 利用 Mapped Type 產生新的型別
*/
type OptionsFlags<T> = {
[P in keyof T]: boolean;
};

type FeatureFlags = {
darkMode: () => void;
newUserProfile: () => void;
};

// P in keyof T 指的會是 "darkMode" | "newUserProfile"
// 因此 FeatureOptions 的型別會是 { darkMode: boolean; newUserProfile: boolean }
type FeatureOptions = OptionsFlags<FeatureFlags>;

利用這樣的特性也可以原封不動的產生規則一樣的新的 Type:

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

type Person = {
fullName: string;
phoneNumber: number;
};

type NewPerson = Clone<Person>;

進一步搭配 as 使用(強大)

  • 要留意 as 後的 template string 要使用 string & K 來縮限 K 的型別
interface IEvent {
click: () => void;
change: () => void;
}

// 要留意 as 後的 template string 要使用 string & K 來縮限 K 的型別
type EventHandler<T> = {
[K in keyof T as `on${Capitalize<string & K>}`]: T[K];
};

type EventHandlers = EventHandler<IEvent>;

type EventHandlers = {
onClick: () => void;
onChange: () => void;
};

實際應用

/**
* Indexed Access Type 可以透過 key 來取得 interface 中 value 的型別
*/
interface CommunicationMethods {
email: HasEmail;
phone: HasPhoneNumber;
fax: { fax: number };
}

type Method = keyof CommunicationMethods;

// keyof CommunicationMethods 會把 'email' | 'phone' | 'fax' 組成新的 type
// 所以這裡的 K 一定要滿足 'email' | 'phone' | 'fax' 的條件
// content 用的是 Indexed Access Types,所以會是 HasEmail | HasPhoneNumber | { fax: number }
function contact<K extends keyof CommunicationMethods>(
method: K,
content: CommunicationMethods[K], // 💡turning key into value - mapped type
) {
// ...
}
contact('email', { name: 'foo', email: 'foo@example.com' });

// ❌ 報錯,phone 要對應到的是 HasPhoneNumber
//contact('phone', { name: 'foo', email: 'foo@example.com' });

contact('fax', { fax: 3000 });

Record Utility

Record Keys Type @ TypeScript > Handbook > Utility Types

Record 是 TypeScript 內建的 utility type,可以把它想成是 {[key: string]: any} 的子集合。

了解 Mapped Type 後,就可以理解 Record 這個 Utility Type。

原本單純的 Mapped Type 是這樣:

type Device = {
name: string;
model: string;
price: number;
isLaunch: boolean;
};

type DeviceRecord = {
[key in 'apple' | 'samsung' | 'xiaomi' | 'google']: Device;
};

可以改成泛型的寫法:

  • 由於 in 後面可以放的是 stringnumbersymbol 型別,因此改成泛型時要記得限縮其型別,例如 extends string
// 改成泛型後的寫法
type MyRecord<K extends string, T> = {
[key in K]: T;
};

// 使用 MyRecord 來達到一樣的效果
type DeviceRecord = MyRecord<'apple' | 'samsung' | 'xiaomi' | 'google', Device>;

而在 TypeScript 中就有一個內建的 utility type 是 Record,它的實作是:

  • keyof any 的值就會是 string | number | symbol
type KeyOfAny = keyof any; // type KeyOfAny = string | number | symbol

// TypeScript 中內建 Record utility type 的實作
type Record<K extends keyof any, T> = {
[key in K]: T;
};

// 使用 Record 來達到一樣的效果
type DeviceRecord = Record<'apple' | 'samsung' | 'xiaomi' | 'google', Device>;
tip

Record 是用 Mapped Type 的概念寫出來的 utility type。

Pick Utility

使用 in 搭配 indexed access type 可以限縮某一個物件中的型別:

type Device = {
name: string;
model: string;
price: number;
isLaunch: boolean;
};

type PartialDevice = {
[K in 'name' | 'model']: Device[K];
};

// type PartialDevice = {
// name: string;
// model: string;
// }

同樣可以改寫成泛型的寫法:

  • 為了確保 Device[K] 取得到值,K 必須要滿足是 Device 的 key,否者 TS 會報錯,所以需要加上 K extends keyof Device 來確保它一定取得到值
type PickDevice<Keys extends keyof Device> = {
[K in Keys]: Device[K];
};

// 使用 PickDevice 來達到相同的效果
type PartialDevice = PickDevice<'name' | 'model'>;

我們可以再改寫的更 general 一點,把 Device 也變成一個參數(V):

type MyPick<V, Keys extends keyof V> = {
[K in Keys]: V[K];
};

// 使用 MyPick 達到一樣的效果
type PartialDevice = MyPick<Device, 'name' | 'model'>;

而在 TypeScript 中就有一個內建的 utility type 是 Pick,它的實作是:

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

// 使用 TS 內建的 Pick 達到一樣的效果
type PartialDevice = Pick<Device, 'name' | 'model'>;
tip

Record 是用 Mapped Type 和 indexed access types 的概念寫出來的 utility type。

使用 Modifiers

在根據 key 產生新的 type 時,TypeScript 提供一些 mapping modifiers 可以讓你把原本 Type 中某屬性的特性(例如 readonly?)拿掉後,再產生新的 type。

Map 後產生新的 Key 名稱

/**
* Mapped 後產生新的 Key 名稱
*/
type ReMapWithGet<T> = {
// 因為 P 有可能是 string | number | symbol,透過 string & P 可以限制它一定是 string
[P in keyof T as `map${Capitalize<string & P>}`]: T[P];
};
type Person = {
fullName: string;
phoneNumber: number;
};

// NewPerson 的型別會是 { mapFullName: string; mapPhoneNumber: number; }
type NewPerson = ReMapWithGet<Person>;

Map 後過濾掉特定屬性

也可以搭配 Exclude 這個 utility 來移除原本 interface 中的屬性:

/**
* Mapped 搭配 Exclude 移除屬性
*/

type ExcludeMap<T> = {
[P in keyof T as Exclude<P, 'fullName'>]: T[P];
};
// OnlyPhoneNumber 的型別會是 type OnlyPhoneNumber = { phoneNumber: number; }
type OnlyPhoneNumber = ExcludeMap<Person>;

Partial, Required, Readonly

透過 Mapped Type 搭配 modifiers,可以產生 PartialRequiredReadonly 這幾個內建的 utility types:

// 所有 T 物件中的 key 都變成 optional
type Partial<T> = {
[P in keyof T]?: T[P];
};

// 所有 T 物件中的 key 都變成 required
type Required<T> = {
[P in keyof T]-?: T[P];
};

// 所有 T 物件中的 key 都變成 readonly
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};

Template Literal Type

透過 Template Literal Type 可以把兩個 Union Type 組出更多 Union Type:

type PositionY = 'top' | 'center' | 'bottom';
type PositionX = 'left' | 'center' | 'right';

// 接受 'center' 但不接受 'center-center'
type Position = Exclude<`${PositionX}-${PositionY}` | 'center', 'center-center'>;
// type Position = "center" | "left-center" | "left-top" | "left-bottom" | "center-top" | "center-bottom" | "right-center" | "right-top" | "right-bottom"
type Color = 'RED' | 'GREEN' | 'BLUE';

// type Color = 'RED' | 'GREEN' | 'BLUE';
type ActionType = `ADJUST_${Color}`;
// 也可以直接寫這樣
// type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"
type Corner = `${'top' | 'bottom'}-${'left' | 'right'}`;

Filtering Properties

透過上面所學的,還可以做到 filtering property 的作用。

filter by key

假設我們想要過濾出 Events 中所有屬性名稱是 handle__ 開頭的,將可以這樣寫:

type Events = {
onClick: (e: MouseEvent) => void;
onChange: (e: Event) => void;
handleClick: (e: MouseEvent) => void;
handleChange: (e: Event) => void;
};

type EventHandlersKey = Extract<keyof Events, `on${string}`>; // "onClick" | "onChange"

type EventHandler = {
[P in EventHandlersKey]: Events[P];
};

// type EventHandler = {
// onClick: (e: MouseEvent) => void;
// onChange: (e: Event) => void;
// }

另一個例子是如果想要 filter 所有是以 query 開頭的 key:

// https://www.typescript-training.com/course/intermediate-v1/09-mapped-types/#filtering-properties-out

type DocKeys = Extract<keyof Document, `query${string}`>;

type KeyFilteredDoc = {
[K in DocKeys]: Document[K];
};

轉成 utility type

可以進一步寫成泛型:

// 從 type T 中找出所有 key 可以滿足 U 的
type FilteredByKey<T, U> = {
[K in Extract<keyof T, U>]: T[K];
};

type KeyFilteredDocs = FilteredByKey<Document, `query${string}`>;

filter by value

假設我們有一個 options 的 interface,我們只想要保留 value 是 boolean 型別的屬性:

// 假設只要保留 value 是 boolean 型別的 object key
interface Options {
isOpen: boolean;
isUnmount: boolean;
className: string;
container: HTMLElement;
onClick: () => void;
}

我們需要先將 value 進行轉換,把符合規則的 value 改成同名的 key name,不符合的改成 never

// 為了根據 value 來做篩選,需要先對 value 進行處理
// 把符合規則的 value 變成 keyName,不符合的變成 never
type TransformOptionsValue = {
[P in keyof Options]: Options[P] extends boolean ? P : never;
};
// type TransformOptionsValue = {
// isOpen: "isOpen";
// isUnmount: "isUnmount";
// className: never;
// container: never;
// onClick: never;
// }

接著透過 never 的 union 後會被過濾掉的特性,可以取得 filter 後的物件屬性:

// 接著把 key name 取出,因為 never 在 union 之後會自動被過濾掉,所以只剩下符合條件的 key name
type AvailableKeys = TransformOptionsValue[keyof TransformOptionsValue];

// type AvailableKeys = "isOpen" | "isUnmount"

最後搭配 Pick 挑出這些符合的名稱:

type AvailableOptions = Pick<Options, AvailableKeys>;
// type AvailableOptions = {
// isOpen: boolean;
// isUnmount: boolean;
// }

轉成 utility type

如此便達到了一開始我們的目的。

我們一樣可以把它轉成泛型,變得更通用:

type TransformOptionsValue = {
[P in keyof Options]: Options[P] extends boolean ? P : never;
};

// 轉一次,把原本的 Options 變成參數
type TransformValueWithBoolean<T> = {
[P in keyof T]: T[P] extends boolean ? P : never;
};

// 轉第二次,把原本的條件也變成參數
type TransformValue<T, U> = {
[P in keyof T]: T[P] extends U ? P : never;
};

接著可以使用這個寫好的泛型來取出 available keys:

type TransformOptionsValue = TransformValue<Options, boolean>;
// type TransformOptionsValue = {
// isOpen: "isOpen";
// isUnmount: "isUnmount";
// className: never;
// container: never;
// onClick: never;
// }

// 使用寫好的泛型
type AvailableKeys = TransformOptionsValue[keyof TransformOptionsValue];
// type AvailableKeys = "isOpen" | "isUnmount"

但泛型還可以再進化,把利用 never 取 union 會消失的特性之外,也包進泛型中:

// 把寫好 TransformOptionsValue 再升級成 FilteredKeyByValue
type FilteredKeyByValue<T, U> = {
[P in keyof T]: T[P] extends U ? P : never;
}[keyof T] &
keyof T;

// 使用寫好的泛型
type AvailableKeys = FilteredKeyByValue<Options, boolean>;
// type AvailableKeys = "isOpen" | "isUnmount"

最後一樣搭配 Pick 就可以取出所有符合條件的物件屬性:

type AvailableOptions = Pick<Options, AvailableKeys>;
// type AvailableOptions = {
// isOpen: boolean;
// isUnmount: boolean;
// };

參考資料