Литеральные типы и 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 не подходит

Вывод:

GET

const у объекта фиксирует только саму переменную-ссылку — нельзя переприсвоить 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 от него не остаётся никакого следа.
Проверьте себя
1. Почему функция с параметром типа "GET" | "POST" принимает const-переменную со значением "GET", но не принимает let-переменную с тем же значением?
AЭто ограничение синтаксиса, не связанное с типами
BДля let TypeScript выводит широкий тип string, а для const — узкий литеральный тип "GET"
Clet-переменные вообще нельзя передавать в функции
DДело в порядке объявления переменных в файле
2. Что именно делает as const, применённый к объекту { method: "GET", retries: 3 }?
AЗамораживает объект в рантайме, как Object.freeze
BДелает поля объекта readonly и фиксирует для каждого поля самый узкий литеральный тип — но только на уровне типов
CПревращает объект в JSON-строку
DНичего не меняет, если объект уже объявлен через const