Power Tools у TypeScript: extend, infer, ключы Maps і as Remaps
Створыце типы рэгламентнага рангу, сумяшчаючы умовныя выразы, адгукаванне, ціклы з каратэкстуваннем і перекаратэкстуванне ключоў — тое ж самае інструментарыя, якое викорыстоўваецца ў Zod і tRPC.
Пасля кальвінаў з 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 часу выконання.