Свои проверки типа: предикаты is и assert
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Проверки вроде typeof компилятор читает сам, но стоит спрятать такую проверку в свою функцию — и сужение пропадает: наружу торчит просто boolean. Вернуть его умеет особый возвращаемый тип value is string — предикат. Платить за это приходится доверием: тело предиката компилятор не проверяет и верит вам на слово.
До сих пор сужение доставалось даром: написали if (typeof value === "string") — и внутри ветки компилятор сам поменял тип. Вынесем ровно ту же проверку в отдельную функцию.
Проверка не изменилась ни на символ, а сужения больше нет. Анализ потока управления работает внутри одного тела функции: там компилятор видит и условие, и то, что из него следует. На вызове isString(value) видимость заканчивается — у вызова есть только тип результата, boolean, и что именно проверялось внутри, за границу не просачивается.
Обратите внимание на порядок вещей: диагностика есть, а программа отработала правильно. Проверка-то настоящая, просто компилятор про неё не знает. Это зеркальное отражение случая с as из статьи про unknown, где компилятор молчал, а падало исполнение.
Первое, что приходит в голову, — заткнуть диагностику тем самым as.
Формально сработало, по сути — нет. Связь между проверкой и утверждением держится только у вас в голове: поправят однажды isString, чтобы он пропускал ещё и числа, — и все as string по коду молча станут ложью. К тому же утверждение приходится повторять на каждой строке, где мы трогаем значение.
as — обещание в точке использования, его дают снова и снова. Предикат — обещание в точке проверки, оно даётся один раз.
Для функций-проверок в TypeScript есть особая форма возвращаемого типа: вместо boolean пишут параметр is Тип. Слева — имя одного из параметров этой же функции, справа — тип, который функция обещает, когда вернёт true.
Ни одной диагностики — и заметьте, работает предикат в обе стороны. В ветке true значение стало string, а в ветке false компилятор вычел строку из объединения и оставил number. Для него вызов предиката равносилен той самой проверке typeof, написанной по месту.
В скомпилированном коде от value is string не остаётся ничего: это пометка для компилятора, а не проверка. В браузер уезжает обычная функция, возвращающая true или false, — та же, что была до предиката.
Настоящая польза начинается там, где встроенных проверок не существует. Вернём долг статье про as const: там includes честно возвращал true, но тип строки после него не менялся.
Строка из формы, ключ из localStorage, поле разобранного JSON — всё это приходит в программу как string или unknown, а работать хочется с точным типом. Предикат и есть тот шов, где широкое превращается в узкое. Швов таких в проекте немного: обычно они собраны на самой границе с внешним миром.
А теперь главное, ради чего стоит читать эту статью. Тело предиката компилятор с обещанием в заголовке не сверяет. Совсем.
Диагностик нет ни одной, а программа падает. Ловушка с typeof null знакома по статье про сужение через typeof — но раньше её ловил бы сам компилятор, а внутри предиката он смотрит в другую сторону. Вы сказали «это User» — он записал.
Ошибка не обязана быть такой грубой. Гораздо чаще предикат проверяет форму в целом и забывает про поля, ради которых всё затевалось.
Кое-что компилятор всё-таки проверяет, но только по краям. Во-первых, обещанный тип должен быть подтипом параметра: пообещать про строку, что она число, не выйдет.
«Тип предиката типа должен быть доступным для назначения этому типу параметра» — за корявой формулировкой перевода стоит простая мысль: сужать можно только внутрь, к более узкому типу, а не вбок. Во-вторых, тело обязано возвращать boolean: return value; при value: unknown даст TS2322. На этом список заканчивается. Правда ли ваша проверка проверяет то, что обещает, не знает никто, кроме вас.
Про ветку false легко забыть, а компилятор про неё помнит: раз функция вернула false, обещанный тип из объединения вычитается. Предикат, отвечающий false на настоящую строку, ломает именно эту ветку.
Отсюда правило имени: предикат отвечает на вопрос «какого это типа», а не «подходит ли оно мне». Проверку длины стоит вынести в отдельную функцию с честным boolean.
Раз ответственность на вас, писать предикат приходится иначе, чем обычную проверку: не «похоже ли это на User», а «выполнено ли каждое обещание из типа User».
Три вещи отсюда стоит забрать с собой. Первая: null отсекается отдельной строкой, typeof его не выдаёт. Вторая: проверяется каждое поле, перечисленное в типе, — иначе обещание неполное. Третья: as внутри предиката уместен именно потому, что он локальный и оплаченный, ведь следом идут настоящие проверки. Тип Record<string, unknown> из статьи про индексные сигнатуры разрешает читать любые ключи, но каждый из них остаётся unknown — соврать через него не получится.
Предикат — место, где кончается «компилятор проверил» и начинается «я проверил». Такие места хорошо отмечать тестами.
Руками такой предикат не пишут: он получится длиннее самого типа и разъедется с ним при первой же правке. В экосистеме для этого есть валидаторы схем — форма данных описывается один раз, а библиотека и проверяет значение на исполнении, и выводит из схемы тип. Внутри у неё те же предикаты, просто сгенерированные.
Подключить пакет в песочнице нельзя, да и незачем: важен принцип. Ручной предикат хорош для двух-трёх полей и там, где никакой схемы нет, — например, для проверки строки на принадлежность литеральному объединению, как было с ролями.
Самый частый способ встретить предикат — filter. Отфильтровали массив от пустот, а компилятор считает, что пустоты остались.
Теперь уберите из notEmpty ровно одну аннотацию — : boolean — и запустите ещё раз.
Диагностика ушла. Начиная с версии 5.5 компилятор сам выводит предикат для простых функций-проверок: если тело — одно выражение, сужающее параметр, выводом будет не boolean, а name is string. Аннотация : boolean, написанная для ясности, этот вывод отменяет — вы явно попросили тип пошире, и компилятор послушался.
Отсюда неожиданное правило: у функции-проверки возвращаемый тип либо не пишут вовсе, либо пишут предикатом. : boolean — единственный вариант, который хуже обоих.
Вывод не всесилен и сдаётся легко. Разветвили тело — и предиката больше нет.
Гадать, сработал вывод или нет, не нужно: наведите курсор на имя функции — подсказка покажет либо boolean, либо предикат. В песочнице выручит приём из статьи про сужение по typeof: присвойте значение переменной заведомо чужого типа и прочитайте, что скажет диагностика.
Когда проверка нужна один раз и заводить именованную функцию не хочется, предикат пишут по месту — стрелке тоже можно задать такой возвращаемый тип.
Скобки вокруг параметра обязательны: без них двоеточие некуда поставить. Как и везде, обещание здесь не проверяется — просто оно короткое и целиком на виду.
Предикат отвечает «да» или «нет», и ответ надо куда-то деть — обычно в if. Но часто неподходящее значение означает не альтернативную ветку, а конец истории: дальше идти нельзя. Для таких случаев есть второй вид проверок — функции-утверждения.
Читается это так: у вызова два исхода — либо функция бросила исключение и продолжения не будет, либо она вернулась нормально, а значит, обещание выполнено. Поэтому сужение действует не в ветке, а от строки вызова и до конца области видимости. Ради этого asserts и существует: код после проверки остаётся плоским.
Доверие тут ровно такое же полное. Функция, забывшая бросить исключение, компилятору подозрительной не покажется.
Зато у asserts есть собственное требование, о которое спотыкаются все. Утверждению компилятор доверяет, только если уверен, что вызывается именно та функция, — а для этого у имени должен быть явно написанный тип.
Формулировка перевода — «утверждения требуют, чтобы каждое имя в целевом объекте вызова было объявлено с явной заметкой с типом» — звучит загадочно, но лечится одинаково: либо function вместо стрелки, либо аннотация у самой константы.
Тип функции выносят в алиас и вешают на константу. Тогда у имени assertString появляется явная аннотация, и требование выполнено.
| Приём | Проверка на исполнении | Где действует сужение | Что проверит компилятор |
|---|---|---|---|
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.
Возвращаемый тип меняем на value is Point — и сразу же дописываем проверки, за которые теперь отвечаем сами. После отсечения null значение можно локально прочитать как Record<string, unknown>: ключи станут доступны, но каждый останется unknown, так что проверить их придётся по-настоящему.
Внутри if тип input теперь Point, утверждения не нужны. Заметьте, что NaN исчез не из-за типов: его убрала настоящая проверка поля y, которую мы дописали в тело.
Утверждение вместо проверки на каждом шагу
Значения настроек могут быть undefined, и компилятор об этом честно предупреждает. Функция assertDefined уже бросает исключение, но объявлена с типом void, поэтому её работу компилятор не засчитывает. Поправьте объявление так, чтобы диагностика исчезла без единого if в connect.
Достаточно заменить void на asserts value is string. Заодно стоит поправить и тело: обещание касается типа, а проверка на undefined пропустила бы число или null.
Сужение действует от вызова и до конца функции, поэтому host ниже — обычная строка. Второй вызов показывает вторую половину сделки: значения нет — исключение, и до следующей строки дело не доходит.
Три символа, которые ломают фильтр
Массив отфильтрован от null, но компилятор этого не признаёт. Уберите диагностику, не трогая ни тело isTaken, ни строку с map. Решений тут два — найдите оба и объясните, чем они отличаются.
Первое решение — убрать аннотацию : boolean и дать компилятору вывести предикат самому.
Второе — написать предикат явно.
Оба варианта дают number[] и одинаковый вывод ["12.5", "8.0", "3.3"]. Разница в надёжности: выведенный предикат держится, пока тело остаётся простым, — разветвите его на два return, и вывод молча вернётся к boolean. Явная запись переживает любую правку тела, но и врать позволяет тоже любую.