Сужение через in, instanceof и Array.isArray
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Для typeof все объекты на одно лицо: и массив, и дата, и экземпляр вашего класса, и обычный литерал получают один и тот же ответ "object". Различать объекты умеют три другие проверки — in, instanceof и Array.isArray. Каждая спрашивает своё: есть ли такой ключ, каким конструктором сделано, массив ли это, — и компилятор понимает все три.
Примитивы typeof разложил по полочкам. С объектами он заканчивается там же, где начинается интересное: два разных варианта объединения дают одинаковый ответ, и сужать нечего.
«Свойство "email" не существует в типе "Contact"», а вложенной причиной — что его нет у варианта { phone: string }. Проверка не убрала из объединения ни одного варианта: она оба назвала объектами и на этом успокоилась.
Обратите внимание на вывод: диагностику мы проигнорировали, код всё равно выполнился и напечатал undefined. Никакого падения — просто чтение поля, которого нет. Именно такие ошибки TypeScript и ловит: они не кричат, а тихо портят данные.
Дальше — три проверки, которые различают объекты. Все три вы уже писали в частях I и II, теперь у них появилась вторая работа.
Правило простое: примитивы различает typeof, объекты — in, instanceof и Array.isArray.
Оператор in отвечает на вопрос «есть ли у объекта такой ключ». Компилятор читает эту проверку и оставляет в ветке только те варианты объединения, у которых поле с таким именем описано.
Ни одной диагностики. В истинной ветке остался { email: string }, в ложной — { phone: string }, и до обоих полей можно дотянуться без всяких утверждений типа. Как и с typeof, сужение даёт только строковый литерал прямо в тексте условия: спрятанное в переменную имя ключа компилятор не отследит.
Теперь ловушка, ради которой стоит читать этот раздел. in спрашивает про ключ, а не про значение. Для необязательного поля это две разные вещи.
«Возможно, "user.nick" имеет значение undefined» — компилятор не поверил, что наличие ключа гарантирует значение. И оказался прав: ключ nick в объекте есть, значение в нём — undefined, и на исполнении получаем TypeError. Проверка in тут вообще ни при чём — от undefined спасает только сравнение со значением, как в статье про strictNullChecks.
Отсюда практическое правило: in хорош, чтобы выбрать вариант из объединения, и плох, чтобы проверить, что поле заполнено.
Во время выполнения in ищет ключ по всей цепочке прототипов — той самой, из главы про прототипное наследование. Своё поле и унаследованный метод для него одинаково «есть».
Для сужения объединений это почти никогда не мешает: имена полей в вариантах вы выбираете сами. Но если ключ приходит извне и совпал с именем из Object.prototype — constructor, toString, — проверка соврёт. Такое надёжнее спрашивать через Object.hasOwn.
x instanceof C отвечает на вопрос из части I: есть ли C.prototype в цепочке прототипов значения. Компилятор превращает этот ответ в сужение до типа экземпляра.
Самое частое место, где эта проверка нужна каждый день, — catch. В строгом режиме пойманное значение имеет тип unknown, потому что бросить в JavaScript можно что угодно, а не только ошибку.
«"error" относится к типу unknown» — та самая закрытая дверь из статьи про any и unknown: у unknown нельзя прочитать ни одного поля, пока не докажешь, что там. Доказательство — instanceof.
Вторая ветка — не паранойя автора учебника: чужая библиотека вполне может бросить строку или объект, и без проверки error.message дал бы undefined в логе как раз в тот момент, когда лог нужнее всего.
А теперь то, обо что спотыкаются все. Справа от instanceof должно стоять значение — класс или функция-конструктор. Интерфейс значением не является.
«"Circle" относится только к типу, но используется здесь как значение» — и следом вторая диагностика про unknown, потому что сужения не случилось. На исполнении всё ещё нагляднее: ReferenceError, имени Circle в скомпилированном коде просто нет. Интерфейсы и алиасы типов исчезают при компиляции, а instanceof — обычный оператор JavaScript и работает уже после этого исчезновения.
Проверить через instanceof можно только то, что доживает до выполнения: класс, конструктор, Error, Date, Map. Интерфейс и type — нельзя.
Массив — тоже объект, и для typeof он неотличим от любого другого. Пока в объединении массив соседствует со строкой, это сходит с рук; как только рядом окажется обычный объект — сужение рассыпается.
«Свойство "join" не существует в типе "Tags"». Заменим проверку — и всё встанет на место.
Компилятор понимает Array.isArray не потому, что знает это имя наизусть, а потому, что в стандартной библиотеке она объявлена особым образом — как проверка типа, возвращающая не просто boolean, а утверждение «аргумент — массив». Такие функции называют предикатами; писать свои вы научитесь в конце этой главы.
У предикатов из библиотеки есть цена, и у Array.isArray она заметная: утверждает она any[]. Пока в объединении был известный тип элементов, это незаметно. А вот на unknown проверка открывает дыру.
Ни одной диагностики — и TypeError на исполнении. Внутри ветки получился any[], а any, как мы помним, отключает проверки целиком: data[0] — это any, .price у any тоже any, и так до самого падения. Проверка на массив честная, обещание про элементы — нет.
Предикат утверждает именно any[], а readonly string[] к any[] не относится — он шире. Из-за этого сужение получается однобоким.
Диагностика приходит на последний return: в ложной ветке остался string | readonly string[], массив оттуда не ушёл. В истинной ветке дела не лучше — там any[], и тип элементов потерян. Лечится проверкой с другой стороны: спросить typeof input === "string" и вернуть массив в оставшейся ветке.
Все три проверки настоящие: они выполняются во время работы программы и отвечают на конкретный вопрос. Расхождение возникает там, где вопрос проверки и вопрос компилятора — разные.
Самый показательный случай — instanceof против структурной типизации. Компилятор считает объект экземпляром класса, если совпала форма; оператор — только если совпал прототип.
Литерал { x: 1, y: 2 } подошёл под тип Point по форме — про структурную типизацию мы говорили в третьей главе. А instanceof отвечает про происхождение и говорит «нет». Один и тот же аргумент, два противоположных ответа, и оба верны в своей системе координат.
То же расхождение с другой стороны: если тип значению не вывели, а назначили силой — приёмом as, до которого мы дойдём в десятой главе, — компилятор поверит на слово, а проверка нет. Она смотрит на настоящий прототип, а не на то, что вы ей пообещали. В коде, куда данные приходят снаружи, instanceof честнее компилятора.
Прототип у класса ровно один — тот, что создан в этом окружении. Если объект приехал из другого — из iframe, из воркера, из другого контекста выполнения, — у него свой Array и свой Error, и value instanceof Array вернёт false для настоящего массива.
Array.isArray этой болезнью не страдает: она спрашивает не про прототип, а про внутреннее устройство значения, и через границы окружений работает. Это вторая причина предпочитать её, а не instanceof Array.
| Что различаем | Проверка | О чём она на самом деле |
|---|---|---|
| Примитивы: строка, число, boolean | typeof x === "string" | какого рода значение |
| Объекты с разными полями | "email" in x | есть ли такой ключ (включая прототип) |
Экземпляры классов, Error, Date | x instanceof Error | есть ли прототип класса в цепочке |
| Массив против объекта | Array.isArray(x) | массив ли это, в любом окружении |
| Массив против строки | typeof x === "string" | проще спросить про строку |
null среди объектов | x !== null | typeof тут не помощник |
У всех трёх строк про объекты — in, instanceof и Array.isArray — один общий изъян: они выясняют, как значение устроено, а не что оно означает. «Есть поле email» и «это контакт по электронной почте» — разные утверждения, и совпадают они ровно до того дня, когда в объединение добавят третий вариант с таким же полем.
Поэтому в реальном коде объединения объектов чаще размечают явно: кладут в каждый вариант поле-метку и сравнивают его. Проверка становится обычным ===, вариантов может быть сколько угодно, а компилятор ещё и проследит, чтобы вы не забыли ни одного. Об этом — следующая статья.
in и instanceof — инструменты для чужих данных и чужих классов. Для своих типов метку задают сразу, и гадать не приходится.
typeofотвечает"object"на все объекты сразу, поэтому объединения объектов он не сужает — для них естьin,instanceofиArray.isArray.inоставляет в ветке варианты, у которых объявлен такой ключ; про значение он ничего не говорит, и у необязательного поля там по-прежнему может бытьundefined.instanceofсужает до типа экземпляра и незаменим вcatch, где переменная имеет типunknown; справа от него должно стоять значение — интерфейс туда поставить нельзя.- Проверка идёт по цепочке прототипов, поэтому потомков проверяют раньше предков, а объект той же формы, но не из класса, её не пройдёт.
Array.isArray— единственная надёжная проверка на массив, но утверждает онаany[]: наunknownэто открывает дыру, аreadonly-массив сужает наполовину.- Все три проверки описывают устройство значения, а не его смысл; для своих типов надёжнее поле-метка — о ней следующая статья.
1/4Тип Contact = { email: string } | { phone: string }, в коде стоит if ("email" in contact) { ... } else { ... }. Какой тип у contact в ветке else?
2/4У типа User поле объявлено необязательным: nick?: string. Внутри if ("nick" in user) написано user.nick.toUpperCase(). Что скажет компилятор?
3/4Circle объявлен как interface. В коде написано if (shape instanceof Circle). Что произойдёт?
4/4Параметр объявлен как data: unknown. Внутри if (Array.isArray(data)) стоит обращение data[0].price. Как отреагирует компилятор?
Три способа заплатить
Функция должна вернуть описание платежа: «карта 4276», «счёт 40817» или «кошелёк rico@wallet». Сейчас компилятор не пропускает обращение к полю — почините его проверками, не добавляя в тип ни одного нового поля.
Каждая проверка in отсекает один вариант, и до последней ветки доезжает ровно один оставшийся — обращение к wallet компилятору уже не нужно объяснять.
Обратите внимание, что последний return обошёлся без проверки: компилятор сам сообразил, что других вариантов не осталось. Если в Payment добавить четвёртый способ, эта строка перестанет компилироваться — и правильно сделает. Как заставить компилятор ловить такое всегда, разбирает статья про never.
Один вход, три формы
Настройка приходит одной строкой, массивом строк или вовсе отсутствует. Приведите её к массиву: одна строка — массив из одного элемента, null — пустой массив.
Порядок проверок важен: null отсекаем первым, иначе он доедет до ветки со строкой. Дальше массив отделяем от строки через Array.isArray.
Проверка if (!input) вместо input === null тоже скомпилируется, но проглотит пустую строку — ту самую ловушку истинности из прошлой статьи. Здесь toList("") должен давать [""], а не [].
Ветка, до которой не доходит
Ошибка NotFoundError наследует AppError, и функция должна печатать про неё отдельную строку с путём. Компилятор не возражает ни на одну строку, а вывод всё равно неверный. Найдите причину и почините.
instanceof проверяет всю цепочку прототипов, а не точное совпадение класса. Экземпляр NotFoundError честно является и AppError, и Error, поэтому первая же ветка забирает его себе. Лечится порядком: потомки проверяются раньше предков.
Компилятор здесь не помощник: обе ветки достижимы с точки зрения типов, и предупреждать ему не о чем. Это тот случай, когда порядок условий — часть логики, а не оформление.