TypeScript продвинутые типы: conditional types, infer и mapped types на реальных примерах
Большинство разработчиков используют TypeScript как «JavaScript с аннотациями типов» — string, number, интерфейсы и на этом всё. Но система типов TS — это тьюринг-полный язык программирования на уровне типов. Когда научишься им пользоваться, начинаешь ловить целые классы ошибок на этапе компиляции, которые раньше обнаруживались только в runtime.
Весь код в этой статье — реальные паттерны из production кодовых баз. Никаких надуманных примеров.
Conditional Types: if/else на уровне типов
Базовый синтаксис: T extends U ? X : Y. Если тип T extends U — результат X, иначе Y.
type IsArray<T> = T extends any[] ? true : false;
type A = IsArray<string[]>; // true
type B = IsArray<string>; // false
Но настоящая сила — в дистрибутивности. Когда T — naked type parameter, conditional type применяется к каждому члену union:
type NonNullable<T> = T extends null | undefined ? never : T;
type Result = NonNullable<string | null | undefined | number>;
// string | number
never в union исчезает — именно так TypeScript фильтрует типы из union. Это используется в стандартной библиотеке TS повсеместно.
infer: извлекаем типы из других типов
infer работает только внутри extends в conditional type. Он говорит: «определи переменную типа здесь и используй её».
Извлекаем тип промиса
type Awaited<T> = T extends Promise<infer R> ? R : T;
type A = Awaited<Promise<string>>; // string
type B = Awaited<Promise<Promise<number>>>; // Promise<number> — не рекурсивно!
// Рекурсивная версия:
type DeepAwaited<T> = T extends Promise<infer R> ? DeepAwaited<R> : T;
type C = DeepAwaited<Promise<Promise<number>>>; // number
Извлекаем тип возвращаемого значения функции
type ReturnType<T extends (...args: any) => any> =
T extends (...args: any) => infer R ? R : never;
async function fetchUser(id: string): Promise<{ id: string; email: string }> {
// ...
}
type UserResponse = Awaited<ReturnType<typeof fetchUser>>;
// { id: string; email: string }
Этот паттерн незаменим при работе с API: вместо дублирования типов выводишь их из функции.
Параметры функции
type Parameters<T extends (...args: any) => any> =
T extends (...args: infer P) => any ? P : never;
function createOrder(userId: string, items: string[], total: number): void {}
type OrderParams = Parameters<typeof createOrder>;
// [userId: string, items: string[], total: number]
// Первый параметр:
type FirstParam = OrderParams[0]; // string
Mapped Types: трансформируем форму объекта
Mapped type итерирует по ключам и создаёт новый тип:
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
type Partial<T> = {
[K in keyof T]?: T[K];
};
type Required<T> = {
[K in keyof T]-?: T[K]; // -? убирает опциональность
};
Реальный кейс: типобезопасные ключи для локализации
type Locale = 'ru' | 'en' | 'kz';
type Translations = {
[K in Locale]: string;
};
// Теперь TypeScript заставит указать все локали:
const buttonLabel: Translations = {
ru: 'Отправить',
en: 'Submit',
kz: 'Жіберу',
// Если пропустить хоть одну — ошибка компиляции
};
Фильтрация ключей через mapped type + conditional
// Оставляем только ключи, значения которых — строки
type StringKeys<T> = {
[K in keyof T]: T[K] extends string ? K : never;
}[keyof T];
type User = {
id: number;
email: string;
name: string;
age: number;
role: string;
};
type UserStringKeys = StringKeys<User>; // 'email' | 'name' | 'role'
// Применяем: тип для поиска пользователей по строковым полям
type UserSearch = {
[K in StringKeys<User>]?: string;
};
// { email?: string; name?: string; role?: string }
Template Literal Types: строгая типизация строк
С TypeScript 4.1 можно создавать строковые типы через шаблоны:
type EventName = `on${Capitalize<string>}`;
// 'onClick', 'onChange', etc.
// Строгая типизация путей API
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type ApiVersion = 'v1' | 'v2';
type Endpoint = 'users' | 'orders' | 'products';
type ApiPath = `/${ApiVersion}/${Endpoint}`;
// '/v1/users' | '/v1/orders' | '/v1/products' | '/v2/users' | ...
type ApiRoute = `${HttpMethod} ${ApiPath}`;
// 'GET /v1/users' | 'POST /v1/users' | ...
// Конфигурация роутера строго типизирована:
const routes: Record<ApiRoute, () => void> = {
'GET /v1/users': () => {},
'POST /v1/orders': () => {},
// TypeScript поймает опечатку: 'GET /v3/users' — ошибка!
// TypeScript поймает 'GOT /v1/users' — ошибка!
};
Реальный кейс: типобезопасный Event Emitter
Это один из самых полезных паттернов. Стандартный EventEmitter — это on(event: string, handler: (...args: any[]) => void). Никакой типобезопасности.
type EventMap = {
'user:created': { id: string; email: string };
'order:placed': { orderId: string; total: number };
'payment:failed': { orderId: string; reason: string };
};
type EventHandler<T extends Record<string, any>, K extends keyof T> =
(payload: T[K]) => void;
class TypedEmitter<T extends Record<string, any>> {
private listeners: Partial<{
[K in keyof T]: Array<EventHandler<T, K>>;
}> = {};
on<K extends keyof T>(event: K, handler: EventHandler<T, K>): void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(handler);
}
emit<K extends keyof T>(event: K, payload: T[K]): void {
this.listeners[event]?.forEach(handler => handler(payload));
}
}
const emitter = new TypedEmitter<EventMap>();
// Полная типобезопасность — TypeScript знает тип payload для каждого события:
emitter.on('user:created', ({ id, email }) => {
console.log(id, email); // id: string, email: string — автодополнение работает
});
emitter.emit('order:placed', { orderId: '123', total: 4500 });
// emitter.emit('order:placed', { orderId: '123' }); // TS ERROR: missing 'total'
DeepPartial и DeepReadonly: рекурсивные mapped types
type DeepPartial<T> = T extends object
? { [P in keyof T]?: DeepPartial<T[P]> }
: T;
type DeepReadonly<T> = T extends (infer R)[]
? ReadonlyArray<DeepReadonly<R>>
: T extends object
? { readonly [P in keyof T]: DeepReadonly<T[P]> }
: T;
// Использование:
type Config = {
database: {
host: string;
port: number;
credentials: {
user: string;
password: string;
};
};
cache: {
ttl: number;
};
};
// Для патч-обновлений конфига — все поля опциональны рекурсивно:
function updateConfig(patch: DeepPartial<Config>): void {
// patch.database?.credentials?.user — TypeScript всё понимает
}
Discriminated Unions + Exhaustiveness Checking
Паттерн, который ловит ошибки при добавлении нового варианта:
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rectangle'; width: number; height: number }
| { kind: 'triangle'; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rectangle':
return shape.width * shape.height;
case 'triangle':
return (shape.base * shape.height) / 2;
default:
// Если добавить новый вид фигуры и забыть обработать — TS ошибка здесь:
const _exhaustive: never = shape;
throw new Error(`Unknown shape: ${_exhaustive}`);
}
}
Когда добавляем | { kind: 'ellipse'; a: number; b: number }, TypeScript немедленно укажет на default ветку — потому что shape уже не never.
Итог: когда это реально нужно
Продвинутые типы — не самоцель. Они оправданы, когда:
- Пишете библиотеку или фреймворк, которым пользуется команда
- Строите типобезопасный API-клиент (extract types из схемы)
- Хотите, чтобы добавление нового варианта enum/union автоматически требовало обновить все обработчики
- Работаете с event-driven системами (типобезопасный emitter)
Для рядового CRUD-контроллера Partial и Readonly обычно достаточно. Не усложняйте без нужды.
Aunimeda строит production TypeScript системы с глубокой типизацией. Связаться с нами.
Смотрите также: tRPC + Zod типобезопасность, Разработка ПО