При проектировании библиотек, маршрутизаторов, конечных автоматов, дизайн-систем и API-клиентов архитектору критически важно сохранять точные строковые литералы ("/api/v1/users", "POST", "light"), числовые значения и кортежи, а не деградировать их до общих базовых типов string или number.
До появления TypeScript 5.0 компилятор при выводе generic-параметров из переданных аргументов всегда применял агрессивное расширение типов (Type Widening). Чтобы заставить систему типов сохранить точные строковые литералы или кортежи, разработчик библиотеки был вынужден перекладывать дисциплину типизации на каждого конечного пользователя, требуя ручного добавления утверждения as const на месте каждого вызова:
// Legacy TypeScript (версии ниже 5.0): разработчик ОБЯЗАН помнить про 'as const'
const router = createRouter({
routes: ['/dashboard', '/settings', '/analytics'] as const,
});Если программист забывал дописать as const, компилятор выводил тип string[]. В результате автодополнение путей в кодовой базе переставало работать, а опечатки в эндпоинтах превращались в плавающие баги рантайма.
Начиная с TypeScript 5.0, появился модификатор const Type Parameters (<const T>). Он переносит бремя сохранения литералов с прикладного разработчика на архитектора библиотеки, обеспечивая нулевой оверхед в DX (Developer Experience).
Модификатор <const T> переносит ответственность за сохранение литералов с вызывающей стороны на сигнатуру функции в библиотеке: компилятор трактует инлайн-литералы аргументов так, будто к ним неявно применили as const.
Чтобы понять, как компилятор разрешает параметры типов, сравним две ментальные установки:
function fn<T>(arg: T)): Компилятор исходит из предположения о возможной будущей мутации переданного объекта или массива. Поэтому строковое свойство { role: 'admin' } получает тип { role: string }, а массив ['GET', 'POST'] типизируется как мутабельный список string[].function fn<const T>(arg: T)): Компилятор исходит из предположения о неизменяемости и максимальной структурной точности:
"GET" | "POST", а не string).404 | 500, а не number).readonly ["/users", "/billing"]).readonly на всех уровнях вложенности.+-------------------------------------------------------------------------+
| Эволюция разрешения литералов в Generics |
+-------------------------------------------------------------------------+
| До TypeScript 5.0 (Type Widening): |
| function createRouter<T extends string[]>(routes: T): T; |
| createRouter(['/home', '/about']); |
| => T разрешается как string[] (литералы утрачены!) |
| createRouter(['/home', '/about'] as const); |
| => T разрешается как readonly ["/home", "/about"] (нужен as const) |
+-------------------------------------------------------------------------+
│
▼ Релиз TypeScript 5.0+ (<const T>)
+-------------------------------------------------------------------------+
| Современный TypeScript (<const T>): |
| function createRouter<const T extends readonly string[]>(routes: T): T; |
| createRouter(['/home', '/about']); |
| => T автоматически выводится как readonly ["/home", "/about"] |
| • Нулевой boilerplate для потребителя |
| • Идеальный IntelliSense по маршрутам и методам |
+-------------------------------------------------------------------------+Модификатор const разрешено указывать исключительно перед объявлением параметров типа в функциях, методах и конструкторах. Его нельзя объявлять на уровне всего класса или интерфейса:
function makeConfig<const T>(c: T): T — ✅ Корректно.class Router<const T> {} — ❌ Синтаксическая ошибка компилятора (TS1458: Type parameter declaration cannot have a 'const' modifier). Для классов const применяется к методам или фабричным функциям.Type Checker обнаруживает модификатор const перед параметром типа (<const T extends ...>).
Компилятор проверяет, является ли аргумент непосредственным литералом объекта/массива или ссылкой на ранее объявленный идентификатор.
Эвристика расширения типов блокируется: примитивные значения фиксируются точными строковыми и числовыми литералами.
Массивы трансформируются в неизменяемые кортежи readonly [...], а поля объектов защищаются от последующих мутаций.
Вычисленный высокоточный тип связывается с возвращаемым значением, активируя идеальное автодополнение в IDE.
Почему при объявлении функции `function defineTokens<const T>(tokens: T): T` передача аргумента `{ primary: '#3b82f6', variants: [100, 200] }` сохраняет тип variants как `readonly [100, 200]`, а не `number[]`?
В маршрутизаторах (Next.js, Express, TanStack Router) пути и методы эндпоинтов задаются конфигурационным массивом. С <const T> потребителю библиотеки достаточно передать обычный JavaScript-массив объектов, а клиентские методы получат строгое автодополнение только существующих путей.
В библиотеках конечных автоматов (XState, кастомные FSM) состояния и события объявляются в графе переходов. Имена состояний (idle, loading, success, error) автоматически выводятся как дискриминирующие литеральные объединения, исключая возможность перехода в неизвестное состояние.
В генераторах стилей (Tailwind-конфиги, Vanilla Extract, Panda CSS) палитры цветов и шкалы отступов передаются как объект. Благодаря <const T> значения цветов выводятся как точные hex-строки или именованные ключи, предотвращая случайную передачу произвольных строковых значений.
При выборке колонок query.select(['id', 'email', 'created_at']) массив аргументов выводится как кортеж readonly ['id', 'email', 'created_at'], что позволяет возвращаемому объекту иметь тип Pick<User, 'id' | 'email' | 'created_at'> без бойлерплейта.
Разберем проектирование типобезопасного построителя маршрутов API Gateway, где пути и методы эндпоинтов валидируются на этапе компиляции.
Разработчик описывает маршруты как массив обычных JS-объектов { path, method } без as const.
Дженерик <const TConfig extends RouterConfig> фиксирует кортеж маршрутов как readonly RouteDefinition[].
Вспомогательные условные типы выводят объединение путей TRoutes[number]['path'] и ассоциированных HTTP-методов.
Метод router.hasRoute() или client.request() разрешает только валидные пары (путь, метод).
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
export interface RouteDefinition<
TPath extends string = string,
TMethod extends HttpMethod = HttpMethod
> {
readonly path: TPath;
readonly method: TMethod;
readonly rateLimit?: number;
}
export interface RouterConfig<TRoutes extends readonly RouteDefinition[]> {
readonly prefix?: string;
readonly routes: TRoutes;
}
/**
* Класс типизированного роутера.
* Обратите внимание: модификатор const пишется в сигнатуре фабрики,
* а сам класс объявляет дженерик с базовым ограничением readonly.
*/
export class TypedRouter<TRoutes extends readonly RouteDefinition[]> {
public readonly routes: TRoutes;
public readonly prefix: string;
constructor(config: RouterConfig<TRoutes>) {
this.routes = config.routes;
this.prefix = config.prefix ?? '';
}
/**
* Проверяет наличие эндпоинта. Метод строго привязан к зарегистрированному пути!
*/
public hasRoute<
TPath extends TRoutes[number]['path'],
TMethod extends Extract<TRoutes[number], { readonly path: TPath }>['method']
>(path: TPath, method: TMethod): boolean {
return this.routes.some(
(r) => r.path === path && r.method === method
);
}
/**
* Возвращает список всех зарегистрированных путей
*/
public getRegisteredPaths(): readonly TRoutes[number]['path'][] {
return this.routes.map((r) => r.path);
}
}
/**
* Фабричная функция с модификатором const перед параметром типа TConfig.
* Именно здесь происходит магия TypeScript 5.0+!
*/
export function createRouter<
const TConfig extends RouterConfig<readonly RouteDefinition[]>
>(config: TConfig): TypedRouter<TConfig['routes']> {
return new TypedRouter(config);
}Что произойдет, если разработчик попытается объявить класс следующим образом: `class ApiService<const TConfig> { constructor(cfg: TConfig) {} }`?
Демонстрация промышленного использования созданного API Gateway роутера с полным автодополнением и строгой валидацией на уровне типов без единого as const.
import { createRouter } from '@/lib/typed-router';
// Пользователь передает обычный литерал объекта без единого `as const`!
export const apiGateway = createRouter({
prefix: '/api/v1',
routes: [
{ path: '/auth/login', method: 'POST', rateLimit: 5 },
{ path: '/auth/logout', method: 'POST' },
{ path: '/users', method: 'GET', rateLimit: 60 },
{ path: '/users/:id', method: 'GET' },
{ path: '/users/:id', method: 'DELETE' },
{ path: '/billing/invoices', method: 'GET' },
],
});
// ==========================================
// 1. Позитивные сценарии (валидация пройдена)
// ==========================================
const check1 = apiGateway.hasRoute('/auth/login', 'POST'); // ✅ OK
const check2 = apiGateway.hasRoute('/users', 'GET'); // ✅ OK
const check3 = apiGateway.hasRoute('/users/:id', 'DELETE'); // ✅ OK
// ==========================================
// 2. Ошибки компилятора TypeScript (Static Safety):
// ==========================================
// @ts-expect-error Ошибка: Для пути '/billing/invoices' зарегистрирован только метод 'GET', но не 'DELETE'
apiGateway.hasRoute('/billing/invoices', 'DELETE');
// Argument of type '"DELETE"' is not assignable to parameter of type '"GET"'.
// @ts-expect-error Ошибка: Путь '/admin/settings' отсутствует в конфигурации роутера
apiGateway.hasRoute('/admin/settings', 'GET');
// Argument of type '"/admin/settings"' is not assignable to parameter of type
// '"/auth/login" | "/auth/logout" | "/users" | "/users/:id" | "/billing/invoices"'.Модификатор <const T> работает только тогда, когда аргумент передается в виде непосредственного инлайн-литерала в вызов функции. Если массив или объект были объявлены ранее без as const, компилятор уже при объявлении переменной расширил тип до string[], и const T не сможет восстановить утраченные строковые литералы.
// ❌ АНТИПАТТЕРН: Предварительное объявление расширяет тип до string[]
const endpoints = ['/users', '/billing']; // Тип: string[]
const router = createRouter({ routes: endpoints.map(p => ({ path: p, method: 'GET' })) });
// T вывелся как RouteDefinition<string, string>[], автодополнение сломано!
// ✅ ПРАВИЛЬНО: Передача литерала непосредственно в фабрику
const router = createRouter({
routes: [
{ path: '/users', method: 'GET' },
{ path: '/billing', method: 'GET' },
],
}); // Тип зафиксирован строго: readonly [{ path: '/users', ... }]Поскольку <const T> выводит массивы как readonly [...], передача такого результата в стороннюю функцию, принимающую мутабельный массив T[], вызовет ошибку компиляции:
The type 'readonly [...]' is 'readonly' and cannot be assigned to the mutable type '...[]'.
// ❌ ОШИБКА: Функция ожидает мутабельный массив
function mutateRoutes(routes: string[]) {
routes.push('/internal');
}
const config = createConfig({ items: ['a', 'b'] }); // items выведен как readonly ['a', 'b']
// @ts-expect-error Несовместимость readonly и mutable
mutateRoutes(config.items);
// ✅ ПРАВИЛЬНО: Поверхностное копирование через спред или ограничение функции через readonly string[]
mutateRoutes([...config.items]);Если объявить функцию function parseList<const T>(items: T), TypeScript выведет тип readonly [...], но компилятор не будет знать, что T вообще является массивом, и запретит обращение к методам массивов (items.map(), items.length). Всегда сопровождайте дженерик базовым ограничением extends readonly unknown[].
// ❌ ОШИБКА: Компилятор не знает, что T — итерируемая структура
function processItems<const T>(items: T) {
// @ts-expect-error Свойство 'map' не существует на типе 'T'
return items.map((x) => String(x));
}
// ✅ ПРАВИЛЬНО: Явное указание ограничения кортежа
function processItems<const T extends readonly unknown[]>(items: T) {
return items.map((x) => String(x));
}Модификатор <const T> в TypeScript 5.0+ — это фундаментальный инструмент для авторов библиотек, фреймворков и дизайн-систем. Он кардинально повышает качество Developer Experience, автоматизируя вывод строковых литералов и неизменяемых кортежей readonly прямо из инлайн-аргументов без необходимости навязывать потребителям утверждение as const. При этом важно помнить о ключевых правилах: передавать аргументы инлайн, объявлять базовые ограничения через readonly unknown[] и применять const в сигнатурах функций, а не на самих классах.