
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. Иначе - пустой список аргументов.В коде
createClientTypeScript подсказывает, где есть несовпадения типов, если мы вдруг попытаемся внести логику, несовместимую с нашей 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 на живых примерах. Декорирование методов класса.
Там вы найдете реальные примеры того, как декораторы упрощают код, избавляют от дублирования и делают ваше приложение более гибким и поддерживаемым. Приятного чтения!
