Данные извне — это unknown
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Компилятор проверяет ваш код, а не чужие данные. Ответ сервера, запись в хранилище, параметр из адреса страницы — про них известно ровно то, что написано в типах, и ни буквой больше. Беда в том, что стандартная библиотека обещает про них any: с первой же строки разбора проверки выключаются, и ошибка всплывает не в редакторе, а у пользователя.
Самая частая дверь — JSON.parse. В стандартной библиотеке она объявлена как parse(text: string, reviver?): any, и это не оплошность: что лежит в строке, до разбора не знает никто.
Ни одной диагностики — и TypeError на последней строке. Про any вы уже знаете из главы про две двери из системы типов: он не «любой тип», а «не проверяй». Здесь важно другое — его никто не выбирал. Он приехал вместе с данными.
Дальше any расходится по программе: он подходит любому параметру, любой переменной, любому полю. Типизированный код принимает его молча.
Строка восьмая проходит без звука, одиннадцатая получает TS2345: Аргумент типа "unknown" нельзя назначить параметру типа "Point". Данные в обеих строках одинаковые — разный только тип, которым мы их пометили. И обратите внимание на вывод: рисуем в NaN напечаталось дважды. Диагностика не отменяет выполнение, точно так же ведёт себя tsc по умолчанию.
| Источник | Что обещает тип | Откуда это |
|---|---|---|
JSON.parse(text) | any | так объявлено в стандартной библиотеке |
await response.json() | any | у ответа fetch метод объявлен как json(): Promise<any> |
localStorage.getItem(key) | string | null | тип строки честный, а про её содержимое он молчит |
params.get(name) у URLSearchParams | string | null | то же самое: тип есть, смысла нет |
formData.get(name) у FormData | FormDataEntryValue | null | тип честный, но FormDataEntryValue — это File | string: вместо строки может приехать файл (разбор — в части IV, «FormData — это не объект со строками») |
event.data у события message | any | MessageEvent<T = any>: отправитель за пределами вкладки |
переменная в catch | unknown | единственная граница, которую strict закрыл сам |
Строка ответа приходит одинаково честной и от своего сервера, и от чужого: типы не пересекают сеть.
Заменить any на unknown в этой сигнатуре предлагают с 2018 года, и предложение до сих пор открыто. Причина простая: одной строкой в стандартной библиотеке перестали бы собираться почти все существующие проекты — везде, где результат разбора сразу используют как объект.
Так что any здесь — не рекомендация, а совместимость со старым кодом. Ставить границу приходится вручную, зато ровно там, где вы решите.
Компилятор не поставит unknown сам — сигнатуру он читает буквально. Аннотацию пишет автор, и это единственное место, где решается, откуда начинается проверяемый код.
TS18046: "data" относится к типу unknown. Это не запрет, а вопрос: докажи, что там объект с полем user. Как отвечать на такой вопрос, разбиралось в главе про сужение — typeof, in, Array.isArray.
Только не путайте аннотацию с проверкой. Аннотация объявляет намерение, а данные остаются какими были.
Диагностик ноль: any подходит и типу number. В переменной port лежит строка "8080", о чём читатель узнаёт из падения. Достаточно поменять тип возврата у readConfig — и то же самое место становится видимым.
TS2571: Объект имеет тип "Неизвестный" — формулировка неожиданная, компилятор перевёл на русский само слово unknown. Код диагностики другой, потому что жаловаться приходится не на именованную переменную, а на выражение. Падение при этом никуда не делось: типы стираются, до браузера доезжает обычный JavaScript.
Аннотация unknown ничего не стоит на исполнении — в собранном коде её просто нет.
unknown можно проверять там, где значение понадобилось. Тогда проверки расползаются: одно и то же поле доказывается в трёх функциях, и каждая знает про данные чуть-чуть. Дешевле разобрать один раз — на входе.
В этой функции важны три вещи. Первая — сигнатура: внутрь unknown, наружу User. Она и есть граница, всё за ней написано в типах. Вторая — текст ошибки называет поле, а не «данные некорректны»: разбирать такое падение в логах придётся вам же. Третья — возвращается новый объект, а не проверенный raw: поле admin, которое сервер прислал заодно, дальше не поедет.
Проверять стоит то, что используете, а не весь ответ. Если из огромного ответа нужны два поля — доказывайте два поля: лишние проверки ломаются при каждой правке на сервере и ничего при этом не защищают.
Функция с типом raw is User из главы про сужение решает похожую задачу, но иначе: она пропускает исходное значение как есть, и компилятор верит ей на слово — тело предиката никто не проверяет. Парсер отдаёт собранный объект, и его возврат компилятор проверяет обычным образом: забудете поле — не соберётся.
Предикат удобен, когда значение нужно оставить как есть — например, отфильтровать массив. Парсер — когда данные всё равно приходится нормализовать: обрезать лишнее, подставить умолчания, превратить строку с датой в Date.
С массивом легко решить, что дело сделано: проверка написана, ветка своя. Попробуйте предсказать, что скажет компилятор здесь.
Диагностик снова ноль — и снова падение. Array.isArray объявлен как isArray(arg: any): arg is any[], поэтому из unknown он сужает к any[]: сам массив доказан, а его элементы стали тем самым any, от которого мы уходили. Одна проверка — и замок открыт.
Лечится одной строкой: вернуть элементам unknown и пропустить каждый через тот же разбор.
С вложенными объектами компилятор ведёт себя лучше: после "address" in raw поле raw.address имеет тип unknown, а не any. То есть спуск на второй уровень — это ровно та же работа, что и на первом, и её видно в типах.
Бросить исключение — не единственный ответ. Выбор зависит от того, можно ли работать дальше без этих данных. Настройки из хранилища, например, ронять приложение не должны: там лежит строка, которую записала версия трёхмесячной давности.
Ни одного исключения и ни одной проверки на стороне вызывающего: readSettings всегда возвращает Settings. Мусор молча заменяется умолчанием — и заметьте, что "18" строкой не превратилось в число само собой, потому что превращать его никто не просил.
Когда данные нужны по-настоящему — бросайте, но ловите на границе, а не в глубине кода.
Обе записи пришли из одного источника, а судьба у них разная: первая проехала дальше в типизированный код, вторая остановилась на входе с внятным текстом. Третий вариант ответа — вернуть исход значением вида { ok: true, value } | { ok: false, error }: тогда вызывающий обязан посмотреть, что там, и try не нужен.
Ручной разбор хорош, пока полей немного. Дальше берут библиотеку схем — zod, valibot и подобные: форму описывают один раз, тип выводится из схемы, а проверка получается в комплекте, вместе с накопленным списком того, что именно не сошлось.
Платить приходится зависимостью и весом в сборке, поэтому для двух-трёх полей честнее свой парсер. Принцип от библиотеки не меняется: наружу — unknown, внутрь типизированного кода — только через разбор. А какую библиотеку схем берут для браузера и какую — для контракта, общего с сервером, напомнит полка браузера.
- Данные из-за границы программы приходят с типом
any: так объявленыJSON.parse,response.json()иevent.data— компилятор перестаёт проверять всё, что из них получилось. - Границу ставит автор: аннотация
unknownна переменной, параметре или типе возврата — единственное место, где вход перестаёт быть дырой. - Аннотация не проверяет значение, а объявляет намерение; проверяют только настоящие проверки, которые работают и в чистом JavaScript.
- Разбор на входе лучше проверок по месту: функция вида
(raw: unknown) => Userотдаёт готовый тип, называет виноватое поле и отсекает лишнее. Array.isArrayсужаетunknownдоany[]— элементам нужно вернутьunknown, иначе проверка массива только создаёт впечатление защиты.- Что делать с плохими данными — решает место: бросить, подставить умолчание или вернуть исход значением; молча продолжать с
any— не вариант.
1/4В коде const data = JSON.parse(body);, дальше data.user.age.toFixed(0), а поля age в ответе нет. Что покажет запуск?
2/4Переменная raw имеет тип unknown. Внутри if (Array.isArray(raw)) написано raw[0].name.toUpperCase(). Что скажет компилятор?
3/4Парсер заканчивается строкой return { name: raw.name, age: raw.age };. Сервер прислал ещё и поле admin. Что окажется в результате?
4/4Данные проходили через parseUser и всё работало. Со вторника сервер стал присылать age строкой. Что произойдёт?
Разбор задачи на входе
Напишите parseTask: она принимает unknown, возвращает Task, а на непохожих данных бросает TypeError с текстом, из которого видно, какое поле подвело. Заглушку уберите — сейчас она пропускает всё подряд.
Проверки идут сверху вниз: сначала что это вообще объект, потом каждое поле. Возвращается новый объект — так в Task попадает только описанное.
Проверка raw === null не формальность: typeof null тоже даёт "object", и без неё разбор упал бы на первом же null из ответа.
Массив с браком
В ответе четыре записи, годных из них две. Ронять всё из-за одной испорченной нельзя: соберите годные, остальные пропустите. Понадобятся две функции — toTask возвращает Task или null, collect собирает массив.
Ключевая строка — const items: unknown[] = raw;. После Array.isArray элементы имеют тип any, и без неё toTask вызывался бы для значений, которые компилятор считает уже проверенными.
Цикл можно свернуть в одну строку: return items.map(toTask).filter((task) => task !== null); — компилятор сам выводит из такой стрелочной функции предикат и убирает null из типа результата.
Город на второй глубине
Город лежит внутри address, и адреса в ответе может не оказаться вовсе. Доберитесь до address.city, а на любом обрыве пути бросьте TypeError с указанием поля.
Вложенный объект — такая же граница, как и внешний: после "address" in raw поле приходит с типом unknown, и его приходится доказывать заново.
Если полей на втором уровне несколько, разбор выносят в отдельную функцию parseAddress(raw: unknown): Address — и получается ровно тот же приём, применённый на этаж ниже.