Разбор входных данных: из unknown в модель
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Каталог начинается с чужой строки: файл рядом со страницей, ответ сервера, старая запись в localStorage. Модель Product, которую мы описали в прошлой статье, — пока обещание: она говорит, каким товар должен быть, и ничего не знает о том, что пришло на самом деле. Превратим обещание в доказательство — одним разбором на входе, после которого форма данных известна и компилятору, и вам.
У каталога ровно три источника данных снаружи: файл с товарами, который страница забирает запросом, настройки из localStorage и то, что наберут в форме. Все три отдают строку. В примерах ниже строка написана рядом с кодом — сети в песочнице нет, — но для разбора это ничего не меняет: важно не откуда она приехала, а что мы про неё знаем.
А знаем мы про неё ничего. Вот только компилятор об этом не догадывается:
Ни одной претензии от компилятора — и TypeError на первой же строке вывода: title в данных оказался числом. Уберите её — упадёт вторая, потому что price приехал строкой. Это тот самый any, о котором шла речь в главе про границы: не ошибка компилятора, а его капитуляция. Всё, что вытекло из JSON.parse, считается проверенным, хотя не проверено ничего.
Первое решение проекта — техническое, но с последствиями на весь код: в разборе есть одна точка входа, и на ней стоит аннотация.
TS18046 выглядит как поражение: ни одного поля прочитать нельзя. Выполнение это не отменяет — типы стираются, и в песочнице вы всё равно увидите 199, — но в проекте такая ошибка останавливает сборку. И это не наказание, а техническое задание: компилятор согласен пропустить дальше любое значение, но сначала докажите, что у него та форма, на которую вы рассчитываете.
Одна дверь — это ещё и одно место для точки останова: сюда приходит сырой ответ целиком, и видно, что реально прислал сервер.
JSON.parse бросает SyntaxError на обрезанном ответе, на HTML-странице ошибки вместо данных, на пустой строке. Это тоже плохой вход, а не ошибка программиста, — и обрабатывать его нужно там же, у двери.
Обратите внимание на error as Error: в catch попадает unknown, потому что бросить в JavaScript можно что угодно. В настоящем разборе мы это учтём аккуратнее.
Соблазн после TS18046 велик: дописать as Product[], и красное подчёркивание исчезнет. Попробуйте предсказать, что напечатает этот код, — и запустите.
Компилятор молчит: as не проверка, а заявление «я знаю лучше». Из any в Product[] он пропустит без единого вопроса. Проверять некому — и tags, которого в данных просто нет, доживает до join.
Хуже всего здесь не падение, а его адрес. Ошибка в данных случилась на входе, а вылезла в цикле вывода — а в настоящем проекте вылезет ещё дальше: в рендере карточки, в сортировке, в подсчёте суммы корзины. Стек-трейс покажет место, где данными пользуются, и ни слова о том, где они появились.
| Приём | Что проверяет при выполнении | Чем платим |
|---|---|---|
value as Product | ничего | падение далеко от входа |
value! | ничего | то же самое, но короче |
| Предикат по списку ключей | наличие полей | типы значений так и не проверены |
| Разбор поле за полем | каждое поле | тридцать строк кода один раз |
Правило проекта: as допустим там, где вы знаете о значении больше компилятора. О чужих данных вы не знаете ничего — значит, здесь он запрещён.
Разбор не пишут целиком — его собирают из нескольких крошечных функций, и первая же закрывает половину работы. isRecord отвечает на вопрос «это вообще объект со свойствами?» и заодно решает, каким типом станет проверенное значение.
Record<string, unknown> выбран не случайно. Поля читать можно, но каждое из них — unknown: последняя строка не скомпилируется, пока price не проверят отдельно. Именно это нам и нужно: одна проверка не должна открывать доступ ко всему объекту.
Напрашивается проверка без своей функции — typeof v === "object" && v !== null. Она правильная, но сужает до типа object, у которого нет ни одного свойства.
Компилятор скажет TS2339: свойство title не существует в типе object. Поэтому предикат и возвращает Record<string, unknown>, а не полагается на встроенное сужение.
Второй кирпич — функция, которая никогда не возвращает управление. Тип never здесь не украшение: он превращает вызов fail в законный «конец ветки», и компилятор перестаёт требовать от неё значения.
Благодаря never тернарный оператор в asString считается корректным: либо строка, либо выхода нет. Тот же приём работает и в операторе if — после if (!isRecord(v)) fail(...) компилятор считает v записью, хотя никакого else мы не писали.
Теперь собираем разбор товара. Каждое поле проходит через свой кирпич, и каждому передаётся путь — где именно в исходных данных мы сейчас находимся.
Запустите: items[0].price: ожидалось число, получено "1990". Такое сообщение можно переслать тому, кто отдаёт данные, и он поймёт его без вас. Сравните с честным, но бесполезным Invalid product data — за ним придётся лезть в отладчик и вручную сличать три сотни карточек.
Вторая половина сделки — возвращаемый тип : Product. Он превращает модель в сторожа: разборщик, который собрал не то, компилироваться не будет.
TS2741: свойство tags отсутствует в собранном объекте и является обязательным в типе Product. Пока модель и разбор связаны этой аннотацией, они не разъедутся: добавили поле в интерфейс — компилятор сам приведёт вас в разборщик и покажет, где его дописать.
Путь стоит собирать строкой сверху вниз: catalog[7].availability.count читается как адрес, а не как загадка.
В модели каталога наличие товара — размеченное объединение: in-stock с количеством, preorder с датой и gone без ничего. Внутри программы метка гарантирует форму. Снаружи метка — обычная строка, которой можно доверять не больше остальных.
Первая попытка проверить её списком известных значений выглядит очевидной — и не компилируется:
TS2345: аргумент типа string нельзя назначить параметру типа "in-stock" | "preorder" | "gone". Массив объявлен через as const, поэтому его includes согласен искать только одно из трёх значений — а мы пришли с произвольной строкой. Проверка, смысл которой в поиске неизвестного, запрещена самим типом искомого.
Лечится это не отключением проверки, а честным расширением типа массива: искать в нём можно любую строку — это правда, — а вот вывод о результате поиска остаётся за предикатом.
В разборе, впрочем, и предикат не понадобится: switch по метке сужает даже unknown, а ветка default ловит всё незнакомое.
Внутри каждой ветки kind уже не unknown, а конкретный литерал — поэтому короткая запись { kind, count: ... } собирает нужный вариант объединения без единого as. А default сообщает не «неверные данные», а какое именно значение метки оказалось незнакомым: чаще всего это новый вариант, который появился на сервере, но ещё не появился у вас.
Разбор, который бросает исключение, хорош для одного объекта и плох для списка из трёхсот: обидно показать пустую страницу из-за одного товара с ценой-строкой. Решение — ловить исключение на уровне элемента и возвращать наверх и товары, и список пропущенного.
Каталог показывает то, что разобралось, а строки из skipped уходят в консоль, в баннер для редактора или в отчёт об ошибках. Где ставить границу «одна битая карточка — пропускаем, половина каталога битая — сдаёмся», решает сам проект; главное, что решение принимается здесь, а не случайно, где-то в глубине рендера.
И вот ради чего всё затевалось: ниже по коду report.items — это Product[], доказанный, а не обещанный. Никаких ?. на всякий случай, никаких as, никаких «а вдруг там строка». Рендер и настройки из следующих статей главы получают данные, форму которых уже никто не оспорит.
В настоящем проекте вместо ручных кирпичей часто берут библиотеку схем — Zod, Valibot и подобные. Схема описывается один раз, тип модели выводится из неё, а сообщения об ошибках с путями получаются сами. Плата — зависимость, вес в сборке и ещё один язык описания типов рядом с языком типов TypeScript.
Для каталога на три сотни строк ручной разбор дешевле и понятнее, и в песочнице учебника импортов всё равно нет. Но выбор между «своё» и «библиотека» — вопрос размера, а не принципа: механика внутри у них ровно та же самая, что мы только что написали.
- У данных снаружи одна дверь:
JSON.parseвнутри разбора, аннотацияunknownна входе иProductна выходе. as— обещание, а не проверка: компилятор молчит, а падение приезжает далеко от места, где данные появились.- Разбор собирается из кирпичей:
isRecordдаётRecord<string, unknown>, а функция с типомneverзакрывает ветку без значения. - Путь к полю в сообщении (
catalog[7].price) превращает «неверные данные» в ошибку, которую можно починить. - Аннотация возвращаемого типа делает модель сторожем разборщика: забытое поле — это
TS2741, а не сюрприз в рендере. - Ошибку элемента ловят на уровне элемента: каталог отдаёт разобранные товары и отчёт о пропущенных, а не пустую страницу.
1/4Данные из JSON.parse сразу приводят: JSON.parse(text) as Product[]. В строке пришёл массив объектов без поля tags. Что произойдёт?
2/4Предикат isRecord вернул true для значения raw. Какого типа теперь выражение raw.price?
3/4Список меток объявлен как const KINDS = ["in-stock", "preorder", "gone"] as const. Почему KINDS.includes(v) для v: string не компилируется?
4/4У разборщика написан возвращаемый тип : Product, а в модель добавили новое обязательное поле. Что случится?
Необязательное поле
В модели у товара появилась необязательная скидка: discount?: number. Разберите её так, чтобы отсутствие поля было нормой, а неверный тип — ошибкой с путём. Сейчас скидка просто теряется, а null проходит незамеченным.
Необязательное поле — это два случая, а не один: «поля нет» и «поле есть, но не то». Разводим их отдельным кирпичом, который отличается от asNumber ровно одной строкой.
Теперь { discount: null } честно падает с сообщением items[2].discount: ожидалось число, получено null: null — это не «поля нет», это неверное значение.
Один нюанс: у товара без скидки в объекте окажется discount: undefined — ключ есть, значение пустое. Для JSON.stringify и для проверок через in это не одно и то же, что отсутствие ключа. Если разница важна, поле добавляют через расширение:
Проверка, которая ничего не проверяет
Автор кода решил, что предиката по списку ключей достаточно. Компилятор доволен, а программа падает. Найдите, что именно предикат не проверяет, и перепишите его в честный разбор.
Подсказка: дыр здесь две, и обе — про значения, а не про ключи.
in отвечает только на вопрос «есть ли такой ключ», и ничего — на вопрос «что в нём лежит». В данных id — число, price — строка; предикат пропускает обоих, а toFixed на строке даёт TypeError: raw.price.toFixed is not a function. Компилятор поверил вам на слово: тело предиката он не сверяет с обещанием.
Честный вариант проверяет каждое поле и возвращает готовую модель, а не булево значение:
Теперь ошибка приходит с адресом — items[0].id: ожидалось строка, получено 1 — и приходит на входе, а не в момент вывода на страницу.
Число, приехавшее строкой
Сервер каталога отдаёт цену строкой: "price": "1990". Менять его нельзя, а as запрещён. Научите asNumber принимать такую строку — но только если она целиком число: "12 рублей", "" и "abc" должны падать по-прежнему.
Приведение типа на границе — нормальный приём, если оно происходит в одном известном месте и остаётся строгим. Number("") даёт ноль, а не NaN, поэтому пустую строку отсекаем отдельно.
Проверьте на " 890 " — пройдёт, пробелы Number отбрасывает сам; на "12 рублей" — упадёт, потому что Number вернёт NaN. И заметьте, где живёт эта поблажка: в одном кирпиче разбора, а не в двадцати местах, где цену складывают и выводят.