readonly и as const: неизменяемость в типах

объекты≈ 10 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

«Это не меняется» в TypeScript пишут двумя способами: readonly перед именем поля и as const после литерала. Оба обещания живут только до сборки — собранный JavaScript пишет в такое поле как ни в чём не бывало, и это стоит один раз увидеть своими глазами. Зато as const попутно делает то, ради чего его чаще всего и ставят: превращает значения в литеральные типы, из которых получается готовый union.

Разделы темы:·

Слово readonly ставится перед именем поля — в interface, в type, в литеральном типе объекта. Оно означает ровно одно: снаружи это поле разрешено читать, но не присваивать. Значение задаётся один раз, при создании объекта.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Компилятор сказал Не удается задать значение для "id", так как это свойство, доступное только для чтения — это TS2540. А потом в консоли появилась двойка: присваивание всё равно случилось. Та же история, что с readonly-массивом из главы про базовые типы — типы стираются при сборке, и до запуска доезжает обычный объект со всеми полями нараспашку.

Сравните с const, который выглядит похоже, но занимается совсем другим делом. const закрепляет связь имени со значением, а не содержимое объекта — и это правило самого JavaScript, а не выдумка TypeScript.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Отказов снова два, но они разной природы. TS2588 компилятор произносит до запуска, а следом браузер бросает настоящий TypeError — на этой строке пример обрывается. Точный текст у ошибки свой у каждого движка: выше напечатан тот, что даёт Chrome. const дожил до выполнения, readonly — нет.

readonly — про поле, const — про имя. Первое проверяет компилятор, второе — движок, и до запуска доживает только второе.

readonly закрывает само поле, а не значение, которое в нём лежит. Если внутри объект, его содержимое остаётся полностью открытым: ссылку менять нельзя, а вот всё, до чего по ней можно добраться, — пожалуйста.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ни одной диагностики: с точки зрения типов мы не трогали поле limits, мы им воспользовались. Чтобы закрыть и внутренность, readonly пришлось бы писать на каждом уровне вручную — или взять as const, который делает это сам.

Вторая дыра неприятнее и обнаруживается обычно на чужом коде. Помните структурную типизацию из этой главы: совместимость типов считается по набору полей и их типам. Модификатор readonly в этот подсчёт не входит вовсе.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Объект один, имён у него два, и через второе имя поле спокойно переписывается. Это не баг и не недосмотр: если бы readonly учитывался в совместимости, ни один объект с неизменяемым полем нельзя было бы передать в тысячи существующих функций, объявленных без него. С массивами компилятор рассуждает иначе — readonly number[] в number[] он не пустит и скажет TS4104, — но для свойств объектов сделано именно такое послабление.

readonly — обещание, которое ваш код даёт компилятору, а не замок на объекте. Оно ловит опечатку и невнимательность, но не защищает от чужого кода.

Из главы про базовые типы вы помните: const kind = "circle" получает литеральный тип "circle", а let расширяет его до string. Внутри объекта работает правило от let: поле объекта можно переприсвоить, значит его тип расширяется — даже если сам объект объявлен через const.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Значение подходит, а тип — нет: Аргумент типа "string" нельзя назначить параметру типа "Theme". Это одна из самых частых встреч новичка с as const: конфиг собран отдельной переменной, значение написано верно, а компилятор упирается. Обе строки, кстати, напечатали одно и то же — ошибка типов выполнение не отменяет.

Посмотреть, что именно поменялось, удобно приёмом из статьи про наведение типов: присвоить значение в null и прочитать отказ.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Компилятор назвал оба типа: { theme: string; retries: number; } и { readonly theme: "dark"; readonly retries: 3; }. То есть as const делает две вещи разом — каждому полю добавляет readonly и каждому значению оставляет литеральный тип вместо расширенного.

И, в отличие от ручного readonly, утверждение идёт вглубь: вложенные объекты и массивы тоже становятся неизменяемыми.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Два TS2540 — и обе записи всё равно прошли. К этому пора привыкнуть: as const строже, но по-прежнему исчезает при сборке.

Утверждение применяется только к литералу — числу, строке, логическому значению, литералу массива или объекта. Результату вызова функции его приписать нельзя.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

TS1355 перечисляет разрешённое прямым текстом. Зато as const можно поставить на одно поле, оставив остальные обычными, — так делают, когда весь объект замораживать не нужно.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Набор допустимых значений обычно нужен дважды: как тип для сигнатур и как массив или объект для запуска — отрисовать список, проверить пришедшее с сервера. Написанные порознь, они рано или поздно расходятся, и компилятор об этом не узнает. Поэтому пишут один раз — значение, — а тип выводят из него.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Читается это справа налево. typeof STATUSES — тип значения STATUSES, то есть readonly ["новая", "в работе", "готова"]. Индекс [number] спрашивает «а что лежит по любому числовому индексу» — и получается объединение всех трёх литералов. В отказе компилятор перечисляет его целиком: нельзя назначить параметру типа "новая" | "в работе" | "готова".

Без as const фокус не работает: тип массива был бы string[], а string[][number] — это просто string. Литеральные типы здесь не украшение, а всё содержание приёма.

С объектом делают то же самое, только ключи и значения дают два разных набора. Ключи достаёт keyof.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Добавили в LABELS новый ключ — тип StatusKey расширился сам, и все места, где перечислялись варианты, компилятор проверит заново. Убрали ключ — сломаются ровно те строки, где он использовался. Ради этого приём и живёт.

Что нужноКак записать
union значений массива(typeof LIST)[number]
union ключей объектаkeyof typeof MAP
union значений объекта(typeof MAP)[keyof typeof MAP]

Подробный разбор keyof и typeof ждёт в статье «keyof и typeof: тип из значения и его ключи» из главы про типы, вычисляемые из значений. Пока достаточно относиться к этим трём строчкам как к готовым рецептам: они закрывают большую часть повседневных случаев.

У точных типов есть обратная сторона. Метод includes у такого массива ждёт значение из самого набора — а мы обычно приходим к нему как раз с чем-то неизвестным.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Во время выполнения оба вызова честно вернули true: разница только в типах. Позже, в статье «Свои проверки типа: предикаты is и assert» из главы про сужение, эту проверку превратят в предикат — тогда после includes компилятор ещё и сузит тип строки до нужного union.

as const строже обычного readonly, но природа у него та же — это по-прежнему обещание в типах. Дыра из второго раздела никуда не делась.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ни одной диагностики, а «константа» поменялась. Если объект действительно должен быть неизменяемым во время выполнения — например, уезжает в чужой код или живёт в общем состоянии, — нужен Object.freeze. Он и типы уточняет, и запись запрещает по-настоящему.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Знакомый TS2540 — и следом то, чего за всю статью не добились ни readonly, ни as const: TypeError: Cannot assign to read only property, и пример обрывается. Так выглядит настоящий запрет. Заодно видно, что Object.freeze поверхностный в обе стороны: и в типах, и при запуске вложенный объект остался изменяемым.

TypeError бросается в строгом режиме — а он включён везде, где вы пишете современный код: в модулях и в собранном tsc выводе. В нестрогом коде запись просто молча не сработает.

ПриёмЖивёт доГлубинаПри попытке записи
const x = ...запускатолько имя переменнойTypeError
readonlyсборкиодно полеTS2540, запись проходит
as constсборкивесь литерал, вглубьTS2540, запись проходит
Object.freezeзапускаодин уровеньTS2540 и TypeError

На практике выбор простой. as const — для наборов-констант, откуда выводятся типы: это девять случаев из десяти. readonly — в сигнатурах функций и полях моделей, чтобы намерение «я это не порчу» проверялось, а не жило в комментарии. Object.freeze — там, где цена случайной мутации выше цены лишней проверки в рантайме.

  • readonly перед полем запрещает запись в него — но только для компилятора: после сборки объект обычный, и присваивание проходит.
  • const держит имя переменной, readonly — поле объекта; до запуска доживает только первое.
  • readonly действует на один уровень и не участвует в проверке совместимости: через переменную другого типа поле переписывается без единой диагностики.
  • as const у литерала делает сразу две вещи — оставляет литеральные типы значений и помечает readonly всё вложенное.
  • Из as const-значения выводится тип: (typeof LIST)[number] для массива, keyof typeof MAP для ключей объекта — список пишется один раз.
  • Неизменяемость во время выполнения даёт только Object.freeze, и та поверхностная; в строгом режиме запись бросает TypeError.

1/4У интерфейса User поле id помечено readonly. Строка user.id = 2 даёт TS2540. Что напечатает следующая за ней console.log(user.id)?

2/4Объект типа { readonly count: number } присваивают переменной, объявленной как { count: number }, и меняют поле через новое имя. Что скажет компилятор?

3/4Какой тип у поля в const draft = { theme: "dark" }?

4/4const config = Object.freeze({ retries: 3, nested: { deep: 1 } }). Строка config.nested.deep = 42 — что будет?

Один список вместо двух

Набор статусов описан дважды: типом и массивом. Они уже разошлись — в массиве появилось значение, о котором тип не знает, и цикл не компилируется. Перепишите так, чтобы источник был один: список значений, а тип выводился из него.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Тип Status удаляем, а массив закрепляем через as const и выводим объединение из него.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Диагностик больше нет: элемент массива теперь имеет тип "новая" | "готова" | "отменена", и это ровно то, что ждёт move. Добавьте в список четвёртое значение — тип расширится сам; уберите значение — компилятор покажет все места, где на него рассчитывали.

Кто испортил конфиг

Поле помечено readonly, компилятор не сказал ни слова, а значение после вызова изменилось. Сначала объясните, почему проверка ничего не заметила, потом почините: reset должна возвращать новый конфиг, не трогая переданный.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Параметр объявлен типом без readonly, а этот модификатор в проверке совместимости не участвует — поэтому Config спокойно прошёл в такой параметр, и внутри функции поле оказалось обычным. Первый шаг починки — объявить параметр самим типом Config: тогда компилятор сразу покажет виновника строкой TS2540 внутри функции.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Дальше остаётся сделать то, ради чего readonly и ставили, — вернуть новый объект вместо правки чужого.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Обратите внимание: тип с readonly-полями собирается из спреда без единой жалобы. Запрещена запись в готовый объект, а создание нового — обычное дело.

Литерал не долетел

Значение написано правильно, а компилятор не пускает его в параметр. Найдите причину и почините двумя разными способами — утверждением и аннотацией.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

У поля level вывелся тип string: поле объекта изменяемое, значит литеральный тип держать незачем. Первый способ — as const, он закрепляет литералы и заодно делает поля неизменяемыми.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Второй — назвать тип объекта заранее. Тогда литерал проверяется по цели присваивания, и расширяться ему незачем.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Разница в том, что останется после починки. После as const объект целиком неизменяем в типах, а preset.level имеет тип "error". После аннотации поле остаётся изменяемым, но принимает только "info" и "error" — что удобнее, если конфиг потом правят.