Введение: магия, которая скрыта в типах
Представьте, что вы пишете не просто код, а набор инструкций для самого компилятора, который на основе ваших правил генерирует безопасные, самодокументируемые конструкции. Это и есть метапрограммирование в TypeScript — способность писать код, который работает на уровне типов, анализирует и трансформирует другие типы во время компиляции.
Многие разработчики останавливаются на базовых дженериках и интерфейсах, даже не подозревая, какой мощный арсенал скрывается за ключевыми словами infer, extends и условными конструкциями. В этой статье мы разберем продвинутые техники метапрограммирования TypeScript, которые превратят вас из простого пользователя языка в его настоящего архитектора.
1. Условные типы: if на уровне типов
Условные типы — это база любого type-level программирования. Они работают как тернарный оператор, но на уровне системы типов:
type IsString<T> = T extends string ? "yes" : "no";
type Test1 = IsString<"hello">; // "yes"
type Test2 = IsString<42>; // "no"
Но настоящая магия начинается, когда мы комбинируем их с дженериками. Например, создадим тип, который извлекает только строковые ключи объекта:
type StringKeys<T> = {
[K in keyof T]: T[K] extends string ? K : never
}[keyof T];
interface User {
name: string;
age: number;
email: string;
}
type UserStringKeys = StringKeys<User>; // "name" | "email"
Этот пример показывает, как продвинутые типы TypeScript позволяют фильтровать и трансформировать структуры данных на этапе компиляции. Вы буквально программируете логику, которая выполняется до того, как код попадет в рантайм.
2. Infer: извлечение типов из других типов
Ключевое слово infer — это, пожалуй, самый мощный инструмент в арсенале метапрограммирования TypeScript. Оно позволяет "заглянуть внутрь" типа и извлечь его составные части. Рассмотрим классический пример — получение типа возвращаемого значения функции:
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type Fn = (x: number) => string;
type FnReturn = ReturnType<Fn>; // string
Но infer умеет гораздо больше. Давайте извлечем тип первого аргумента функции:
type FirstArg<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never;
type Handler = (id: number, name: string) => void;
type HandlerFirstArg = FirstArg<Handler>; // number
А как насчет рекурсивного разбора промисов? Создадим тип, который "разворачивает" все вложенные Promise:
type UnwrapPromise<T> = T extends Promise<infer U> ? UnwrapPromise<U> : T;
type DeepPromise = Promise<Promise<Promise<number>>>;
type Unwrapped = UnwrapPromise<DeepPromise>; // number
Обратите внимание на рекурсию — это одна из самых элегантных продвинутых техник TypeScript, которая позволяет обрабатывать бесконечно вложенные структуры.
3. Mapped types: трансформация объектов
Mapped types позволяют создавать новые типы на основе существующих, перебирая их ключи. Это незаменимый инструмент для создания обобщенных утилит:
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
type Partial<T> = {
[K in keyof T]?: T[K];
};
Но мы можем пойти дальше. Создадим тип, который делает все свойства необязательными, но только если они являются строками:
type PartialStringProps<T> = {
[K in keyof T]: T[K] extends string ? T[K] | undefined : T[K];
};
interface Config {
name: string;
port: number;
debug: boolean;
}
type MutableConfig = PartialStringProps<Config>;
// { name: string | undefined; port: number; debug: boolean; }
Такие конструкции особенно полезны при работе с API-ответами, где некоторые поля могут отсутствовать. Mapped types в сочетании с условными типами дают невероятную гибкость.
4. Шаблонные литеральные типы: манипуляции со строками
В TypeScript 4.1 появилась возможность манипулировать строковыми литералами на уровне типов. Это открыло дорогу к созданию типобезопасных DSL и парсингу строк:
type EventName<T extends string> = `on${Capitalize<T>}`;
type ClickEvent = EventName<"click">; // "onClick"
type SubmitEvent = EventName<"submit">; // "onSubmit"
А теперь представьте, что вы хотите создать тип, который принимает строку вида "user:123" и разбирает ее на сущность и ID:
type ParseEntity<T extends string> =
T extends `${infer Entity}:${infer Id}`
? { entity: Entity; id: Id }
: never;
type Parsed = ParseEntity<"user:42">; // { entity: "user"; id: "42" }
type ParsedFail = ParseEntity<"invalid">; // never
Это уже не просто типы — это настоящий парсер, работающий на этапе компиляции! Шаблонные литеральные типы позволяют создавать строгие типы для URL-роутов, CSS-свойств и любых строковых форматов.
5. Декораторы: метапрограммирование в рантайме
Хотя TypeScript в основном известен своей системой типов, декораторы — это механизм метапрограммирования, который работает во время выполнения. Они позволяют аннотировать и модифицировать классы, методы и свойства:
function Log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`Calling ${propertyKey} with args:`, args);
return originalMethod.apply(this, args);
};
return descriptor;
}
class Calculator {
@Log
add(a: number, b: number): number {
return a + b;
}
}
const calc = new Calculator();
calc.add(2, 3); // В консоли: "Calling add with args: [2, 3]"
Декораторы идеально подходят для реализации сквозной функциональности: логирования, валидации, кэширования, авторизации. Это классический пример метапрограммирования, когда код изменяет поведение другого кода.
Обратите внимание, что декораторы в TypeScript — экспериментальная фича. Для их использования нужно включить "experimentalDecorators