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;否則為 FalseTypeNewType = SomeType extends OtherType ? TrueType : FalseType;

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

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

基本語法#

sample code

// https://www.typescriptlang.org/docs/handbook/2/conditional-types.htmlinterface 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 NameLabellet b = createLabel(2.8); // b is IdLabellet c = createLabel(Math.random() ? 'Hello' : 42); // c is NameLabel | IdLabellet 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.htmltype 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 變成 handleXXXtype 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 typestype 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 Arrayconst 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 signaturetype 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 insteadtype 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

要操作物件的 type 或 interface 的 key 或 value 時,就要想到 Mapped Type。

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']: Device[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 Typetype 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[keyof T];};
type EventHandlers = EventHandler<IEvent>;
// type EventHandlers = {//     onClick: (() => void) | (() => void);//     onChange: (() => void) | (() => void);// }

實際應用#

/** * Mapped 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 用的是 MappedType,所以會是 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 都變成 optionaltype Partial<T> = {  [P in keyof T]?: T[P];};
// 所有 T 物件中的 key 都變成 requiredtype Required<T> = {  [P in keyof T]-?: T[P];};
// 所有 T 物件中的 key 都變成 readonlytype 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 keyinterface Options {  isOpen: boolean;  isUnmount: boolean;  className: string;  container: HTMLElement;  onClick: () => void;}

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

// 為了根據 value 來做篩選,需要先對 value 進行處理// 把符合規則的 value 變成 keyName,不符合的變成 nevertype 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 nametype 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 再升級成 FilteredKeyByValuetype 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;// };

參考資料#