readonly и as const: неизменяемость в типах
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
«Это не меняется» в TypeScript пишут двумя способами: readonly перед именем поля и as const после литерала. Оба обещания живут только до сборки — собранный JavaScript пишет в такое поле как ни в чём не бывало, и это стоит один раз увидеть своими глазами. Зато as const попутно делает то, ради чего его чаще всего и ставят: превращает значения в литеральные типы, из которых получается готовый union.
Слово readonly ставится перед именем поля — в interface, в type, в литеральном типе объекта. Оно означает ровно одно: снаружи это поле разрешено читать, но не присваивать. Значение задаётся один раз, при создании объекта.
Компилятор сказал Не удается задать значение для "id", так как это свойство, доступное только для чтения — это TS2540. А потом в консоли появилась двойка: присваивание всё равно случилось. Та же история, что с readonly-массивом из главы про базовые типы — типы стираются при сборке, и до запуска доезжает обычный объект со всеми полями нараспашку.
Сравните с const, который выглядит похоже, но занимается совсем другим делом. const закрепляет связь имени со значением, а не содержимое объекта — и это правило самого JavaScript, а не выдумка TypeScript.
Отказов снова два, но они разной природы. TS2588 компилятор произносит до запуска, а следом браузер бросает настоящий TypeError — на этой строке пример обрывается. Точный текст у ошибки свой у каждого движка: выше напечатан тот, что даёт Chrome. const дожил до выполнения, readonly — нет.
readonly — про поле, const — про имя. Первое проверяет компилятор, второе — движок, и до запуска доживает только второе.
readonly закрывает само поле, а не значение, которое в нём лежит. Если внутри объект, его содержимое остаётся полностью открытым: ссылку менять нельзя, а вот всё, до чего по ней можно добраться, — пожалуйста.
Ни одной диагностики: с точки зрения типов мы не трогали поле limits, мы им воспользовались. Чтобы закрыть и внутренность, readonly пришлось бы писать на каждом уровне вручную — или взять as const, который делает это сам.
Вторая дыра неприятнее и обнаруживается обычно на чужом коде. Помните структурную типизацию из этой главы: совместимость типов считается по набору полей и их типам. Модификатор readonly в этот подсчёт не входит вовсе.
Объект один, имён у него два, и через второе имя поле спокойно переписывается. Это не баг и не недосмотр: если бы readonly учитывался в совместимости, ни один объект с неизменяемым полем нельзя было бы передать в тысячи существующих функций, объявленных без него. С массивами компилятор рассуждает иначе — readonly number[] в number[] он не пустит и скажет TS4104, — но для свойств объектов сделано именно такое послабление.
readonly — обещание, которое ваш код даёт компилятору, а не замок на объекте. Оно ловит опечатку и невнимательность, но не защищает от чужого кода.
Из главы про базовые типы вы помните: const kind = "circle" получает литеральный тип "circle", а let расширяет его до string. Внутри объекта работает правило от let: поле объекта можно переприсвоить, значит его тип расширяется — даже если сам объект объявлен через const.
Значение подходит, а тип — нет: Аргумент типа "string" нельзя назначить параметру типа "Theme". Это одна из самых частых встреч новичка с as const: конфиг собран отдельной переменной, значение написано верно, а компилятор упирается. Обе строки, кстати, напечатали одно и то же — ошибка типов выполнение не отменяет.
Посмотреть, что именно поменялось, удобно приёмом из статьи про наведение типов: присвоить значение в null и прочитать отказ.
Компилятор назвал оба типа: { theme: string; retries: number; } и { readonly theme: "dark"; readonly retries: 3; }. То есть as const делает две вещи разом — каждому полю добавляет readonly и каждому значению оставляет литеральный тип вместо расширенного.
И, в отличие от ручного readonly, утверждение идёт вглубь: вложенные объекты и массивы тоже становятся неизменяемыми.
Два TS2540 — и обе записи всё равно прошли. К этому пора привыкнуть: as const строже, но по-прежнему исчезает при сборке.
Утверждение применяется только к литералу — числу, строке, логическому значению, литералу массива или объекта. Результату вызова функции его приписать нельзя.
TS1355 перечисляет разрешённое прямым текстом. Зато as const можно поставить на одно поле, оставив остальные обычными, — так делают, когда весь объект замораживать не нужно.
Набор допустимых значений обычно нужен дважды: как тип для сигнатур и как массив или объект для запуска — отрисовать список, проверить пришедшее с сервера. Написанные порознь, они рано или поздно расходятся, и компилятор об этом не узнает. Поэтому пишут один раз — значение, — а тип выводят из него.
Читается это справа налево. typeof STATUSES — тип значения STATUSES, то есть readonly ["новая", "в работе", "готова"]. Индекс [number] спрашивает «а что лежит по любому числовому индексу» — и получается объединение всех трёх литералов. В отказе компилятор перечисляет его целиком: нельзя назначить параметру типа "новая" | "в работе" | "готова".
Без as const фокус не работает: тип массива был бы string[], а string[][number] — это просто string. Литеральные типы здесь не украшение, а всё содержание приёма.
С объектом делают то же самое, только ключи и значения дают два разных набора. Ключи достаёт keyof.
Добавили в LABELS новый ключ — тип StatusKey расширился сам, и все места, где перечислялись варианты, компилятор проверит заново. Убрали ключ — сломаются ровно те строки, где он использовался. Ради этого приём и живёт.
| Что нужно | Как записать |
|---|---|
| union значений массива | (typeof LIST)[number] |
| union ключей объекта | keyof typeof MAP |
| union значений объекта | (typeof MAP)[keyof typeof MAP] |
Подробный разбор keyof и typeof ждёт в статье «keyof и typeof: тип из значения и его ключи» из главы про типы, вычисляемые из значений. Пока достаточно относиться к этим трём строчкам как к готовым рецептам: они закрывают большую часть повседневных случаев.
У точных типов есть обратная сторона. Метод includes у такого массива ждёт значение из самого набора — а мы обычно приходим к нему как раз с чем-то неизвестным.
Во время выполнения оба вызова честно вернули true: разница только в типах. Позже, в статье «Свои проверки типа: предикаты is и assert» из главы про сужение, эту проверку превратят в предикат — тогда после includes компилятор ещё и сузит тип строки до нужного union.
as const строже обычного readonly, но природа у него та же — это по-прежнему обещание в типах. Дыра из второго раздела никуда не делась.
Ни одной диагностики, а «константа» поменялась. Если объект действительно должен быть неизменяемым во время выполнения — например, уезжает в чужой код или живёт в общем состоянии, — нужен Object.freeze. Он и типы уточняет, и запись запрещает по-настоящему.
Знакомый 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 — что будет?
Один список вместо двух
Набор статусов описан дважды: типом и массивом. Они уже разошлись — в массиве появилось значение, о котором тип не знает, и цикл не компилируется. Перепишите так, чтобы источник был один: список значений, а тип выводился из него.
Тип Status удаляем, а массив закрепляем через as const и выводим объединение из него.
Диагностик больше нет: элемент массива теперь имеет тип "новая" | "готова" | "отменена", и это ровно то, что ждёт move. Добавьте в список четвёртое значение — тип расширится сам; уберите значение — компилятор покажет все места, где на него рассчитывали.
Кто испортил конфиг
Поле помечено readonly, компилятор не сказал ни слова, а значение после вызова изменилось. Сначала объясните, почему проверка ничего не заметила, потом почините: reset должна возвращать новый конфиг, не трогая переданный.
Параметр объявлен типом без readonly, а этот модификатор в проверке совместимости не участвует — поэтому Config спокойно прошёл в такой параметр, и внутри функции поле оказалось обычным. Первый шаг починки — объявить параметр самим типом Config: тогда компилятор сразу покажет виновника строкой TS2540 внутри функции.
Дальше остаётся сделать то, ради чего readonly и ставили, — вернуть новый объект вместо правки чужого.
Обратите внимание: тип с readonly-полями собирается из спреда без единой жалобы. Запрещена запись в готовый объект, а создание нового — обычное дело.
Литерал не долетел
Значение написано правильно, а компилятор не пускает его в параметр. Найдите причину и почините двумя разными способами — утверждением и аннотацией.
У поля level вывелся тип string: поле объекта изменяемое, значит литеральный тип держать незачем. Первый способ — as const, он закрепляет литералы и заодно делает поля неизменяемыми.
Второй — назвать тип объекта заранее. Тогда литерал проверяется по цели присваивания, и расширяться ему незачем.
Разница в том, что останется после починки. После as const объект целиком неизменяем в типах, а preset.level имеет тип "error". После аннотации поле остаётся изменяемым, но принимает только "info" и "error" — что удобнее, если конфиг потом правят.