Метапрограммирование в TypeScript: как писать код, который пишет код

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: магия, которая скрыта в типах

Представьте, что вы пишете не просто код, а набор инструкций для самого компилятора, который на основе ваших правил генерирует безопасные, самодокументируемые конструкции. Это и есть метапрограммирование в 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

Блоги

Рекомендаций по книгам