Type-level программирование в TypeScript: практические кейсы и новые возможности
- воскресенье, 5 января 2025 г. в 00:00:05
Type-level программирование в контексте TypeScript - это набор приемов и паттернов, позволяющих решать задачи уже на этапе компиляции, опираясь на возможности системы типов. Если описывать коротко:
TypeScript умеет вычислять определенные конструкции во время компиляции, используя Generics, Conditional Types, Template Literal Types и другие механизмы.
Результаты таких вычислений не попадают в итоговый JavaScript-код, но активно влияют на валидацию и безопасность кода при написании (editor/IDE support) и при компиляции (tsc).
Чёткое моделирование бизнес-логики - Сложные доменные правила (разрешённые значения, обязательные поля и т.п.) можно выразить на уровне типов, чтобы ошибки ловились ещё при компиляции.
Улучшенное документирование - типы становятся живой документацией, упрощая понимание и поддержку кода без дополнительных комментариев.
Унификация на уровне всей команды - при согласованном использовании type-level приёмов все участники проекта работают с единообразным кодом и легко понимают структуру данных.
Оптимизация времени отладки - Ошибки, пойманные во время компиляции, решаются быстрее и не доходят до runtime, сокращая цикл отладки.
Более широкие сценарии автогенерации - на базе типовой конфигурации можно генерировать не только API-клиенты, но и валидаторы, схемы и прочие инструменты без ручного дублирования.
Синтаксис:
T extends U ? X : Y
Позволяет на уровне типов ветвить логику. Например:
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
Когда T
- это union-тип (string | number
), условный тип применяется к каждому элементу union:
type WrapInArray<T> = T extends any ? T[] : never;
type MyType = WrapInArray<string | number>;
// MyType: string[] | number[]
Дает возможность пробежаться по всем ключам интерфейса или типа и трансформировать их. Классический пример - сделать все поля необязательными:
type MakeOptional<T> = {
[P in keyof T]?: T[P];
};
interface User {
name: string;
age: number;
}
type PartialUser = MakeOptional<User>;
// { name?: string; age?: number; }
Разрешают склеивать строковые литералы на уровне типов:
type IdType<T extends string> = `ID_${T}`;
type UserId = IdType<"user">; // "ID_user"
type PostId = IdType<"post">; // "ID_post"
Представим, что у нас есть два интерфейса, которые, как мы хотим убедиться, совместимы на уровне типов (чтобы, например, гарантировать, что поля одного присутствуют и в другом). Используем условные типы для проверки в 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
, что уже приведёт к ошибке компиляции.
Допустим, мы хотим описывать роуты нашего приложения в виде типа-конфигурации, а затем автоматически создавать функции для обращения к ним.
// Каждая запись описывает метод (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 декларацией.
Таким образом, мы избавляемся от дублирования: один раз объявили конфигурацию - и получили гарантированно согласованные методы клиента.
Шаблонные литералы типов позволяют склеивать строковые литералы на этапе компиляции, создавая более удобные и читаемые типы. Допустим, у нас есть условный тип, который проверяет, является ли поле обязательным, и возвращает строку ошибки:
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."
На практике это можно использовать во внутренних утилитах, где, если условие валидации не проходит, мы на уровне типов получаем строку с конкретным указанием на ошибку. Это помогает во время разработки, ещё до выполнения кода.
Допустим, у нас есть протокол - мы хотим определить тип 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
.
В итоге мы получаем глубоко расплющенный тип, но это работает лишь на этапе компиляции.
Представим задачу: у нас есть интерфейс, где некоторые поля помечены как 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 и т. п.).
TypeScript продолжает стремительно развиваться. Рассмотрим несколько ключевых новшеств, появившихся в последних версиях TS.
TypeScript теперь умеет оптимизировать условные типы глубже, избегая избыточных вычислений при работе с большими union-типами. Это ускоряет компиляцию и снижает нагрузку на систему типов.
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 были ограничения на глубину рекурсии для условных типов. Теперь эти лимиты увеличены, что дает простор для еще более глубоких алгоритмов:
type Flatten<T> = T extends (infer U)[]
? Flatten<U>
: T;
type DeepArray = number[][][][][];
type Result = Flatten<DeepArray>;
// number
Позволяют изменять структуру кортежа на уровне типов, добавляя или убирая элементы:
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
Теперь можно использовать keyof
более гибко вместе с шаблонными литералами. Это упрощает создание типов, привязанных к строковым ключам.
type Events = "click" | "hover" | "keydown";
type EventHandlers<T extends string> = `${T}Handler`;
type Handlers = EventHandlers<Events>;
// "clickHandler" | "hoverHandler" | "keydownHandler"
Чтобы не взрывать компилятор слишком сложными рекурсивными типами, TypeScript позволяет явно задавать глубину вычислений:
type BuildArray<L extends number, A extends unknown[] = []> =
A['length'] extends L ? A : BuildArray<L, [unknown, ...A]>;
type Subtract<A extends number, B extends number> =
BuildArray<A> extends [...BuildArray<B>, ...infer R]
? R['length']
: never;
type LimitDepth<T, Depth extends number> =
Depth extends 0
? T
: T extends (infer U)[]
? LimitDepth<U, Subtract<Depth, 1>>
: T;
type Limited = LimitDepth<number[][][][], 2>;
// Limited: number[][]
Меньше ошибок на этапе runtime. Многие проблемы ловятся компилятором.
Меньше дублирования, т.к. часть логики шаблонизируется типами.
Ясная архитектура: сложные API становятся самодокументирующимися.
Крутая кривая обучения. Сложные условные типы и рекурсии в типах могут быть тяжелыми для понимания.
TS может выдавать громоздкие сообщения.
Несмотря на постоянные улучшения, иногда приходится сталкиваться с лимитами системы типов и обходить узкие места.
Type-level программирование в TypeScript - это не просто страшные условные типы, а целая парадигма, позволяющая:
Строить динамические системы, где конфигурация (описанная в виде типов) формирует реальную логику на этапе компиляции.
Избегать множества ошибок за счет статического анализа.
Автоматизировать рутинные задачи (например, генерацию роутов, DTO, клиентских методов, валидацию и многое другое).
Новые возможности (улучшенные условные типы, рекурсивные типы, вариативные кортежи и т.д.) заметно расширяют границы того, что мы можем делать на уровне компиляции.
Если вы видите, что в вашем проекте много повторяющихся паттернов, а типы растут и усложняются - осмотритесь в сторону Type-level техник. Может оказаться, что многие вещи можно вычислить еще на этапе компиляции и тем самым упростить себе жизнь (и жизнь ваших коллег) на этапе runtime.
Остается только пожелать удачи в освоении TypeScript - и помните, что в вопросах метапрограммирования на типах горизонты постоянно расширяются с каждой новой версией!
А если вам интересно посмотреть, как декораторы в TypeScript помогают инкапсулировать сквозную функциональность (логирование, кеширование, валидацию и т.д.), рекомендую заглянуть в мою статью Мощь декораторов TypeScript на живых примерах. Декорирование методов класса.
Там вы найдете реальные примеры того, как декораторы упрощают код, избавляют от дублирования и делают ваше приложение более гибким и поддерживаемым. Приятного чтения!