Сужение через in, instanceof и Array.isArray

сужение≈ 10 минут

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

Для typeof все объекты на одно лицо: и массив, и дата, и экземпляр вашего класса, и обычный литерал получают один и тот же ответ "object". Различать объекты умеют три другие проверки — in, instanceof и Array.isArray. Каждая спрашивает своё: есть ли такой ключ, каким конструктором сделано, массив ли это, — и компилятор понимает все три.

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

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

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

«Свойство "email" не существует в типе "Contact"», а вложенной причиной — что его нет у варианта { phone: string }. Проверка не убрала из объединения ни одного варианта: она оба назвала объектами и на этом успокоилась.

Обратите внимание на вывод: диагностику мы проигнорировали, код всё равно выполнился и напечатал undefined. Никакого падения — просто чтение поля, которого нет. Именно такие ошибки TypeScript и ловит: они не кричат, а тихо портят данные.

Дальше — три проверки, которые различают объекты. Все три вы уже писали в частях I и II, теперь у них появилась вторая работа.

Правило простое: примитивы различает typeof, объекты — in, instanceof и Array.isArray.

Оператор in отвечает на вопрос «есть ли у объекта такой ключ». Компилятор читает эту проверку и оставляет в ветке только те варианты объединения, у которых поле с таким именем описано.

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

Ни одной диагностики. В истинной ветке остался { email: string }, в ложной — { phone: string }, и до обоих полей можно дотянуться без всяких утверждений типа. Как и с typeof, сужение даёт только строковый литерал прямо в тексте условия: спрятанное в переменную имя ключа компилятор не отследит.

Теперь ловушка, ради которой стоит читать этот раздел. in спрашивает про ключ, а не про значение. Для необязательного поля это две разные вещи.

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

«Возможно, "user.nick" имеет значение undefined» — компилятор не поверил, что наличие ключа гарантирует значение. И оказался прав: ключ nick в объекте есть, значение в нём — undefined, и на исполнении получаем TypeError. Проверка in тут вообще ни при чём — от undefined спасает только сравнение со значением, как в статье про strictNullChecks.

Отсюда практическое правило: in хорош, чтобы выбрать вариант из объединения, и плох, чтобы проверить, что поле заполнено.

Во время выполнения in ищет ключ по всей цепочке прототипов — той самой, из главы про прототипное наследование. Своё поле и унаследованный метод для него одинаково «есть».

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

Для сужения объединений это почти никогда не мешает: имена полей в вариантах вы выбираете сами. Но если ключ приходит извне и совпал с именем из Object.prototype — constructor, toString, — проверка соврёт. Такое надёжнее спрашивать через Object.hasOwn.

x instanceof C отвечает на вопрос из части I: есть ли C.prototype в цепочке прототипов значения. Компилятор превращает этот ответ в сужение до типа экземпляра.

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

Самое частое место, где эта проверка нужна каждый день, — catch. В строгом режиме пойманное значение имеет тип unknown, потому что бросить в JavaScript можно что угодно, а не только ошибку.

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

«"error" относится к типу unknown» — та самая закрытая дверь из статьи про any и unknown: у unknown нельзя прочитать ни одного поля, пока не докажешь, что там. Доказательство — instanceof.

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

Вторая ветка — не паранойя автора учебника: чужая библиотека вполне может бросить строку или объект, и без проверки error.message дал бы undefined в логе как раз в тот момент, когда лог нужнее всего.

А теперь то, обо что спотыкаются все. Справа от instanceof должно стоять значение — класс или функция-конструктор. Интерфейс значением не является.

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

«"Circle" относится только к типу, но используется здесь как значение» — и следом вторая диагностика про unknown, потому что сужения не случилось. На исполнении всё ещё нагляднее: ReferenceError, имени Circle в скомпилированном коде просто нет. Интерфейсы и алиасы типов исчезают при компиляции, а instanceof — обычный оператор JavaScript и работает уже после этого исчезновения.

Проверить через instanceof можно только то, что доживает до выполнения: класс, конструктор, Error, Date, Map. Интерфейс и type — нельзя.

Массив — тоже объект, и для typeof он неотличим от любого другого. Пока в объединении массив соседствует со строкой, это сходит с рук; как только рядом окажется обычный объект — сужение рассыпается.

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

«Свойство "join" не существует в типе "Tags"». Заменим проверку — и всё встанет на место.

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

Компилятор понимает Array.isArray не потому, что знает это имя наизусть, а потому, что в стандартной библиотеке она объявлена особым образом — как проверка типа, возвращающая не просто boolean, а утверждение «аргумент — массив». Такие функции называют предикатами; писать свои вы научитесь в конце этой главы.

У предикатов из библиотеки есть цена, и у Array.isArray она заметная: утверждает она any[]. Пока в объединении был известный тип элементов, это незаметно. А вот на unknown проверка открывает дыру.

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

Ни одной диагностики — и TypeError на исполнении. Внутри ветки получился any[], а any, как мы помним, отключает проверки целиком: data[0] — это any, .price у any тоже any, и так до самого падения. Проверка на массив честная, обещание про элементы — нет.

Предикат утверждает именно any[], а readonly string[] к any[] не относится — он шире. Из-за этого сужение получается однобоким.

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

Диагностика приходит на последний return: в ложной ветке остался string | readonly string[], массив оттуда не ушёл. В истинной ветке дела не лучше — там any[], и тип элементов потерян. Лечится проверкой с другой стороны: спросить typeof input === "string" и вернуть массив в оставшейся ветке.

Все три проверки настоящие: они выполняются во время работы программы и отвечают на конкретный вопрос. Расхождение возникает там, где вопрос проверки и вопрос компилятора — разные.

Самый показательный случай — instanceof против структурной типизации. Компилятор считает объект экземпляром класса, если совпала форма; оператор — только если совпал прототип.

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

Литерал { x: 1, y: 2 } подошёл под тип Point по форме — про структурную типизацию мы говорили в третьей главе. А instanceof отвечает про происхождение и говорит «нет». Один и тот же аргумент, два противоположных ответа, и оба верны в своей системе координат.

То же расхождение с другой стороны: если тип значению не вывели, а назначили силой — приёмом as, до которого мы дойдём в десятой главе, — компилятор поверит на слово, а проверка нет. Она смотрит на настоящий прототип, а не на то, что вы ей пообещали. В коде, куда данные приходят снаружи, instanceof честнее компилятора.

Прототип у класса ровно один — тот, что создан в этом окружении. Если объект приехал из другого — из iframe, из воркера, из другого контекста выполнения, — у него свой Array и свой Error, и value instanceof Array вернёт false для настоящего массива.

Array.isArray этой болезнью не страдает: она спрашивает не про прототип, а про внутреннее устройство значения, и через границы окружений работает. Это вторая причина предпочитать её, а не instanceof Array.

Что различаемПроверкаО чём она на самом деле
Примитивы: строка, число, booleantypeof x === "string"какого рода значение
Объекты с разными полями"email" in xесть ли такой ключ (включая прототип)
Экземпляры классов, Error, Datex instanceof Errorесть ли прототип класса в цепочке
Массив против объектаArray.isArray(x)массив ли это, в любом окружении
Массив против строкиtypeof x === "string"проще спросить про строку
null среди объектовx !== nulltypeof тут не помощник

У всех трёх строк про объекты — 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». Сейчас компилятор не пропускает обращение к полю — почините его проверками, не добавляя в тип ни одного нового поля.

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

Каждая проверка in отсекает один вариант, и до последней ветки доезжает ровно один оставшийся — обращение к wallet компилятору уже не нужно объяснять.

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

Обратите внимание, что последний return обошёлся без проверки: компилятор сам сообразил, что других вариантов не осталось. Если в Payment добавить четвёртый способ, эта строка перестанет компилироваться — и правильно сделает. Как заставить компилятор ловить такое всегда, разбирает статья про never.

Один вход, три формы

Настройка приходит одной строкой, массивом строк или вовсе отсутствует. Приведите её к массиву: одна строка — массив из одного элемента, null — пустой массив.

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

Порядок проверок важен: null отсекаем первым, иначе он доедет до ветки со строкой. Дальше массив отделяем от строки через Array.isArray.

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

Проверка if (!input) вместо input === null тоже скомпилируется, но проглотит пустую строку — ту самую ловушку истинности из прошлой статьи. Здесь toList("") должен давать [""], а не [].

Ветка, до которой не доходит

Ошибка NotFoundError наследует AppError, и функция должна печатать про неё отдельную строку с путём. Компилятор не возражает ни на одну строку, а вывод всё равно неверный. Найдите причину и почините.

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

instanceof проверяет всю цепочку прототипов, а не точное совпадение класса. Экземпляр NotFoundError честно является и AppError, и Error, поэтому первая же ветка забирает его себе. Лечится порядком: потомки проверяются раньше предков.

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

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