Power Tools TypeScript: extends, infer, ключі Maps та as Remaps
Створюйте типи рівня бібліотеки, поєднуючи умовні оператори, механізми інференції, цикли з мапуванням та перемапуванням ключів — це той самий набір інструментів, що використовується у Zod та tRPC.
Після кількох місяців роботи з TypeScript вбудовані функції на кшталт Partial, Pick та Omit зрештою перестають відповідати реальним потребам бібліотек: вилучення типу значення функції, трансформація кожного ключа чи створення типів, які адаптуються до інших типів. Чотири функції поєднуються, щоб досягти цього рівня: умовне використання extends, infer, цикли над keyof та перенаправлення ключів за допомогою as. Окремо вони є зрозумілими; разом вони уможливлюють патерни, які використовуються у Zod, tRPC та React Router.
1. Умовні типи з extends
У контексті типів extends є оператором умовного переходу:
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 до реального значення, яке повертається, та отримати 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 під час виконання.