[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;
基本語法
// 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'
T extends K
可以想成是 T 能夠滿足 K,也就是 T 是 K 的子集合(subset),K 的條件會比 T 來的寬鬆。
Conditional Type Constraints
Example: MessageOf
如果想要建立一個名為 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
建立一個 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"
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
- 認識 TypeScript 中 的型別魔術師:Mapped Type
- Day14:什麼!TypeScript 中還有迴圈的概念 - 用 Mapped Type 操作物件型別
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 則提供更方便的方式來定義和操作型別。
Index signature 和 Mapped Type 很大的差別在於,index signature 的 key 適用的是 string
或 number
;Mapped Type 的 key 則是限縮後的 string (subset of string)或 number。
Mapped Types & in operator
透過 Mapped Type 將可以更容易的操作物件類的型別,也可以使用 Mapped Typed 來產生新的型別。
操作物件型別時,要很快聯想到 Mapped Types,不論是想改變物件屬性 key 的名稱或 value 的型別,都可以透過。Mapped Types 達到。
in:loop over 的概念
Mapped Type 搭配 in
時,要把它想成類似 JavaScript 中的 for...in
或 for...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;
};
P in T
的使用可以想成是 JS 中 for...of
或 for...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;
};
in
後面可以放的是 string
、number
或 symbol
型別,也就會是 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 Type 中 in
的概念而成:
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
後面可以放的是string
、number
或symbol
型別,因此改成泛型時要記得限縮其型別,例如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>;
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'>;
Record
是用 Mapped Type 和 indexed access types 的概念寫出來的 utility type。
使用 Modifiers
在根據 key 產生新的 type 時,TypeScript 提供一些 mapping modifiers 可以讓你把原本 Type 中某屬性的特性(例如 readonly
或 ?
)拿掉後,再產生新的 type。
Map 後產生新的 Key 名稱
- 這裡搭配 template literal types 和
Capitalize
這個 utility:
/**
* 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,可以產生 Partial
、Required
和 Readonly
這幾個內建的 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'}`;
進階使用
列出所有 Window 物件型別中,名稱為 set
開頭的 key:
type setXXX = Extract<keyof Window, `set${any}`>; // "setInterval" | "setTimeout"
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}`>;