Сужение типов и type guards
Union string | number бесполезен, пока вы не докажете компилятору, что именно перед вами. Механизм этого доказательства называется сужение типа (narrowing), и работает он не магией и не эвристикой: компилятор строит граф потока управления функции и для каждой ссылки (переменной, this.field, obj.prop) считает её тип в каждом узле графа отдельно. Проверка typeof x === 'string' — это ребро графа: в ветке then ссылка x получает тип string, в ветке else — то, что осталось от union. Никакого рантайм-кода TypeScript при этом не добавляет — вся аналитика происходит на этапе компиляции поверх обычного JavaScript, который вы и так написали бы.
Отсюда — три факта, на которых спотыкаются на собеседованиях. Первый: сужение привязано к ссылке, а не к значению; скопируйте суженное obj.prop в локальную const — и она сохранит тип, а сам obj.prop, перечитанный внутри колбэка, вернётся к объявленному. Второй, самый контринтуитивный: вызов функции не сбрасывает сужение. Компилятор не считает, что произвольный вызов мог всё испортить — он сбрасывает тип только на присваивании, которое видит в том же потоке. Это намеренная несостоятельность (unsoundness) языка: код после mutate() спокойно компилируется с устаревшим типом и падает в рантайме. Третий: as и ! — не сужение. Это утверждения, которые компилятор принимает на веру и стирает; настоящую проверку в рантайме делают только type guard и assertion-функция, а never в default-ветке превращает забытый вариант union в ошибку сборки.
Карта темы
- Сужение по потоку управления — как компилятор считает тип ссылки в каждой ветке графа, какие встроенные проверки он понимает и где сужение теряется.
- Пользовательские type guards — предикат
p is Fishкак обещание компилятору, которое тело функции может нарушить, и почему такому обещанию всё-таки нужна честная проверка. - null, undefined и strictNullChecks — что включает
strictNullChecks, чем??отличается от||на нуле и пустой строке и почему!не проверка, а обещание. - Размеченные объединения — литеральное поле-дискриминант, из-за которого один
switchсужает весь union, и расширение типа, которое молча ломает разметку. - Assertion-функции —
asserts v is Userсужает до конца области видимости, а не в одной ветке, и требует явной аннотации у цели вызова. - Проверка полноты через never — приём с
neverвdefault, превращающий новый вариант union в ошибку компиляции во всехswitchсразу.
Частые ошибки и ловушки
| Ошибка | Последствие | ||
|---|---|---|---|
| Считать, что вызов функции сбрасывает сужение | Не сбрасывает — компилятор реагирует только на видимое присваивание; после mutate() тип устаревает, а код падает в рантайме | ||
Проверять объект через typeof x === 'object' | typeof null тоже 'object' — null проваливается в ветку «это объект» и роняет первое же обращение к полю | ||
Ожидать, что в ветке else от if (s) останется только undefined | Проверка на истинность отсекает и '', и 0 — в else остаётся весь `string \ | undefined` | |
| Писать `value \ | \ | defaultValue` для чисел и строк | 0 и '' — falsy: подставится значение по умолчанию вместо законного нуля; для этого и существует ?? |
Считать as и ! проверками | Оба стираются — рантайм-проверки не происходит; ! на реальном undefined даёт TypeError там же, где дал бы и без него | ||
| Верить телу type guard на слово | Предикат p is Fish — обещание компилятору; тело может вернуть true для чего угодно, и компилятор поверит | ||
Забывать as const или аннотацию у литерала с дискриминантом | Поле расширяется до string, разметка исчезает, объект перестаёт подходить под union — классический баг | ||
Сужать по in там, где ключ необязательный | Необязательное поле не отсекает вариант: он остаётся в обеих ветках, и сужения фактически нет | ||
Полагаться на instanceof для интерфейса | Интерфейсы стираются — instanceof работает только с настоящей функцией-конструктором и цепочкой прототипов | ||
Читать суженное obj.prop внутри колбэка | Внутри вложенной функции изменяемая ссылка возвращается к объявленному типу — скопируйте её в const до колбэка | ||
Вызывать assertion-функцию через выведенную стрелочную const | Ошибка «Assertions require every name in the call target to be declared with an explicit type annotation» | ||
Закрывать default просто throw new Error() | Компилятор молчит: новый вариант union доедет до продакшена и упадёт в рантайме, а не на сборке | ||
Ждать, что never-приём поймает всё | Если union расширился до string или в него просочился any, never-присваивание проходит и защита исчезает | ||
Считать ?. защитой всей цепочки после себя | a?.b.c при a === null вернёт undefined целиком, но a.b?.c.d уже упадёт на a — короткое замыкание начинается только с места ?. | ||
Смешивать ?? с `\ | \ | или &&` без скобок | Синтаксическая ошибка — в JavaScript такое сочетание требует явных скобок |
Ждать сужения от if (typeof x === 'string') на let, присвоенном ниже | Присваивание в потоке перезаписывает тип; сужение действует только до него |
Значение для собеседований
Тема сужения — главный водораздел между «знаю синтаксис TypeScript» и «понимаю, как работает компилятор». Интервьюер почти никогда не спрашивает «что такое narrowing»; он спрашивает где оно ломается. Классическая связка: попросить написать функцию, отличающую два варианта union, потом добавить в неё вызов метода — и посмотреть, скажет ли кандидат, что сужение переживёт этот вызов (переживёт) и что это дыра в системе типов, а не гарантия.
Что обычно проверяют:
- Какие именно проверки понимает компилятор —
typeof,instanceof,in,Array.isArray, истинность, сравнение с литералом. - Что сужение — это анализ потока управления, а не свойство значения, и что оно привязано к ссылке.
- Переживает ли сужение вызов функции и почему это намеренная несостоятельность.
- Что такое type predicate
p is Fishи чем он опасен. - Разницу
??и||, и что!не делает никакой проверки. - Что делает union размеченным и почему литеральный тип дискриминанта обязателен.
- Чем
asserts v is Userотличается от предиката. - Приём с
neverи как он превращает добавление варианта в ошибку сборки.
Типичный неверный ответ: «компилятор считает, что любой вызов мог изменить значение, поэтому после вызова сужение сбрасывается, и его нужно перепроверить». Звучит логично и осторожно — и неверно. TypeScript намеренно не делает такого анализа побочных эффектов: он сбрасывает сужение только на присваивании, которое видит в том же потоке. Кандидат, уверенный в обратном, не увидит настоящий баг — переприсвоенную из колбэка let, которая после вызова всё ещё числится string. Второй частый провал — считать as формой сужения: «я же проверил через as User». Не проверили: as не порождает ни байта рантайм-кода, и отличается от честного type guard ровно тем, что ничего не проверяет.