Индексные сигнатуры и Record: ключи заранее неизвестны

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

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

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

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

Тип объекта, с которого началась эта глава, — это список: компилятор знает, что у scores есть math и physics, и каждое обращение сверяет со списком. Пока ключ написан прямо в коде, сверка сходится.

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

Диагностика ровно одна, на последней строке, и в ней две части: Элемент неявно имеет тип "any", так как выражение типа "string" не может использоваться для индексации типа "{ math: number; physics: number; }", а следом причина — В типе "{ math: number; physics: number; }" не обнаружена сигнатура индекса с параметром типа "string". Вложенную причину читаем как в статье про диагностику: она и есть настоящий ответ.

Компилятор здесь не придирается. exact имеет литеральный тип "math" — конкретный ключ, он в списке есть. А loose имеет тип string, то есть «любая строка»: "math", "milk", "". Тип из двух полей не может пообещать значение для любой строки, и вместо обещания компилятор честно говорит, что тип результата ему неизвестен.

На игрушечном примере это выглядит формальностью. Стоит завести функцию — и становится видно, что за диагностикой стоит настоящая ложь в коде.

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

Функция объявлена возвращающей number, а вернула undefined — и возражения не последовало. Возражать было не с чем: результат индексации получил тип any, а any, как мы выяснили в прошлой главе, проходит куда угодно. Одна диагностика, но проблем в ней две: неизвестный тип и невыполненное обещание.

TS7053 — это не «объект нельзя индексировать». Это «вы просите значение по ключу, которого в типе нет, и я не знаю, что вам вернуть».

Чаще всего читатель встречает эту диагностику не в функции, а в переборе. Object.keys объявлен возвращающим string[] — про ваш конкретный объект он ничего не знает и знать не может.

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

Самый дешёвый выход тут — вообще не индексировать. Object.entries отдаёт готовые пары «ключ и значение», и типы у них выведены из объекта: subject — строка, mark — число.

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

Индексная сигнатура описывает не поле, а правило: «ключ такого типа — значение такого». Пишется она внутри типа объекта, в квадратных скобках, и заменяет собой перечисление, которого не существует.

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

Слово key ни на что не влияет — это подпись для человека, компилятор смотрит только на его тип. Типов ключа разрешено немного: string, number, symbol и шаблонные литеральные типы, до которых доберёмся в главе про условные и сопоставленные типы.

Ключи объектов в JavaScript всё равно строки. Сигнатура { [id: number]: string } — заявление о намерении, а Object.keys вернёт вам ["1", "2"].

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

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

Ни одной диагностики, и тема осталась светлой. Замените первую строку на type Options = { dark: boolean; compact: boolean } — и получите TS2551: Свойство "darkk" не существует в типе "Options". Вы имели в виду "dark"?. Отсюда простое следствие: индексная сигнатура нужна там, где ключи действительно неизвестны, а не там, где лень их перечислить.

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

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

Первая диагностика — на самом объявлении: Свойство "retries" типа "number" не может быть назначено типу индекса "string" "string". Два string подряд — не опечатка: первое здесь тип ключа, второе — обещанный тип значения. Вторая приходит уже к объекту и объясняет ту же мысль подробнее: Свойство "retries" несовместимо с сигнатурой индекса. Сигнатура пообещала строку по любому ключу, а retries это обещание нарушает.

Лечится расширением типа значений — тогда все поля укладываются в правило и объект принимает произвольные ключи сверх перечисленных.

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

Record<K, V> — готовый тип из стандартной библиотеки. Для компилятора Record<string, number> и { [key: string]: number } — одно и то же, разница только в длине записи.

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

Интересное начинается, когда в первый параметр попадает не string, а объединение литералов — тех самых, из статьи про литеральные типы. Тогда Record перестаёт быть словарём с произвольными ключами и превращается в таблицу с заранее известным набором строк.

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

Свойство "готово" отсутствует в типе { новая: string; работа: string; } и является обязательным в типе Record<Status, string>. Это ровно то, чего от таблицы и хотят: добавили статус в объединение — компилятор сам обошёл все места, где для него забыли написать подпись. Ключи Record по умолчанию обязательные, все до одного.

Проверка работает и на чтении: ключ, которого нет в объединении, компилятор не пропустит.

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

Диагностика та же самая, TS7053, но причина в ней другая: Свойство "архив" не существует в типе "Record<Status, string>". В первом разделе компилятор жаловался, что ключ слишком широкий; здесь — что ключ конкретный, но чужой.

Что написалиКакие ключи принимаетЗаметит ли пропущенный ключ
{ [key: string]: number }любые строкинет
Record<string, number>любые строкинет
Record<Status, string>только перечисленные в объединениида, TS2741

Record — не встроенное ключевое слово, а обычный сопоставленный тип в одну строку. Как он устроен изнутри, разбираем в главе про типы, вычисляемые из значений.

Здесь начинается вторая половина темы, и она важнее первой. Индексная сигнатура — это утверждение: «по любому строковому ключу здесь лежит число». Компилятор принимает утверждение за правду и больше ничего не проверяет. Что объект при этом пуст, знает только исполнение.

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

Тот же код, что в первом разделе, только с аннотацией — и вместо диагностики падение. Мы не проверили данные, а описали, какими они должны быть; по духу это тот же приём, что и утверждение через as: ответственность за правду перешла к нам.

Обиднее падения — тихий NaN, который никто не заметит до отчёта.

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

Ноль диагностик. Компилятор видит number + 1 и не имеет ни малейшего повода придраться — сигнатура ему пообещала число. Спасают обычные проверки из части I: оператор in или значение по умолчанию через ??.

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

Эти строки написаны не для компилятора — он их не требовал. Они написаны потому, что без них код врёт: обещает число, а отдаёт пустоту.

Требовать компилятор всё-таки умеет, просто соответствующая проверка выключена по умолчанию — и в песочнице учебника тоже. Флаг noUncheckedIndexedAccess заставляет считать результат любой индексации «значением или undefined»: тогда на const p = prices["milk"]; p.toFixed(2) появится TS18048: Возможно, "p" имеет значение undefined, и проверку придётся написать. Включать ли его в своём проекте — разговор в статье «Строгость сверх strict: noUncheckedIndexedAccess и exactOptionalPropertyTypes» из главы про настройку.

Индексная сигнатура описывает форму словаря, а не его содержимое. Есть ли ключ на самом деле — вопрос к исполнению, и отвечать на него по-прежнему вам.

У объекта-словаря есть особенность, к типам отношения не имеющая, но выстреливающая именно в словарях: у него есть прототип. Ключи вроде toString, constructor и valueOf в нём уже «есть», а тип об этом молчит.

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

Для словаря цен это курьёз. Для счётчика слов, который считает пользовательский ввод, — настоящая ошибка: достаточно, чтобы кто-то написал слово «constructor». И ?? здесь не спасёт, потому что унаследованный метод — не undefined.

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

Способ первый: объект без прототипа. Object.create(null) создаёт голый объект, наследовать которому не от кого.

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

Способ второй и более честный: Map из части I. Она сделана для словарей, чужих ключей у неё не бывает, а тип у неё не врёт — get объявлен возвращающим «значение или undefined», и проверку компилятор потребует сам.

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

Обратите внимание на разницу в характере. Объект с индексной сигнатурой молчал до самого падения; Map начала спорить на третьей строке — потому что её тип описывает не то, чего мы хотим, а то, что есть на самом деле.

Отсюда и правило выбора. Набор ключей известен заранее — Record по объединению литералов: получите проверку полноты. Ключи приходят из данных и особенно от пользователя — Map. Record<string, T> остаётся для своих, заведомо безопасных ключей: разобранного конфига, таблицы констант, кеша по идентификатору, который вы же и сгенерировали.

  • TS7053 означает не «объект нельзя индексировать», а «ключ типа string шире, чем список полей в типе».
  • Индексная сигнатура { [key: string]: number } и Record<string, number> — одно и то же: правило «любой строковый ключ — значение такого типа».
  • Record по объединению литералов ведёт себя иначе: все ключи обязательны, пропущенный даёт TS2741, а чужой — снова TS7053.
  • Сигнатура — обещание, а не проверка: за отсутствующий ключ компилятор не ругается, а на исполнении вы получаете undefined, NaN или TypeError.
  • Рядом с сигнатурой можно перечислять поля, но их типы обязаны укладываться в тип значений, иначе TS2411.
  • У объекта-словаря есть прототип, поэтому ключи вроде constructor «уже есть»; для данных извне надёжнее Map или Object.create(null).

1/4У объекта scores = { math: 5, physics: 4 } обращение scores["math"] проходит молча, а scores[loose] при let loose: string = "math" даёт TS7053. Почему?

2/4Тип Status — объединение трёх литералов, а в объекте Record<Status, string> расписаны только два. Что произойдёт?

3/4Объявлено const prices: Record<string, number> = { apple: 50 }, дальше написано prices["milk"].toFixed(2). Что будет?

4/4В интерфейсе написаны подряд [key: string]: string и retries: number. Как отреагирует компилятор?

Средний балл

Код считает средний балл и работает правильно, но каждый запуск сопровождается TS7053. Уберите диагностику, не отключая проверок и не добавляя any. Способов как минимум два — найдите оба.

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

Первый способ — сказать, что объект и есть словарь: аннотация Record<string, number> добавляет индексную сигнатуру, и индексация строкой становится законной.

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

Второй — вообще не индексировать. Значения нужны, ключи нет, поэтому достаточно Object.values: тип элементов выведен из объекта, диагностики нет, и аннотацию писать не пришлось.

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

Второй вариант обычно лучше: он не расширяет тип объекта до «любые строковые ключи», а значит, опечатка в scores.mth в другом месте файла по-прежнему будет поймана.

Подписи статусов

Функция должна возвращать подпись для каждого статуса, а на "готово" отдаёт undefined вопреки объявленному : string. Компилятор про это уже сказал — прочитайте диагностику и почините так, чтобы забыть статус впредь было нельзя.

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

Вложенная причина в диагностике называет виновника прямо: Свойство "готово" не существует в типе { новая: string; работа: string; }. Значит, лечить надо не индексацию, а сам объект — объявить его таблицей по объединению.

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

Стоит убрать любую из трёх строк — и появится TS2741 прямо на объекте. Именно это и требовалось: теперь новый статус в объединении заставит компилятор напомнить про подпись, а не оставит undefined на потом.

Аннотация Record<string, string> тоже убрала бы диагностику, но вместе с ней и всю пользу: пропущенный статус снова стал бы незаметен.

Счётчик слов

Счётчик молча выдаёт NaN, и компилятор не возражает. Почините — а потом проверьте свою починку строкой "constructor и constructor". Если и она считается правильно, задача решена полностью.

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

Первая половина чинится значением по умолчанию: result[word] = (result[word] ?? 0) + 1. Для «кот и кот» выйдет честная двойка, но на «constructor» такой счётчик всё ещё сломается — унаследованный от прототипа метод не undefined, и ?? его пропустит.

Надёжный вариант — Map: у неё нет прототипных ключей, а get честно объявлен возвращающим значение или undefined, так что ?? здесь стоит по делу, а не для вида.

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

Если объект нужен по другим причинам, помогает Object.create(null) — словарь без прототипа. Но для данных, пришедших извне, Map проще и не требует помнить об этой ловушке.