Power Tools у TypeScript: extend, infer, ключы Maps і as Remaps
Тыпы TypeScript класу бібліятэкі, створаныя з умовных тыпаў за дапамою extends, infer, ключоў ключоў у цікле mapped і перэзначэння as, тое ж самае інструментарыя для гэнерыкаў, якое вікорыстоўваецца ў Zod і tRPC.
Крыніцы: Advanced TypeScript: Mastering `extends`, `infer`, `keyof` Loops, and the `as` Syntax
Пасля кальвінаў з TypeScript, вбудованыя функцыі такія як Partial, Pick і Omit зрэшты перастаюць падходзіць для рэальных задач бібліятэк: выявлення тыпу значэння, якое вяртае функцыя, трансформація кожнага ключа або стварэння тыпаў, якія прыстосаваліся да іншых тыпаў. Чатыры функцыі разам дазволяюць досягнуць такага рэвалюцыі: умовны extends, infer, ціклы, якія працуюць з keyof, і перэназначэння ключоў за дапамогою as. Адна-адна яны ёсць простымі для вікорыстання; разам жа яны ствараюць патэрны, якія вікорыстоўваюцца ў Zod, tRPC і React Router.
1. Умовныя тыпы з extends
У кантэксце тыпаў extends ёсць статэмента if:
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
Гэта означае: калі T можа быць прызначаны як string, то рэзультат — true, інакш — false.
Умовныя выразы станоць сільнейшымі, калі яны маюць дыстрыбутывную структуру. Аднасць T пераглядаецца элемент за элементам:
type ToArray<T> = T extends any ? T[] : never;
type Result = ToArray<string | number>;
// Result = string[] | number[] (not (string | number)[])
Самэй тым фільтрацыя аднасцей выконваецца швыта:
type ExcludeString<T> = T extends string ? never : T;
type NoStrings = ExcludeString<string | number | boolean>;
// NoStrings = number | boolean
never не з’являецца ў аднасцях — гэта тая ж ідэя, якая лежыць у основе вбудованага Exclude<T, U>.
2. Выяўленне форм з адпамінанням infer
infer выкорыстоўваецца толькі ў клазу extends. Ён прызначае месца для замены, якое TypeScript запоўняе на адпаведнаю форму. Цэль — класычны выяўлення тыпу вярнення:
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function getUser() {
return { id: 1, name: "Oussama" };
}type User = MyReturnType<typeof getUser>;
// User = { id: number; name: string }
Этапы: падбіраем typeof getUser па (...args: any[]) => infer R, прыяўляем R да рэальнага значэння, якое вяртаецца, і выкарыстоўваем яго як рэзультат.
infer таксама можа выкорыстоўвацца і ў іншых месцах:
// Extract the element type of an array
type ElementOf<T> = T extends (infer U)[] ? U : never;
type Item = ElementOf<string[]>; // string
// Extract the resolved type of a Promise
type Awaited2<T> = T extends Promise<infer U> ? U : T;
type Data = Awaited2<Promise<{ status: number }>>;
// Data = { status: number }// Extract the first argument of a function
type FirstArg<T> = T extends (arg: infer A, ...rest: any[]) => any ? A : never;
type Arg = FirstArg<(id: number, name: string) => void>; // number
Рэкурсія распакоўвае вкладаныя обяцанні:
type DeepAwaited<T> = T extends Promise<infer U> ? DeepAwaited<U> : T;
type Flat = DeepAwaited<Promise<Promise<Promise<string>>>>;
// Flat = string
3. Пераходы ціклам з вікорыстаннем тыпоў з мапаванням keyof
keyof вяртае аб’еднанне ключоў:
interface User {
id: number;
name: string;
email: string;
}
type UserKeys = keyof User; // "id" | "name" | "email"
Тыпы з мапаванням ітэруюць гэтае аб’еднанне як у рэжыме for...in на рэвэлі тыпу:
type Readonly2<T> = {
[K in keyof T]: T[K];
};
Дадзіце модыфікаторы для необавязковых або толькі для чытання копій:
// Make every property optional
type Optional<T> = {
[K in keyof T]?: T[K];
};
// Make every property readonly
type ReadonlyAll<T> = {
readonly [K in keyof T]: T[K];
};// Remove readonly / optional with a minus modifier
type Mutable<T> = {
-readonly [K in keyof T]-?: T[K];
};
Трансфармавайце значэнні пад час ітэрацый:
type Stringify<T> = {
[K in keyof T]: string;
};
type StringifiedUser = Stringify<User>;
// { id: string; name: string; email: string }
Спяўнюйце ўмовы разам з ціклам:
type NullableStrings<T> = {
[K in keyof T]: T[K] extends string ? T[K] | null : T[K];
};
type Result2 = NullableStrings<User>;
// { id: number; name: string | null; email: string | null }
4. Перэмапаванне ключоў з вікорыстаннем as
Унутры тыпу з мапаванням as вычысляе новую назву ключа для кожнай властывы:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<User>;
/*
{
getId: () => number;
getName: () => string;
getEmail: () => string;
}
*/
[K in keyof T] абвяртае ітэрацыю; шаблонны літэрал стварае ключы у стылі getName; T[K] стае тыпам, які вяртае гетэр.
Выкарыстоўвайце as never, каб адмахнуцца ключоў пад час ітэрацый:
type OmitByType<T, U> = {
[K in keyof T as T[K] extends U ? never : K]: T[K];
};
interface Mixed {
id: number;
name: string;
active: boolean;
}type OnlyNonBoolean = OmitByType<Mixed, boolean>;
// { id: number; name: string }
Шаблон extends унутры as — гэта спосаб стварэння варыянтав Pick/Omit, базаваных на значэннях.
Усе чатыро разам
Рэалістычны камбінат: верыфікаторы для поль дадзейна, прычыненне функцыйных атрыбутаў:
type Validators<T> = {
[K in keyof T as T[K] extends Function ? never : `validate${Capitalize<string & K>}`]:
(value: T[K]) => value is T[K];
};
interface ApiResponse {
id: number;
email: string;
active: boolean;
refresh: () => void;
}type ResponseValidators = Validators<ApiResponse>;
/*
{
validateId: (value: number) => value is number;
validateEmail: (value: string) => value is string;
validateActive: (value: boolean) => value is boolean;
}
*/
Разбіранне: keyof задае ключы; цыкл адпрацоўкі перавідае кожны з іх; as перайменоўвае або выкасывае элементы за дапамогою never, калі T[K] extends Function; extends вяршыць, чы рэшаць застаўіць або выкасыць; infer быў бы вікорыстаны, якбы трэба было выявіць тыпы вёсканых аргументаў.
Короткая інструкцыя
| Функцыя | Ролі | Дзе вікорыстоўваецца |
|---|---|---|
extends | На рэвэлі тыпаў | Порэйнаванне тыпаў |
infer | Толькі ўсерэдыне extends |
[K in keyof T]asas для перапрацаванняКалі ўжо натыснуць на іх, складныя фарматы больш не змушаюць вікорыстоўваць any. Перазбудуйце Pick, Omit, Record і ReturnType з нуля — усі вышэйзгаданы функцыяны будуць відны — і інструментары на рэвэлі тыпа стане практычным, а не містычным. Автары бібліятэкаў паводзяцца за аднаковымі чатырма параметрамі; чытанне ўсіх іх файлаў .d.ts становіцца набліжна простым пасля таго, як за выходны перазбудуваць гэтыя інструменты вручную.
Шлях практыкавання
Паўторна створэнне элемента Exclude за дапамою умовных выразаў, ReturnType — за дапамою функцыі infer, элементаў Partial/Readonly — за дапамою мапаваных модыфікатораў, а таксама спецыяльнага элемента OmitByType з параметрам as never. Гэтыя чатыры задачы выкорыстоўваюць усе функцыяны, практыкуемыя ў гэтым кіясе. Пасля чаго чытанне файлаў .d.ts бібліятэк перастае адчувацца як гэмпіранг коду і стае схожым на звычны TypeScript — толькі на рэвэле тыпаў.
Калі памылка з тыпам ператвараецца на масу умовных выразаў, трэба адразу знайсці рашэння: тымчасова заменіць мапаваны тып на конкрэтны аб’ектны тып або заменіць infer R на явную анотацыю, пакуль не стане зрозумелым той ветка коду, якая выклікае проблему. Дэбаггін на рэвэле тыпаў застаецца дэбаггінам; прыменяецца тая ж сама стратэгія роздзелення на часткі.
Лепшае выкарыстоўваць іменаваныя аліясы замест вялічзіных прыказаў, каб будучы чытальнікі моглі разумець намер. Экспортуйце тыя функцыі, якія ваша аплікацыя насправды павтарае выкарыстоўвае; спекулятывныя інструменты заставіце ў «сандбоксе» пакуль не з’явіцца другое месца вызову. Так спецыфікацыя тыпаў застанецца такой жа намеранай, як і API часу выканання.