Изображение, созданное DALL-E
Изображение, созданное DALL-E

Type-level программирование в контексте TypeScript - это набор приемов и паттернов, позволяющих решать задачи уже на этапе компиляции, опираясь на возможности системы типов. Если описывать коротко:

  • TypeScript умеет вычислять определенные конструкции во время компиляции, используя Generics, Conditional Types, Template Literal Types и другие механизмы.

  • Результаты таких вычислений не попадают в итоговый JavaScript-код, но активно влияют на валидацию и безопасность кода при написании (editor/IDE support) и при компиляции (tsc).

Зачем это нужно?

  • Чёткое моделирование бизнес-логики - Сложные доменные правила (разрешённые значения, обязательные поля и т.п.) можно выразить на уровне типов, чтобы ошибки ловились ещё при компиляции.

  • Улучшенное документирование - типы становятся живой документацией, упрощая понимание и поддержку кода без дополнительных комментариев.

  • Унификация на уровне всей команды - при согласованном использовании type-level приёмов все участники проекта работают с единообразным кодом и легко понимают структуру данных.

  • Оптимизация времени отладки - Ошибки, пойманные во время компиляции, решаются быстрее и не доходят до runtime, сокращая цикл отладки.

  • Более широкие сценарии автогенерации - на базе типовой конфигурации можно генерировать не только API-клиенты, но и валидаторы, схемы и прочие инструменты без ручного дублирования.

Ключевые кирпичики Type-level программирования

Условные типы (Conditional Types)

Синтаксис:

T extends U ? X : Y

Позволяет на уровне типов ветвить логику. Например:

type IsString<T> = T extends string ? true : false;

type A = IsString<string>; // true
type B = IsString<number>; // false

Распределительные условные типы (Distributive Conditional Types)

Когда T - это union-тип (string | number), условный тип применяется к каждому элементу union:

type WrapInArray<T> = T extends any ? T[] : never;
type MyType = WrapInArray<string | number>; 
// MyType: string[] | number[]

Mapped Types (отображенные типы)

Дает возможность пробежаться по всем ключам интерфейса или типа и трансформировать их. Классический пример - сделать все поля необязательными:

type MakeOptional<T> = {
  [P in keyof T]?: T[P];
};

interface User {
  name: string;
  age: number;
}

type PartialUser = MakeOptional<User>;
// { name?: string; age?: number; }

Шаблонные литералы типов (Template Literal Types)

Разрешают склеивать строковые литералы на уровне типов:

type IdType<T extends string> = `ID_${T}`;

type UserId = IdType<"user">; // "ID_user"
type PostId = IdType<"post">; // "ID_post"

Пример 1: Проверка совместимости интерфейсов

Представим, что у нас есть два интерфейса, которые, как мы хотим убедиться, совместимы на уровне типов (чтобы, например, гарантировать, что поля одного присутствуют и в другом). Используем условные типы для проверки в compile time:

type EnsureCompatibility<A, B> = A extends B 
  ? true 
  : false;

interface IUser {
  id: number;
  name: string;
}

interface IPerson {
  name: string;
}

type Result = EnsureCompatibility<IUser, IPerson>; 
// true (IUser содержит все поля IPerson)

type Result2 = EnsureCompatibility<IPerson, IUser>; 
// false (у IPerson нет поля id)

Компилятор не выдаёт полноценную ошибку в консоли при несоответствии типов - однако вы можете использовать такие конструкты для условной генерации новых типов или, например, для выброса ошибочного типа через never, что уже приведёт к ошибке компиляции.

Пример 2: Генерация API методов на основе конфигурации (type-safe роутинг)

Допустим, мы хотим описывать роуты нашего приложения в виде типа-конфигурации, а затем автоматически создавать функции для обращения к ним.

// Каждая запись описывает метод (GET, POST и т.д.) и путь.
// Для простоты пусть путь содержит ID-часть.
interface RouteConfig {
  path: string;
  method: "GET" | "POST";
  hasIdParam: boolean;
}

type Routes = {
  getUsers: RouteConfig;
  getUserById: RouteConfig;
  createUser: RouteConfig;
};

// Опишем конкретную конфигурацию
const routes = {
  getUsers: {
    path: "/users",
    method: "GET",
    hasIdParam: false,
  },
  getUserById: {
    path: "/users",
    method: "GET",
    hasIdParam: true,
  },
  createUser: {
    path: "/users",
    method: "POST",
    hasIdParam: false,
  }
} as const satisfies Routes;

Зачем «комбинация» as const satisfies Routes?

  • satisfies Routes даёт проверку, что объект routes корректно реализует структуру Routes. Если что-то пойдёт не так (например, опечатаетесь в имени method или укажете неверную строку), TypeScript сразу же скажет об ошибке.

  • as const фиксирует все значения в объекте как литералы. То есть hasIdParam становится типом true или false (не просто boolean).

Так мы одновременно и получаем строгую проверку типа всего объекта, и сохраняем дискриминацию (true/false), нужную для определения формы функции в зависимости от hasIdParam.

Генерация функции на уровне типов

Представим, мы хотим создать клиент - объект, где для каждого ключа из Routes будет метод, который принимает параметры, соответствующие роуту (например, ID, если hasIdParam = true), а возвращает Promise с результатом, имитируя HTTP-запрос.

type ClientFunction<T extends RouteConfig> = T['hasIdParam'] extends true
  ? (id: number) => Promise<string>
  : () => Promise<string>;

type Client<T extends Record<string, RouteConfig>> = {
  [K in keyof T]: ClientFunction<T[K]>;
};

// Создадим фабрику, которая сделает реальный объект со всеми методами:
function createClient<T extends Record<string, RouteConfig>>(
  config: T
): Client<T> {
  const entries = Object.entries(config).map(([key, route]) => {
    const fn = route.hasIdParam
      ? (id: number) =>
          Promise.resolve(`${route.method} ${route.path}/${id} was called!`)
      : () => Promise.resolve(`${route.method} ${route.path} was called!`);

    return [key, fn];
  });

  return Object.fromEntries(entries);
}

// Пример использования
const apiClient = createClient(routes);

apiClient.getUsers().then(console.log);
// "GET /users was called!"

apiClient.getUserById(123).then(console.log);
// "GET /users/123 was called!"

apiClient.createUser().then(console.log);
// "POST /users was called!"

Что здесь интересно:

  • На уровне типов мы вычисляем: если hasIdParam == true, то в сигнатуре метода ожидаем аргумент id: number. Иначе - пустой список аргументов.

  • В коде createClient TypeScript подсказывает, где есть несовпадения типов, если мы вдруг попытаемся внести логику, несовместимую с нашей type-level декларацией.

Таким образом, мы избавляемся от дублирования: один раз объявили конфигурацию - и получили гарантированно согласованные методы клиента.

Пример 3: Генерация сообщений об ошибках валидации (Template Literal Types)

Шаблонные литералы типов позволяют склеивать строковые литералы на этапе компиляции, создавая более удобные и читаемые типы. Допустим, у нас есть условный тип, который проверяет, является ли поле обязательным, и возвращает строку ошибки:

type ValidateRequired<T, K extends keyof T & string> = undefined extends T[K]
  ? `Error: Field "${K}" is optional, but is required.`
  : true;

interface FormData {
  name: string;
  age?: number;
}

type ValidateName = ValidateRequired<FormData, "name">;
// true

type ValidateAge = ValidateRequired<FormData, "age">;
// "Error: Field \"age\" is optional, but is required."

На практике это можно использовать во внутренних утилитах, где, если условие валидации не проходит, мы на уровне типов получаем строку с конкретным указанием на ошибку. Это помогает во время разработки, ещё до выполнения кода.

Пример 4: Программирование на типах со сложными условиями

Допустим, у нас есть протокол - мы хотим определить тип Flatten<T>, который "расплющивает" вложенные массивы в одномерный массив. Реализуем (упрощённую) версию через рекурсивные условные типы:

type Flatten<T> = T extends (infer U)[]
  ? U extends any[] 
    ? Flatten<U> 
    : U
  : T;

// Проверим:
type A1 = Flatten<number[][][]>;  // number
type A2 = Flatten<string[]>;      // string
type A3 = Flatten<boolean>;       // boolean (не массив, значит остаётся как есть)
  • T extends (infer U)[] - классический способ «выдернуть» тип из массива.

  • Если U снова массив (U extends any[]), мы вызываем Flatten<U>, пока не дойдем до конца. Иначе возвращаем U.

  • В итоге мы получаем глубоко расплющенный тип, но это работает лишь на этапе компиляции.

Пример 5: Выборка только readonly-полей

Представим задачу: у нас есть интерфейс, где некоторые поля помечены как readonly. А мы хотим создать новый тип, в котором только неизменяемые (readonly) поля остались, а изменяемые - убрали. .

// 1) Определяем равенство типов (IfEquals)
type IfEquals<X, Y, A = true, B = false> = (<T>() => T extends X
  ? 1
  : 2) extends <T>() => T extends Y ? 1 : 2
  ? A
  : B;

// 2) Проверка является ли поле, K, readonly?
type IsReadonlyKey<T, K extends keyof T> = IfEquals<
  Pick<T, K>,
  Readonly<Pick<T, K>>
>;

// 3) Собираем объектный тип из только readonly-полей
type OnlyReadonly<T> = {
  // Перебираем все ключи T
  // С помощью оператора 'as' оставляем только те,
  // у которых IsReadonlyKey<T,K> = true
  [K in keyof T as IsReadonlyKey<T, K> extends true ? K : never]: T[K];
};

interface MixedInterface {
  readonly id: number;
  name: string;
  readonly createdAt: Date;
  updatedAt: Date;
}

type Result  = OnlyReadonly<MixedInterface>;
/*
  {
    readonly id: number;
    readonly createdAt: Date;
  }
*/
  • IfEquals<X, Y, A, B> утилита, проверяющая, действительно ли тип X равен типу Y.

    • Если равны (по структурной проверке TypeScript), вернёт тип A.

    • Если не равны, вернёт тип B.

  • IsReadonly<T, K> проверяет, было ли поле K в типе T объявлено как readonly. Для этого условно снимает модификатор readonly и смотрит, изменится ли тип.

    • Если при снятии readonly тип не меняется, значит поле изначально не было readonly.

    • Если меняется - значит было readonly.

  • ReadonlyOnly<T> перебирает все ключи и оставляет только те, для которых IsReadonly<T, K> вернуло true (то есть поля действительно readonly).

Это пример type-level логики, решающей задачу выделения полей по определённому критерию (readonly, optional, never и т. п.).

Обзор расширений для type-level программирования в TypeScript

Команда TypeScript непрерывно совершенствует систему типов. Ниже — несколько ключевых возможностей и оптимизаций, которые появились (или получили значимое развитие) начиная с версий 4.0–4.5.

Оптимизация условных типов

Начиная с TS4.5, были внесены улучшения в обработку сложных условных типов, что позволило ускорить компиляцию в случаях, когда в коде есть большие union-объединения.

Пример: популярная утилита Simplify ниже не является встроенной в TS, а считается "комьюнити-паттерном" для упрощения выводимых типов.

type Simplify<T> = T extends infer U ? { [K in keyof U]: U[K] } : never;

type Test = Simplify<{ a: string } & { b: number }>;
// Test: { a: string; b: number }

Глубокие рекурсивные условные типы

Ранее TypeScript мог упираться в лимиты глубокой рекурсии быстрее. Начиная с версий около 4.5, эти лимиты увеличены, и многие типовые конструкции (например, многократное “расплющивание” массивов) работают корректнее без досрочных ошибок компилятора.

type Flatten<T> = T extends (infer U)[]
  ? Flatten<U>
  : T;

type DeepArray = number[][][][][];
type Result = Flatten<DeepArray>; 
// number

Variadic Tuple Types (Вариативные кортежи)

Позволяют изменять структуру кортежа на уровне типов, добавляя или убирая элементы:

type AppendArgument<Func, Arg> = 
  Func extends (...args: infer Args) => infer R
    ? (...args: [...Args, Arg]) => R
    : never;

type MyFunc = (a: string, b: number) => void;
type ExtendedFunc = AppendArgument<MyFunc, boolean>;
// (a: string, b: number, args_2: boolean) => void

Ограничение глубины вычислений

TypeScript не предлагает официальной настройки для максимальной глубины рекурсии в условных типах. Однако под капотом у него есть внутренние лимиты (например, счётчик итераций для хвостовой рекурсии и ограничение instantiationDepth). Если вычисления заходят слишком далеко, компилятор выдаст ошибку вида:

Type instantiation is excessively deep and possibly infinite

В редких случаях, когда нужна действительно большая глубина, можно обойти это ручными счётчиками или разбить вычисления на несколько шагов. Например, с помощью типов BuildArray и Subtract можно ограничивать глубину рекурсии:

type LimitDepth<T, Depth extends number, C extends 1[] = []> =
  Depth extends C['length']
    ? T
    : T extends (infer U)[]
      ? LimitDepth<U, Depth, [...C, 1]>
      : T;

type Limited = LimitDepth<number[][][][], 2>; 
// Limited: number[][]

Если ваши типы не выходят за разумные пределы, TS сам попробует оптимизировать хвостовую рекурсию. Но при слишком сложных (или бесконечных) вычислениях вы можете столкнуться с лимитом и либо упростить логику типов, либо прибегнуть к подобным паттернам с подсчётом глубины.

Подробнее про ограничение максимальной глубины рекурсии можно почитать тут

Плюсы и минусы Type-level программирования

Плюсы

  • Меньше ошибок на этапе runtime. Многие проблемы ловятся компилятором.

  • Меньше дублирования, т.к. часть логики шаблонизируется типами.

  • Ясная архитектура: сложные API становятся самодокументирующимися.

Минусы

  • Крутая кривая обучения. Сложные условные типы и рекурсии в типах могут быть тяжелыми для понимания.

  • TS может выдавать громоздкие сообщения.

  • Несмотря на постоянные улучшения, иногда приходится сталкиваться с лимитами системы типов и обходить узкие места.

Заключение

Type-level программирование в TypeScript - это не просто страшные условные типы, а целая парадигма, позволяющая:

  • Строить динамические системы, где конфигурация (описанная в виде типов) формирует реальную логику на этапе компиляции.

  • Избегать множества ошибок за счет статического анализа.

  • Автоматизировать рутинные задачи (например, генерацию роутов, DTO, клиентских методов, валидацию и многое другое).

Если вы видите, что в вашем проекте много повторяющихся паттернов, а типы растут и усложняются - осмотритесь в сторону Type-level техник. Может оказаться, что многие вещи можно вычислить еще на этапе компиляции и тем самым упростить себе жизнь (и жизнь ваших коллег) на этапе runtime.

Остается только пожелать удачи в освоении TypeScript - и помните, что в вопросах метапрограммирования на типах горизонты постоянно расширяются с каждой новой версией!

А если вам интересно посмотреть, как декораторы в TypeScript помогают инкапсулировать сквозную функциональность (логирование, кеширование, валидацию и т.д.), рекомендую заглянуть в мою статью Мощь декораторов TypeScript на живых примерах. Декорирование методов класса.

Там вы найдете реальные примеры того, как декораторы упрощают код, избавляют от дублирования и делают ваше приложение более гибким и поддерживаемым. Приятного чтения!

Комментарии (14)


  1. hardtop
    04.01.2025 11:06

    Ну не знаю... как по мне - открыли ящик Пандоры. Теперь логику размазываем и в коде и в типах.


    1. orion_76
      04.01.2025 11:06

      Т.е. декомпозиция.
      При правильном использовании делает код проще и надежнее.


      1. hardtop
        04.01.2025 11:06

        При правильном использовании делает код проще и надежнее.

        При правильном... Вот мы в конкретном примере видели, как простой массив action обрастает сложностью сразу и быстро. А ведь это просто /user/get-by-id - а мы так всё усложнили. Чего ради? Вместо 10 строк кода нам надо написать 150+ и помнить о наследовании, юнификации... А чтобы изменить-расширить - придётся делать наследование или юнион. Через год разобраться в этом будет нереально, а ведь именно для этого вроде всю историю с абстракциями затеяли.

        А у нас просто страница. Просто обычное действие. Но мы сделали сложно, что развалится через 3 года,. Да-да... вы всё равно всё перепишите. Просто возьмите историю с 95 года - ничего не осталось.


      1. dom1n1k
        04.01.2025 11:06

        Декомпозиция ли? Больше смахивает на сильное зацепление.