Литеральные типы и as const
«Вот объект с полем method: "GET". Почему при передаче в функцию, ожидающую тип "GET" | "POST", компилятор иногда ругается, а иногда — нет?» Вопрос звучит странно, пока не разберёшься с литеральными типами и as const.
Литеральный тип — тип, состоящий из ровно одного конкретного значения (например, тип
"GET"— это не «любая строка», а именно строка"GET"и ничего больше). as const — оператор, который говорит компилятору «зафиксируй самый узкий, самый конкретный тип для этого значения, не расширяй его».
Вопрос-крючок
Смотри на два очень похожих куска кода:
function request(method: "GET" | "POST") {
console.log("Отправляю запрос:", method);
}
let methodA = "GET";
const methodB = "GET";
request(methodB); // компилируется без ошибок
// request(methodA); // ошибка компиляции!Вывод (после исправления, если убрать вызов с methodA):
Отправляю запрос: GETОбе переменные хранят одну и ту же строку "GET". Но methodA компилятор не пропускает в функцию, а methodB — пропускает. Разница в одном слове: let против const. Сейчас разберёмся, почему это вообще имеет значение для типов.
Литеральный тип: значение как тип
В обычном программировании тип и значение — разные уровни: тип string описывает «любую строку», а "GET" — это конкретное значение этого типа. TypeScript стирает эту границу для литералов: можно объявить тип, который допускает не любую строку, а ровно одну конкретную:
type ExactlyGet = "GET";
let a: ExactlyGet = "GET"; // ок
// let b: ExactlyGet = "POST"; // ошибка: "POST" не является типом "GET"
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
function send(method: HttpMethod) {
console.log(method);
}
send("PUT");
// send("PATCH"); // ошибка: "PATCH" не входит в объединение четырёх литераловВывод:
PUTЛитеральные типы обычно используют не по одному, а собранными в union — получается что-то вроде enum, только на уровне строк, без отдельной рантайм-конструкции. Это удобнее классического enum: не нужно ничего импортировать, значения — обычные строки, которые видно в логах и в сети без расшифровки.
Почему let «портит» литеральный тип, а const — нет
Вернёмся к вопросу-крючку. Когда TypeScript выводит тип переменной автоматически (без явной аннотации), он смотрит, может ли эта переменная в принципе измениться:
let methodA = "GET"; // TypeScript выводит тип: string (широкий)
const methodB = "GET"; // TypeScript выводит тип: "GET" (литеральный, узкий)Для let methodA компилятор рассуждает так: «раз это let, значение может измениться в любой момент на любую другую строку — значит, безопаснее вывести широкий тип string». Это называется расширением типа (type widening). А для const methodB компилятор точно знает: значение никогда не изменится, оно навсегда останется строкой "GET" — значит, можно смело вывести узкий литеральный тип "GET", а не общий string.
Именно поэтому request(methodB) компилируется — тип "GET" входит в union "GET" | "POST". А request(methodA) — нет, потому что широкий string не гарантирует, что внутри лежит именно один из двух разрешённых вариантов, ведь по ходу программы в methodA теоретически можно было записать что угодно.
as const: фиксируем самый узкий тип принудительно
Проблема расширения типов особенно заметна на объектах и массивах — даже const для них не спасает автоматически:
const configA = { method: "GET", retries: 3 };
// Тип configA.method выводится как string, а не "GET" —
// потому что const фиксирует саму переменную configA,
// но не мешает менять её ВНУТРЕННИЕ поля: configA.method = "POST" — это разрешено!
const configB = { method: "GET", retries: 3 } as const;
// Тип: { readonly method: "GET"; readonly retries: 3 }
function send(method: "GET" | "POST") {
console.log(method);
}
send(configB.method); // компилируется
// send(configA.method); // ошибка: тип string не подходитВывод:
GETconst у объекта фиксирует только саму переменную-ссылку — нельзя переприсвоить configA целиком, но поля внутри него менять можно, а значит компилятор обязан выводить широкие типы полей на всякий случай. Оператор as const идёт дальше: он делает объект глубоко неизменяемым на уровне типов (все поля становятся readonly) и фиксирует для каждого поля самый узкий литеральный тип, какой вообще возможно вывести. То же самое работает и для массивов — без as const массив [1, 2, 3] получит тип number[], а с as const — тип кортежа readonly [1, 2, 3], где известна не только длина, но и каждое конкретное значение.
Как это работает под капотом
Компилятор TypeScript хранит для каждого значения не один, а как бы два уровня типа — широкий и узкий, и решает, какой показать, по контексту:
| Объявление | Выведенный тип | Причина |
let x = "GET" | string | Значение может измениться, нужен запас на будущее |
const x = "GET" | "GET" | Значение неизменяемо, можно быть точным |
const obj = { m: "GET" } | { m: string } | Поле объекта можно переприсвоить, даже если сам obj — const |
{ m: "GET" } as const | { readonly m: "GET" } | as const запрещает менять поля и фиксирует их литеральный тип |
Важно: as const — это исключительно инструмент времени компиляции, часть системы типов. В скомпилированном JavaScript от него не остаётся ни следа — объект в рантайме как был обычным изменяемым объектом, так и остаётся (JavaScript вообще не знает, что такое readonly на уровне полей). Защита работает только на этапе проверки типов, до сборки, а не как рантайм-заморозка вроде Object.freeze.
Частые ошибки на собеседовании
- «const у объекта делает его неизменяемым». Неверно — const фиксирует только саму переменную-ссылку. Поля внутри объекта менять можно свободно, если явно не подключить readonly или as const.
- Путают as const с Object.freeze. as const — это чисто типовая проверка на этапе компиляции, она ничего не делает в рантайме. Object.freeze — рантайм-механизм, который реально запрещает JavaScript-движку менять объект, и это две независимые вещи, которые иногда используют вместе.
- Не понимают, почему let «портит» литеральный тип. Кандидат объявляет let со строковым литералом и не понимает, почему функция с union-параметром отказывается принимать эту переменную — забывают про расширение типа (widening) для let.
- Забывают про readonly-массивы после as const. После as const массив становится кортежем только для чтения — попытка вызвать push или изменить элемент по индексу вызовет ошибку компиляции, и это неожиданность для тех, кто ждал обычный массив.
Итоги
- Литеральный тип — это тип, состоящий из одного конкретного значения; собранные в union, литералы работают как облегчённая замена enum.
- Для let TypeScript расширяет выведенный тип до общего (string, number), потому что значение может измениться; для const — выводит узкий литеральный тип, потому что значение неизменно.
- const у объекта фиксирует только переменную-ссылку, но не поля внутри — они всё ещё получают широкий тип и остаются изменяемыми.
- as const принудительно делает поля объекта readonly и фиксирует для каждого самый узкий литеральный тип; для массивов превращает их в кортежи фиксированной длины.
- as const — это чисто механизм проверки типов на этапе компиляции, в скомпилированном JavaScript от него не остаётся никакого следа.