Power Tools TypeScript: extends, infer, ключи объекта Maps и функция 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 во время выполнения.