Тип объекта: обязательные, необязательные, вложенные поля

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

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

Объект в JavaScript — мешок, в который можно положить что угодно и когда угодно. TypeScript просит описать этот мешок заранее: какие поля обязательны, какие можно опустить, что лежит внутри вложенных. Самое тонкое место — записи age?: number и age: number | undefined: выглядят почти одинаково, а от вызывающего кода требуют разного, и разницу первым покажет текст ошибки.

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

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

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

«Свойство "nickname" не существует в типе "{ name: string; age: number; }"» — компилятор выписал в сообщение всю форму целиком, чтобы было видно, что именно он считает списком разрешённых полей. Выполнению это не помешало: обращение к несуществующему свойству — законная операция JavaScript, она даёт undefined. Компилятор возражает не потому, что программа упадёт, а потому, что вы написали не то, что собирались.

Записывать форму прямо в аннотации удобно ровно один раз. Дальше типу дают имя через type, и оно начинает работать как полноценная сущность: видно в подсказках редактора и в текстах ошибок. То же самое умеет interface — чем они правда отличаются, разбирает следующая статья.

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

Первый объект компилятор пропустил молча, хотя поля перечислены в другом порядке. Второй получил одну диагностику ровно на слове "тридцать": «Тип "string" не может быть назначен для типа "number"». Дальше сработал приём, знакомый по первым статьям части: диагностику проигнорировали, код выполнился — и упал уже в браузере.

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

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

«Свойство "age" отсутствует в типе "{ name: string; }" и является обязательным в типе "User"». Обратите внимание на левую часть сообщения: компилятор сначала вывел тип написанного вами литерала — { name: string; } — и уже его сравнил с обещанным User. Подчёркнута при этом переменная, а не строка с типом: неправ здесь литерал, а не объявление.

Тип объекта ничего не создаёт и никого не проверяет во время выполнения. Проверка одна, на этапе сборки; в браузер уезжает голый литерал.

Иногда хочется объявить пустую заготовку и дозаполнить её позже. Аннотация этого не разрешит: она обещает, что значение уже такой формы, а не станет такой когда-нибудь.

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

TS2739 — тот же TS2741, но во множественном числе: «В типе "{}" отсутствуют следующие свойства из типа "User": name, age, city». Выходов два: собрать объект целиком одним литералом или описать заготовку отдельным типом, где поля необязательны. Для второго есть готовый инструмент Partial — о нём статья «Partial, Pick, Omit, Record, ReturnType, Awaited» в главе про типы, вычисляемые из значений.

Знак вопроса перед двоеточием снимает обязанность писать поле. Только обязанность — не больше.

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

Диагностика ровно одна, и не там, где её ждёшь. Само чтение guest.age разрешено — третья строка честно печатает undefined. Запрещён вызов метода у того, чего может не оказаться: «Возможно, "guest.age" имеет значение undefined». Слово «возможно» здесь точное — компилятор не утверждает, что там пусто, он утверждает, что не знает. И на этом примере предупреждение сбылось буквально: четвёртая строка упала с TypeError.

Что именно ? делает с типом поля, видно, если попробовать прочитать его в переменную, где undefined не ждут.

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

«Тип "number | undefined" не может быть назначен для типа "number"», а вложенной причиной — «Тип "undefined" не может быть назначен для типа "number"». Читается по схеме из статьи про диагностику: сверху общий отказ, снизу конкретный виновник. Но главное здесь — имя типа. Объявлено age?: number, а при чтении поле имеет тип number | undefined: знак вопроса добавил undefined в набор возможных значений.

Знак вопроса — это два обещания сразу: «поле можно не писать» и «при чтении там может оказаться undefined».

Отсюда простое следствие. Необязательное поле не избавляет от работы, а переносит её: раньше вы решали, писать ли поле, теперь решаете, что делать, когда его нет. Компилятор всего лишь не даёт пропустить этот вопрос молча.

Если ? всё равно добавляет undefined к типу поля, зачем нужна вторая запись — age: number | undefined? Разница есть, она одна, и проявляется не при чтении, а при создании объекта.

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

age?: number означает «поле можно не упоминать». age: number | undefined означает «поле упомянуть обязательно, но значением может быть undefined». Ошибка у Hard — та же самая TS2741 про отсутствующее обязательное свойство: с точки зрения компилятора age здесь обычное обязательное поле, просто с широким типом.

Последняя строка показывает, что разница не только в текстах ошибок. "age" in a даёт false, "age" in c — true: в одном объекте ключа нет вообще, в другом он есть и равен undefined. Это различие существовало в JavaScript всегда, ещё в главе про объекты; TypeScript просто дал ему два разных обозначения в типах.

Что делаемage?: numberage: number | undefined
Создать объект без этого поляможноTS2741
Написать age: undefined явноможноможно
Тип поля при чтенииnumber | undefinednumber | undefined
Нужна проверка перед toFixedдада
Ключ попадёт в Object.keysтолько если написаливсегда

Когда что писать. ? — обычный выбор для настроек, необязательных полей формы, всего, что дописывается по желанию. | undefined берут там, где забыть про поле опаснее, чем оставить его пустым: тип заставит автора вызывающего кода принять решение и написать undefined руками.

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

Третий вызов даёт TS2345: «Аргумент типа "{ amount: number; }" нельзя назначить параметру типа "Price"», а причиной — знакомое «Свойство "discount" отсутствует... и является обязательным». И всё-таки он напечатал 1000: для JavaScript отсутствующее поле и поле со значением undefined при чтении неразличимы. Вся разница живёт в типах — и в этом её польза: она про то, о чём подумал автор кода, а не про то, что лежит в памяти.

Под обычным strict в необязательное поле разрешено записать undefined явно: { name: "Гость", age: undefined } для типа Soft проходит молча. Строго говоря, это уступка — «поля нет» и «поле есть, но пустое» снова смешались в одну кучу.

Развести их до конца умеет отдельный флаг exactOptionalPropertyTypes. С ним age?: number начинает значить буквально «либо поля нет, либо там число», и явное age: undefined становится ошибкой. В strict флаг не входит и в песочнице учебника не включён, поэтому примеры выше ведут себя так, как ведут; подробный разговор о нём — в главе про настройку реального проекта.

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

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

Литерал прошёл без единого замечания: contacts на месте, phone и company пропущены законно. Зато чтение дало две диагностики TS18048 — про rico.company и про rico.contacts.phone. Заметьте, что компилятор называет полный путь, а не имя поля: важно, где именно в цепочке разрыв.

Предупреждений два, а падение одно: выполнение остановилось на первом же TypeError, до последней строки дело не дошло. Компилятор в этом смысле щедрее движка — он показывает все проблемы разом, а не по одной за запуск.

Обратная комбинация — необязательный объект с обязательным полем внутри.

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

Сообщение стоит прочитать целиком: «Свойство "address" отсутствует в типе "{}" и является обязательным в типе "{ address: string; date?: string | undefined; }"». Компилятор выписал форму вложенного типа — и в ней обе записи из этой статьи стоят рядом: address: string без вопроса и date?: string | undefined. Он сам проговорил то, что знак вопроса добавляет к типу поля.

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

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

Второго ?. перед title.length не понадобилось: цепочка коротко замыкается целиком, и если company пусто, до .length дело не доходит. Тип у всего выражения при этом number | undefined — что компилятор и сообщил в последней строке.

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

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

Предупреждение и результат совпали до буквы: 30 + undefined даёт NaN, и дальше NaN расползается по всей сумме. Без типов такую строку находят по странному числу в интерфейсе — и хорошо, если своими глазами, а не по письму от пользователя.

Первый приём — проверка. Сравнение с undefined компилятор читает и внутри ветки считает тип уже известным; механизм тот же, что спасал unknown в прошлой главе, и целиком ему посвящена следующая.

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

Второй приём — значение по умолчанию через ??. Оператор из части I оказывается здесь ещё и инструментом типов: на входе number | undefined, на выходе number.

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

Заметьте, что options.secure никакой проверки не потребовал: условие и тернарный оператор работают с undefined как с ложным значением, и это законно для любого типа. TS18048 появляется там, где у значения что-то берут, — свойство, метод, слагаемое.

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

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

Проверка, ?? и умолчание в деструктуризации нужны и в обычном JavaScript. TypeScript не добавляет работы — он не даёт про неё забыть.

  • Тип объекта — перечень полей; по умолчанию каждое обязательно, а пропущенное даёт TS2741 (или TS2739, если пропущено несколько).
  • Порядок полей в литерале не важен, а обращение к необъявленному полю — это TS2339, хотя на исполнении там законное undefined.
  • ? делает две вещи сразу: разрешает не писать поле и добавляет undefined к его типу при чтении — отсюда TS18048 «Возможно, ... имеет значение undefined».
  • age?: number и age: number | undefined при чтении неразличимы; разница в создании объекта — во втором случае поле обязано присутствовать, пусть даже со значением undefined.
  • Вложенность считается по уровням: обязательное поле может содержать необязательные и наоборот, а каждый ? в типе отвечает одному ?. в коде.
  • Жить с необязательным полем помогают три приёма — проверка на undefined, оператор ?? и умолчание в деструктуризации; всё это нужно и в обычном JavaScript.

1/4Тип User объявлен как { name: string; age: number }, а написано const guest: User = { name: "Гость" }. Что произойдёт?

2/4Поле объявлено как age?: number. Какой тип у выражения user.age при чтении?

3/4Чем age?: number отличается от age: number | undefined?

4/4Тип Profile — это { name: string; company?: { title: string } }, объект создан без company. Что скажет компилятор на console.log(profile.company?.title) и что попадёт в консоль?

Дописать форму

Тип Product описан наполовину: код обращается к полям, которых в нём нет, и получает две диагностики TS2339. Дополните тип так, чтобы price был обязательным, а discount можно было не указывать, — и приведите литерал в соответствие. Цель: ноль диагностик и осмысленные числа в консоли.

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

Обязательное поле пишется как есть, необязательное — со знаком вопроса. Литерал при этом обязан получить price, а discount может его не упоминать.

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

Строка с discount не требует ни проверки, ни ?.: оператор ?? принимает number | undefined и отдаёт number. А вот item.discount * 2 без ?? дало бы TS18048 — и заслуженно, потому что на исполнении это NaN.

Написать discount: number | undefined тоже можно, но тогда поле придётся указывать в каждом литерале — для скидки, которой у большинства товаров просто нет, это лишний шум.

Забыть нельзя написать

У посылки может не быть трек-номера, и функция это честно обрабатывает. Беда в другом: вызов describe({ id: 2 }) проходит молча, и по коду не отличить «трека действительно нет» от «про трек забыли». Переделайте тип так, чтобы вызывающий код был обязан высказаться про трек-номер явно. Проверьте, что { id: 2 } теперь даёт диагностику, а { id: 2, trackNumber: undefined } — нет.

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

Меняется ровно один символ в типе: знак вопроса уходит, а undefined переезжает в объединение.

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

Диагностика на втором вызове: «Аргумент типа "{ id: number; }" нельзя назначить параметру типа "Shipment"», а причиной — «Свойство "trackNumber" отсутствует... и является обязательным». Внутри функции не изменилось ничего: проверка === undefined нужна была и раньше, тип поля при чтении в обоих вариантах один и тот же.

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

Вложенная настройка

Блок retry необязателен, а функция читает его так, будто он есть всегда: две диагностики TS18048 и TypeError на втором вызове. Почините функцию, не трогая тип. Без блока должно печататься «повторов нет», с блоком — числа, причём delayMs по умолчанию 300.

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

Два уровня необязательности — два разных приёма: для внешнего блока проверка с ранним выходом, для внутреннего поля ??.

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

Ранний выход удобнее вложенного if: после return компилятор знает, что config.retry точно на месте, и до конца функции больше не спрашивает. Обратите внимание, что config.retry.times после проверки не требует ничего дополнительного — times внутри блока обязательно.