Під час проектування бібліотек, маршрутизаторів, скінченних автоматів, дизайн-систем та 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> користувачеві бібліотеки достатньо передати звичайний масив об'єктів, а клієнтські методи отримають суворе автодоповнення лише зареєстрованих шляхів.
У бібліотеках скінченних автоматів (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() приймає виключно валідні зареєстровані пари (шлях, метод).
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 у сигнатурах функцій, а не на самих класах.