Свои проверки типа: предикаты is и assert

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

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

Проверки вроде typeof компилятор читает сам, но стоит спрятать такую проверку в свою функцию — и сужение пропадает: наружу торчит просто boolean. Вернуть его умеет особый возвращаемый тип value is string — предикат. Платить за это приходится доверием: тело предиката компилятор не проверяет и верит вам на слово.

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

До сих пор сужение доставалось даром: написали if (typeof value === "string") — и внутри ветки компилятор сам поменял тип. Вынесем ровно ту же проверку в отдельную функцию.

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

Проверка не изменилась ни на символ, а сужения больше нет. Анализ потока управления работает внутри одного тела функции: там компилятор видит и условие, и то, что из него следует. На вызове isString(value) видимость заканчивается — у вызова есть только тип результата, boolean, и что именно проверялось внутри, за границу не просачивается.

Обратите внимание на порядок вещей: диагностика есть, а программа отработала правильно. Проверка-то настоящая, просто компилятор про неё не знает. Это зеркальное отражение случая с as из статьи про unknown, где компилятор молчал, а падало исполнение.

Первое, что приходит в голову, — заткнуть диагностику тем самым as.

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

Формально сработало, по сути — нет. Связь между проверкой и утверждением держится только у вас в голове: поправят однажды isString, чтобы он пропускал ещё и числа, — и все as string по коду молча станут ложью. К тому же утверждение приходится повторять на каждой строке, где мы трогаем значение.

as — обещание в точке использования, его дают снова и снова. Предикат — обещание в точке проверки, оно даётся один раз.

Для функций-проверок в TypeScript есть особая форма возвращаемого типа: вместо boolean пишут параметр is Тип. Слева — имя одного из параметров этой же функции, справа — тип, который функция обещает, когда вернёт true.

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

Ни одной диагностики — и заметьте, работает предикат в обе стороны. В ветке true значение стало string, а в ветке false компилятор вычел строку из объединения и оставил number. Для него вызов предиката равносилен той самой проверке typeof, написанной по месту.

В скомпилированном коде от value is string не остаётся ничего: это пометка для компилятора, а не проверка. В браузер уезжает обычная функция, возвращающая true или false, — та же, что была до предиката.

Настоящая польза начинается там, где встроенных проверок не существует. Вернём долг статье про as const: там includes честно возвращал true, но тип строки после него не менялся.

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

Строка из формы, ключ из localStorage, поле разобранного JSON — всё это приходит в программу как string или unknown, а работать хочется с точным типом. Предикат и есть тот шов, где широкое превращается в узкое. Швов таких в проекте немного: обычно они собраны на самой границе с внешним миром.

А теперь главное, ради чего стоит читать эту статью. Тело предиката компилятор с обещанием в заголовке не сверяет. Совсем.

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

Диагностик нет ни одной, а программа падает. Ловушка с typeof null знакома по статье про сужение через typeof — но раньше её ловил бы сам компилятор, а внутри предиката он смотрит в другую сторону. Вы сказали «это User» — он записал.

Ошибка не обязана быть такой грубой. Гораздо чаще предикат проверяет форму в целом и забывает про поля, ради которых всё затевалось.

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

Кое-что компилятор всё-таки проверяет, но только по краям. Во-первых, обещанный тип должен быть подтипом параметра: пообещать про строку, что она число, не выйдет.

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

«Тип предиката типа должен быть доступным для назначения этому типу параметра» — за корявой формулировкой перевода стоит простая мысль: сужать можно только внутрь, к более узкому типу, а не вбок. Во-вторых, тело обязано возвращать boolean: return value; при value: unknown даст TS2322. На этом список заканчивается. Правда ли ваша проверка проверяет то, что обещает, не знает никто, кроме вас.

Про ветку false легко забыть, а компилятор про неё помнит: раз функция вернула false, обещанный тип из объединения вычитается. Предикат, отвечающий false на настоящую строку, ломает именно эту ветку.

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

Отсюда правило имени: предикат отвечает на вопрос «какого это типа», а не «подходит ли оно мне». Проверку длины стоит вынести в отдельную функцию с честным boolean.

Раз ответственность на вас, писать предикат приходится иначе, чем обычную проверку: не «похоже ли это на User», а «выполнено ли каждое обещание из типа User».

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

Три вещи отсюда стоит забрать с собой. Первая: null отсекается отдельной строкой, typeof его не выдаёт. Вторая: проверяется каждое поле, перечисленное в типе, — иначе обещание неполное. Третья: as внутри предиката уместен именно потому, что он локальный и оплаченный, ведь следом идут настоящие проверки. Тип Record<string, unknown> из статьи про индексные сигнатуры разрешает читать любые ключи, но каждый из них остаётся unknown — соврать через него не получится.

Предикат — место, где кончается «компилятор проверил» и начинается «я проверил». Такие места хорошо отмечать тестами.

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

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

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

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

Теперь уберите из notEmpty ровно одну аннотацию — : boolean — и запустите ещё раз.

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

Диагностика ушла. Начиная с версии 5.5 компилятор сам выводит предикат для простых функций-проверок: если тело — одно выражение, сужающее параметр, выводом будет не boolean, а name is string. Аннотация : boolean, написанная для ясности, этот вывод отменяет — вы явно попросили тип пошире, и компилятор послушался.

Отсюда неожиданное правило: у функции-проверки возвращаемый тип либо не пишут вовсе, либо пишут предикатом. : boolean — единственный вариант, который хуже обоих.

Вывод не всесилен и сдаётся легко. Разветвили тело — и предиката больше нет.

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

Гадать, сработал вывод или нет, не нужно: наведите курсор на имя функции — подсказка покажет либо boolean, либо предикат. В песочнице выручит приём из статьи про сужение по typeof: присвойте значение переменной заведомо чужого типа и прочитайте, что скажет диагностика.

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

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

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

Предикат отвечает «да» или «нет», и ответ надо куда-то деть — обычно в if. Но часто неподходящее значение означает не альтернативную ветку, а конец истории: дальше идти нельзя. Для таких случаев есть второй вид проверок — функции-утверждения.

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

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

Доверие тут ровно такое же полное. Функция, забывшая бросить исключение, компилятору подозрительной не покажется.

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

Зато у asserts есть собственное требование, о которое спотыкаются все. Утверждению компилятор доверяет, только если уверен, что вызывается именно та функция, — а для этого у имени должен быть явно написанный тип.

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

Формулировка перевода — «утверждения требуют, чтобы каждое имя в целевом объекте вызова было объявлено с явной заметкой с типом» — звучит загадочно, но лечится одинаково: либо function вместо стрелки, либо аннотация у самой константы.

Тип функции выносят в алиас и вешают на константу. Тогда у имени assertString появляется явная аннотация, и требование выполнено.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
ПриёмПроверка на исполненииГде действует сужениеЧто проверит компилятор
value as Tникакойв одном выражениичто T и тип значения хоть как-то родственны
value is Tта, что вы написалив ветках if и в filterчто T уже типа параметра
asserts value is Tта, что вы написалиот вызова до конца областито же плюс явную аннотацию у имени

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

  • Анализ потока не переходит границу вызова: своя проверка с типом boolean возвращает результат, но не сужает.
  • Предикат параметр is Тип возвращает сужение обратно — и в ветке true, и в ветке false.
  • Тело предиката компилятор не сверяет с обещанием: он проверит лишь, что обещанный тип уже типа параметра, а тело возвращает boolean.
  • Честный предикат отсекает null отдельно и проверяет каждое поле; локальный as Record<string, unknown> внутри него — нормальная плата.
  • У функции-проверки возвращаемый тип либо не пишут (компилятор выведет предикат сам), либо пишут предикатом — : boolean хуже обоих вариантов.
  • asserts value is T сужает от вызова до конца области, но требует явной аннотации у имени, иначе TS2775.

1/4Функция isString(value: unknown): boolean возвращает результат typeof value === "string". В другом месте написано if (isString(value)) { value.toUpperCase(); }. Что произойдёт?

2/4Написано function isUser(value: unknown): value is User { return typeof value === "object"; }, а затем if (isUser(null)) { ... } с чтением поля. Что скажет компилятор?

3/4Функция-проверка notEmpty(name: string | undefined): boolean с телом return name !== undefined; передана в filter. Почему после фильтрации компилятор всё ещё видит undefined, и что помогает?

4/4Утверждение объявлено стрелкой: const assertString = (value: unknown): asserts value is string => { ... }. Что будет в месте вызова?

Предикат вместо двух as

Проверка isPoint возвращает boolean, поэтому в цикле приходится дважды писать as Point. Хуже того, проверка пропускает объект без поля y, и в консоли появляется NaN. Превратите isPoint в предикат и сделайте его честным: из цикла должны исчезнуть оба as, а напечататься — только 3.

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

Возвращаемый тип меняем на value is Point — и сразу же дописываем проверки, за которые теперь отвечаем сами. После отсечения null значение можно локально прочитать как Record<string, unknown>: ключи станут доступны, но каждый останется unknown, так что проверить их придётся по-настоящему.

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

Внутри if тип input теперь Point, утверждения не нужны. Заметьте, что NaN исчез не из-за типов: его убрала настоящая проверка поля y, которую мы дописали в тело.

Утверждение вместо проверки на каждом шагу

Значения настроек могут быть undefined, и компилятор об этом честно предупреждает. Функция assertDefined уже бросает исключение, но объявлена с типом void, поэтому её работу компилятор не засчитывает. Поправьте объявление так, чтобы диагностика исчезла без единого if в connect.

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

Достаточно заменить void на asserts value is string. Заодно стоит поправить и тело: обещание касается типа, а проверка на undefined пропустила бы число или null.

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

Сужение действует от вызова и до конца функции, поэтому host ниже — обычная строка. Второй вызов показывает вторую половину сделки: значения нет — исключение, и до следующей строки дело не доходит.

Три символа, которые ломают фильтр

Массив отфильтрован от null, но компилятор этого не признаёт. Уберите диагностику, не трогая ни тело isTaken, ни строку с map. Решений тут два — найдите оба и объясните, чем они отличаются.

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

Первое решение — убрать аннотацию : boolean и дать компилятору вывести предикат самому.

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

Второе — написать предикат явно.

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

Оба варианта дают number[] и одинаковый вывод ["12.5", "8.0", "3.3"]. Разница в надёжности: выведенный предикат держится, пока тело остаётся простым, — разветвите его на два return, и вывод молча вернётся к boolean. Явная запись переживает любую правку тела, но и врать позволяет тоже любую.