Сужение по typeof и по истинности

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

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

Объединение string | number само по себе мало на что годится: пока компилятор не знает, что внутри, он разрешает только то, что найдётся у обоих вариантов сразу. Но стоит написать обычное if — и внутри ветки тип становится другим, уже не таким широким. Это называется сужением, и делает его не какая-то особая конструкция, а тот же typeof, которым вы пользовались в части I.

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

У значения типа string | number нет ни toUpperCase, ни toFixed. Разрешено только то, что есть у обоих вариантов одновременно, — и это разумно: компилятор ведь не знает, что именно придёт в вызове.

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

Диагностика приходит матрёшкой, как в статье про чтение ошибок: сначала общий вердикт про string | number, следом причина — toUpperCase нет у number. У строки-то метод есть, но этого мало: подойти должны все варианты сразу.

Дальше начинается самое интересное. Добавим проверку — и внутри ветки тип станет другим.

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

Ни одной диагностики. Внутри ветки value — это string, после неё — number, хотя параметр объявлен один раз и одним типом. Компилятор прошёл по коду тем же путём, каким пойдёт исполнение: попасть на шестую строку можно, только не выполнив условие, — а значит, строка отпадает. Этот разбор называют анализом потока управления, а его результат — сужением типа.

Отсюда главный вывод статьи: тип переменной — не один на всю функцию, а свой в каждой точке кода. Объявление задаёт лишь верхнюю границу, дальше её сужают проверки. Наведите в редакторе курсор на value внутри ветки и сразу за ней — подсказка покажет для одного имени два разных типа.

Сужение — не команда компилятору, а вывод, который он делает сам, читая ваши обычные проверки. Отдельного синтаксиса тут нет.

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

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

«Тип "string" не может быть назначен для типа "null"» — вот и ответ: в этой точке value имеет тип string. Приём работает в любом месте программы, а строчку потом убирают. Дальше в статье он пригодится не раз.

В части I typeof был способом спросить у значения, что оно такое. В TypeScript у него появилась вторая работа: компилятор читает такое сравнение и подстраивает тип. Сам список ответов не изменился.

typeof вернётКакие значенияТип в ветке, если проверяли unknown
"string"любая строка, включая пустуюstring
"number"числа, а также NaN и Infinitynumber
"bigint"10n и прочие большие целыеbigint
"boolean"true и falseboolean
"symbol"результат вызова Symbol()symbol
"undefined"только undefinedundefined
"function"любая функция, а также классFunction
"object"объекты, массивы — и nullobject | null

Последняя строка — источник половины ошибок в этой теме, и ей отведён следующий раздел. Сначала — как сужение выглядит в коде на самом безопасном варианте.

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

unknown из главы про базовые типы наружу ничего не выпускал, пока ему не докажешь, что там лежит. Вот и доказательство. Заметьте, что проверка нужна не компилятору: без неё умножение массива на два дало бы тихий NaN и в обычном JavaScript.

Сужает не любая проверка, а сравнение typeof со строковым литералом. Если такого варианта в объединении нет, ветка просто становится мёртвой — и компилятор об этом молчит.

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

Внутри условия value имеет тип never: не осталось ни одного варианта, который сюда прошёл бы. Синтаксически всё законно, поэтому претензий нет, — а код внутри не выполнится никогда. Тому, как из этого never сделать пользу, отведена отдельная статья главы.

Сужение — это разбор написанного текста, а не вычисление во время работы. Спрячьте строку сравнения в переменную — и сужения не будет, даже если переменная объявлена через const и её значение очевидно.

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

Диагностика та же TS2339, что была бы без всякой проверки. На исполнении код при этом совершенно правильный — wanted и вправду "string", и в консоли появится РИКО. Просто анализ потока такую подмену не отслеживает.

О том, что typeof null возвращает "object", вы читали в части I: ошибка первой реализации языка, которую уже не исправить без поломки половины интернета. В типизированном коде она перестаёт быть забавным фактом.

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

Компилятор эту ловушку знает: TS18047, «Возможно, tags имеет значение null». Проверка честно пропустила оба варианта — и массив, и null одинаково отвечают "object", — поэтому внутри ветки остался ровно тот же тип string[] | null, что был снаружи. Сузить не удалось ничего.

Дальше — показательный случай: диагностику проигнорировали, код всё равно запустился, первый вызов напечатал двойку, а второй упал настоящим TypeError. Компилятор указывал ровно на ту строку, где рвануло.

Лечится это не хитрой проверкой, а обычным сравнением — тем самым, о котором шла речь в соседней статье про strictNullChecks.

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

tags !== null отсекает ровно то, что мешало, и в ветке остаётся string[]. А различать между собой объекты, массивы и экземпляры классов typeof не умеет вовсе — для них есть in, instanceof и Array.isArray, им посвящена следующая статья.

На объект typeof отвечает "object" всегда: и на массив, и на дату, и на null. Полезной информации в этом ответе почти нет.

Второй способ сузить тип обходится вообще без слов: значение просто ставят в условие. Список ложных значений компилятор знает почти тот же, что и вы, — false, 0, 0n, "", null, undefined, NaN, — и вычитает из типа всё, что могло бы оказаться ложным. Восьмого значения из списка языка — -0 — здесь нет: для компилятора это тот же тип, что 0.

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

В истинной ветке name — это string, и метод строки берётся без придирок. А вот что осталось в ложной, угадывают неверно почти все.

ПроверкаТип в истинной веткеТип в ложной ветке
if (name) при string | undefinedstringstring | undefined
if (count) при number | undefinednumbernumber | undefined
if (tags) при string[] | nullstring[]null
if (value) при string | numberstring | numberstring | number

Строка в ложную ветку возвращается: пустая строка ложна, но она полноправная string. С массивом иначе — пустой массив истинен, поэтому в else остаётся один null. А последняя строка таблицы объясняет, почему проверка на истинность иногда не даёт ничего: и строка, и число бывают ложными, вычитать нечего.

Компилятор тут точнее человеческой интуиции. И следующий пример — про то, чего он поймать не может в принципе.

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

Ни одной диагностики — с точки зрения типов всё безупречно: toFixed вызван у числа. Врёт логика. Ноль — это полноценные данные, а if (count) отправил его туда же, куда и отсутствующее значение. Такую ошибку компилятор не найдёт никогда: вы попросили отсечь всё ложное, он отсёк.

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

typeof count === "number" спрашивает ровно то, что нужно, и ноль остаётся числом. Сравнение count !== undefined сделало бы то же самое — выбор между ними дело вкуса, важно лишь не спрашивать «истинно ли», когда на самом деле интересует «есть ли».

Истинность годится, когда ноль и пустая строка означают ровно то же, что отсутствие значения. Во всех остальных случаях спрашивайте прямо.

Раз тип — это свойство точки в коде, его можно и потерять. Присваивание переписывает результат сужения: компилятор берёт тип нового значения и с этого места считает верным его.

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

Проверка в заголовке говорит «строка», но к моменту вызова там уже число — и компилятор это отследил: в диагностике назван number, а не string | number. На исполнении получаем TypeError, дословно подтверждающий сказанное.

Куда неожиданнее ведёт себя отложенный код. Сужение доезжает до колбэка — если переменной больше нигде не присваивают.

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

А теперь допишем в самый конец функции одну строку. Она выполнится задолго до колбэка — таймер ждёт, пока освободится стек, это разбиралось в статье «Event loop: как браузер успевает всё».

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

TS18048 на четвёртой строке, хотя изменилась восьмая. Рассуждение честное: колбэк выполнится когда-то потом, а к тому времени в изменяемой переменной может лежать любое значение из объявленного типа. Пока присваиваний нет — сужение остаётся в силе; появилось хоть одно, хоть в конце функции, — и внутри отложенного кода тип снова полный. Здесь компилятор не придирается: код действительно падает.

Сужение отменяет присваивание где угодно в функции, а не только до колбэка. Компилятор смотрит на весь срок жизни переменной.

Проще всего снять копию в const: у неё будущих присваиваний быть не может по определению, поэтому и сомнений у компилятора нет.

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

Это та же осторожность, с какой в асинхронном коде забирают изменяемое состояние в отдельную переменную, — просто теперь на неё указывает не отладка, а компилятор.

  • Тип переменной — не один на всю функцию, а свой в каждой точке: компилятор разбирает поток управления и сужает объявленный тип по вашим обычным проверкам.
  • typeof сужает при сравнении со строковым литералом прямо в тексте условия; спрятанная в переменную строка сравнения сужения не даёт.
  • typeof x === "object" пропускает null вместе с объектами, поэтому от null оно не спасает — нужно прямое сравнение x !== null.
  • Проверка на истинность отсекает всё ложное разом: вместе с undefined уходят ноль и пустая строка. Компилятор молчит, а логика ломается.
  • В ложной ветке остаётся больше, чем кажется: у string | undefined там снова string | undefined, потому что пустая строка — тоже строка.
  • Присваивание отменяет сужение; для отложенного кода оно снимается любым присваиванием в функции, и лечится копией значения в const.

1/4Параметр объявлен как string | number. В начале функции стоит if (typeof value === "string") { return ... }. Какой тип у value в строке после этой ветки?

2/4Функция report(count: number | undefined) печатает данные внутри if (count) и «данных нет» в else. Что будет при вызове report(0)?

3/4Параметр tags: string[] | null, внутри — if (typeof tags === "object") и обращение к tags.length. Что скажет компилятор?

4/4Сужение if (name) благополучно доехало до колбэка в setTimeout. Затем в конец функции дописали строку name = undefined. Что изменится?

Ноль — это данные

Функция собирает строку отчёта. Для трёх заказов она права, для отсутствующего значения тоже, а вот ноль заказов превратился в «нет данных» — хотя ноль как раз данные, и довольно важные. Почините, не меняя сигнатуру.

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

Диагностик здесь не было и не будет: типы в порядке, ошибка в вопросе, который задан условию. Нужно спросить не «истинно ли», а «число ли».

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

Сужение при этом никуда не делось: в первой ветке count — number, и складывать его со строкой компилятор разрешает. Равноценный вариант — count !== undefined; он читается чуть прямее, потому что называет ровно тот случай, который отсекают.

Порт из настроек

Настройка приходит в трёх видах: числом, строкой из конфига или null, если её не задали. Наружу должно уходить число. Разберите все три случая: null и нечисловая строка дают 3000, остальное — своё значение.

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

Три варианта — три проверки, причём последняя не нужна: когда отсеяны null и string, остаётся только number, и компилятор это знает.

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

Последняя строка — та самая проверка на истинность, от которой предостерегал раздел про if (count). Разница в смысле: там ноль был осмысленным ответом, здесь он такой же мусор, как NaN.

Предскажите три типа

Прежде чем запускать, напишите на бумаге, какой тип у value в каждой из трёх помеченных точек. Затем проверьте себя трюком из первого раздела: подставьте в нужное место const check: null = value; и прочитайте диагностику.

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

Ответы: string, number, number | undefined. Первые два предсказывают почти все, третий — почти никто.

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

В третьей ветке undefined остался — это ожидаемо. Но с ним остался и number: ноль ложен, значит попасть сюда числом можно, и вывод 3: 0 это подтверждает. Строка отсюда ушла раньше, на первой проверке, — иначе пустая строка была бы здесь тоже.