Тип объекта: обязательные, необязательные, вложенные поля
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Объект в JavaScript — мешок, в который можно положить что угодно и когда угодно. TypeScript просит описать этот мешок заранее: какие поля обязательны, какие можно опустить, что лежит внутри вложенных. Самое тонкое место — записи age?: number и age: number | undefined: выглядят почти одинаково, а от вызывающего кода требуют разного, и разницу первым покажет текст ошибки.
Тип объекта — это просто список полей с их типами. Пишется он там же, где любая другая аннотация, и никакой новой сущности за собой не тянет: ни класса, ни конструктора, ни проверки во время выполнения. До браузера доезжает обычный объектный литерал.
«Свойство "nickname" не существует в типе "{ name: string; age: number; }"» — компилятор выписал в сообщение всю форму целиком, чтобы было видно, что именно он считает списком разрешённых полей. Выполнению это не помешало: обращение к несуществующему свойству — законная операция JavaScript, она даёт undefined. Компилятор возражает не потому, что программа упадёт, а потому, что вы написали не то, что собирались.
Записывать форму прямо в аннотации удобно ровно один раз. Дальше типу дают имя через type, и оно начинает работать как полноценная сущность: видно в подсказках редактора и в текстах ошибок. То же самое умеет interface — чем они правда отличаются, разбирает следующая статья.
Первый объект компилятор пропустил молча, хотя поля перечислены в другом порядке. Второй получил одну диагностику ровно на слове "тридцать": «Тип "string" не может быть назначен для типа "number"». Дальше сработал приём, знакомый по первым статьям части: диагностику проигнорировали, код выполнился — и упал уже в браузере.
По умолчанию каждое перечисленное поле обязательно. Пропустить его нельзя, даже если прямо сейчас оно вам не нужно.
«Свойство "age" отсутствует в типе "{ name: string; }" и является обязательным в типе "User"». Обратите внимание на левую часть сообщения: компилятор сначала вывел тип написанного вами литерала — { name: string; } — и уже его сравнил с обещанным User. Подчёркнута при этом переменная, а не строка с типом: неправ здесь литерал, а не объявление.
Тип объекта ничего не создаёт и никого не проверяет во время выполнения. Проверка одна, на этапе сборки; в браузер уезжает голый литерал.
Иногда хочется объявить пустую заготовку и дозаполнить её позже. Аннотация этого не разрешит: она обещает, что значение уже такой формы, а не станет такой когда-нибудь.
TS2739 — тот же TS2741, но во множественном числе: «В типе "{}" отсутствуют следующие свойства из типа "User": name, age, city». Выходов два: собрать объект целиком одним литералом или описать заготовку отдельным типом, где поля необязательны. Для второго есть готовый инструмент Partial — о нём статья «Partial, Pick, Omit, Record, ReturnType, Awaited» в главе про типы, вычисляемые из значений.
Знак вопроса перед двоеточием снимает обязанность писать поле. Только обязанность — не больше.
Диагностика ровно одна, и не там, где её ждёшь. Само чтение guest.age разрешено — третья строка честно печатает undefined. Запрещён вызов метода у того, чего может не оказаться: «Возможно, "guest.age" имеет значение undefined». Слово «возможно» здесь точное — компилятор не утверждает, что там пусто, он утверждает, что не знает. И на этом примере предупреждение сбылось буквально: четвёртая строка упала с TypeError.
Что именно ? делает с типом поля, видно, если попробовать прочитать его в переменную, где undefined не ждут.
«Тип "number | undefined" не может быть назначен для типа "number"», а вложенной причиной — «Тип "undefined" не может быть назначен для типа "number"». Читается по схеме из статьи про диагностику: сверху общий отказ, снизу конкретный виновник. Но главное здесь — имя типа. Объявлено age?: number, а при чтении поле имеет тип number | undefined: знак вопроса добавил undefined в набор возможных значений.
Знак вопроса — это два обещания сразу: «поле можно не писать» и «при чтении там может оказаться undefined».
Отсюда простое следствие. Необязательное поле не избавляет от работы, а переносит её: раньше вы решали, писать ли поле, теперь решаете, что делать, когда его нет. Компилятор всего лишь не даёт пропустить этот вопрос молча.
Если ? всё равно добавляет undefined к типу поля, зачем нужна вторая запись — age: number | undefined? Разница есть, она одна, и проявляется не при чтении, а при создании объекта.
age?: number означает «поле можно не упоминать». age: number | undefined означает «поле упомянуть обязательно, но значением может быть undefined». Ошибка у Hard — та же самая TS2741 про отсутствующее обязательное свойство: с точки зрения компилятора age здесь обычное обязательное поле, просто с широким типом.
Последняя строка показывает, что разница не только в текстах ошибок. "age" in a даёт false, "age" in c — true: в одном объекте ключа нет вообще, в другом он есть и равен undefined. Это различие существовало в JavaScript всегда, ещё в главе про объекты; TypeScript просто дал ему два разных обозначения в типах.
| Что делаем | age?: number | age: number | undefined |
|---|---|---|
| Создать объект без этого поля | можно | TS2741 |
Написать age: undefined явно | можно | можно |
| Тип поля при чтении | number | undefined | number | undefined |
Нужна проверка перед toFixed | да | да |
Ключ попадёт в Object.keys | только если написали | всегда |
Когда что писать. ? — обычный выбор для настроек, необязательных полей формы, всего, что дописывается по желанию. | undefined берут там, где забыть про поле опаснее, чем оставить его пустым: тип заставит автора вызывающего кода принять решение и написать undefined руками.
Третий вызов даёт TS2345: «Аргумент типа "{ amount: number; }" нельзя назначить параметру типа "Price"», а причиной — знакомое «Свойство "discount" отсутствует... и является обязательным». И всё-таки он напечатал 1000: для JavaScript отсутствующее поле и поле со значением undefined при чтении неразличимы. Вся разница живёт в типах — и в этом её польза: она про то, о чём подумал автор кода, а не про то, что лежит в памяти.
Под обычным strict в необязательное поле разрешено записать undefined явно: { name: "Гость", age: undefined } для типа Soft проходит молча. Строго говоря, это уступка — «поля нет» и «поле есть, но пустое» снова смешались в одну кучу.
Развести их до конца умеет отдельный флаг exactOptionalPropertyTypes. С ним age?: number начинает значить буквально «либо поля нет, либо там число», и явное age: undefined становится ошибкой. В strict флаг не входит и в песочнице учебника не включён, поэтому примеры выше ведут себя так, как ведут; подробный разговор о нём — в главе про настройку реального проекта.
Поле объекта само может быть объектом — тип для него пишется прямо на месте, и вложенность ничем не ограничена. Обязательность считается на каждом уровне отдельно: обязательное поле может содержать необязательные, необязательное — обязательные.
Литерал прошёл без единого замечания: contacts на месте, phone и company пропущены законно. Зато чтение дало две диагностики TS18048 — про rico.company и про rico.contacts.phone. Заметьте, что компилятор называет полный путь, а не имя поля: важно, где именно в цепочке разрыв.
Предупреждений два, а падение одно: выполнение остановилось на первом же TypeError, до последней строки дело не дошло. Компилятор в этом смысле щедрее движка — он показывает все проблемы разом, а не по одной за запуск.
Обратная комбинация — необязательный объект с обязательным полем внутри.
Сообщение стоит прочитать целиком: «Свойство "address" отсутствует в типе "{}" и является обязательным в типе "{ address: string; date?: string | undefined; }"». Компилятор выписал форму вложенного типа — и в ней обе записи из этой статьи стоят рядом: address: string без вопроса и date?: string | undefined. Он сам проговорил то, что знак вопроса добавляет к типу поля.
Последняя строка — опциональная цепочка из части I, и в типах она читается буквально: каждый ?. в коде отвечает одному ? в типе. Пропустить хоть один — получить TS18048 ровно на этом месте.
Второго ?. перед title.length не понадобилось: цепочка коротко замыкается целиком, и если company пусто, до .length дело не доходит. Тип у всего выражения при этом number | undefined — что компилятор и сообщил в последней строке.
TS18048 — самая частая диагностика вокруг объектных типов, и лечится она тремя приёмами. Сначала посмотрим на неё там, где её меньше всего ждут.
Предупреждение и результат совпали до буквы: 30 + undefined даёт NaN, и дальше NaN расползается по всей сумме. Без типов такую строку находят по странному числу в интерфейсе — и хорошо, если своими глазами, а не по письму от пользователя.
Первый приём — проверка. Сравнение с undefined компилятор читает и внутри ветки считает тип уже известным; механизм тот же, что спасал unknown в прошлой главе, и целиком ему посвящена следующая.
Второй приём — значение по умолчанию через ??. Оператор из части I оказывается здесь ещё и инструментом типов: на входе number | undefined, на выходе number.
Заметьте, что options.secure никакой проверки не потребовал: условие и тернарный оператор работают с undefined как с ложным значением, и это законно для любого типа. TS18048 появляется там, где у значения что-то берут, — свойство, метод, слагаемое.
Третий приём — деструктуризация со значением по умолчанию. Тот же ??, только записанный в сигнатуре: удобно, когда полей с умолчаниями несколько.
Проверка, ?? и умолчание в деструктуризации нужны и в обычном 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 можно было не указывать, — и приведите литерал в соответствие. Цель: ноль диагностик и осмысленные числа в консоли.
Обязательное поле пишется как есть, необязательное — со знаком вопроса. Литерал при этом обязан получить price, а discount может его не упоминать.
Строка с discount не требует ни проверки, ни ?.: оператор ?? принимает number | undefined и отдаёт number. А вот item.discount * 2 без ?? дало бы TS18048 — и заслуженно, потому что на исполнении это NaN.
Написать discount: number | undefined тоже можно, но тогда поле придётся указывать в каждом литерале — для скидки, которой у большинства товаров просто нет, это лишний шум.
Забыть нельзя написать
У посылки может не быть трек-номера, и функция это честно обрабатывает. Беда в другом: вызов describe({ id: 2 }) проходит молча, и по коду не отличить «трека действительно нет» от «про трек забыли». Переделайте тип так, чтобы вызывающий код был обязан высказаться про трек-номер явно. Проверьте, что { id: 2 } теперь даёт диагностику, а { id: 2, trackNumber: undefined } — нет.
Меняется ровно один символ в типе: знак вопроса уходит, а undefined переезжает в объединение.
Диагностика на втором вызове: «Аргумент типа "{ id: number; }" нельзя назначить параметру типа "Shipment"», а причиной — «Свойство "trackNumber" отсутствует... и является обязательным». Внутри функции не изменилось ничего: проверка === undefined нужна была и раньше, тип поля при чтении в обоих вариантах один и тот же.
Цена решения — поле придётся писать в каждом литерале. Поэтому приём берегут для полей, о которых опасно забыть молча: скидка, срок действия, идентификатор владельца.
Вложенная настройка
Блок retry необязателен, а функция читает его так, будто он есть всегда: две диагностики TS18048 и TypeError на втором вызове. Почините функцию, не трогая тип. Без блока должно печататься «повторов нет», с блоком — числа, причём delayMs по умолчанию 300.
Два уровня необязательности — два разных приёма: для внешнего блока проверка с ранним выходом, для внутреннего поля ??.
Ранний выход удобнее вложенного if: после return компилятор знает, что config.retry точно на месте, и до конца функции больше не спрашивает. Обратите внимание, что config.retry.times после проверки не требует ничего дополнительного — times внутри блока обязательно.