Сужение и анализ потока управления
Анализ потока управления отслеживает тип переменной в каждой точке программы. typeof, истинность, in, instanceof и присваивание сужают объединение — но замыкание или await расширяют его обратно.
Вы защищаете значение проверкой if (typeof id === "string"), и внутри блока проверяющий охотно позволяет вызвать id.toUpperCase(). Затем вы оборачиваете этот вызов в колбэк setTimeout — и внезапно проверяющий заявляет, что id снова string | number. С самим id ничего не изменилось. Изменилось то, где выполняется код относительно проверки. Сужение — это не свойство переменной; это свойство точки программы. Понимание того, как проверяющий вычисляет тип в каждой точке — и когда сдаётся, — отделяет борьбу с компилятором от управления им.
Проверяющий отслеживает тип в каждой точке программы
У переменной-объединения нет одного типа на всю область видимости. Проверяющий обходит граф потока управления и в каждой точке держит самый узкий тип, всё ещё совместимый с проверками, через которые туда пришли. Проверка расщепляет поток: ветка true несёт одно сужение, ветка false — дополнение.
function format(x: string | number) {
// x: string | number
if (typeof x === "string") {
x;
// ^? (parameter) x: string
return x.toUpperCase();
}
x;
// ^? (parameter) x: number — дополнение к строковой ветке
return x.toFixed(2);
}else (здесь — проход после return) получает number бесплатно: проверяющий вычитает string из объединения, ведь эта ветка уже обработана. Поэтому ранний return внутри проверки сужает остаток функции.
Пять повседневных операторов сужения
type Val = string | number | boolean | null | string[];
function inspect(v: Val) {
if (typeof v === "number") v; // ^? number (проверка typeof)
if (v === null) return; // проверка равенства → ниже v без null
if (Array.isArray(v)) v; // ^? string[] (встроенная проверка)
if (v) v; // ^? string | number | boolean | string[] (истинность убирает null)
}Повседневные инструменты, каждый сужает чуть по-своему:
typeof— расщепляет по рантайм-тегам JS ("string","number","boolean","object","function","undefined","symbol","bigint"). Учтите:typeof null === "object"— причуда JS, которую проверяющий уважает.- Истинность —
if (v)убирает из типаnull,undefined,0,"",false,NaN. Отлично дляstring | null, опасно дляnumber | undefined(заодно отбрасывает0). - Равенство —
===/!==против литерала илиnull/undefinedсужает обе ветки. in—if ("radius" in shape)сужает до членов объединения, объявляющих это свойство. Структурно, а не номинально.instanceof— сужает до класса (и подклассов) по цепочке прототипов в рантайме.
▸Граничные случаи
typeof v === "object" коварен. Он true и для массивов, и для null, и для обычных объектов. Под этой проверкой проверяющий сужает string | number[] | null до number[] | null (в рантайме оба — “object”), а не до number[]. С null всё ещё придётся разбираться. Предпочитайте Array.isArray и явные проверки === null голому typeof === "object".
Сужение присваиванием и разница let/const
Присваивание значения сужает переменную к типу присвоенного значения начиная с этой точки — но лишь в пределах, разрешённых объявленным типом.
let s: string | number;
s = "hello";
s;
// ^? string — присваивание сузило
s.toUpperCase(); // здесь OK
declare function rand(): string | number;
s = rand();
s;
// ^? string | number — расширено обратно до объявленного типаconst отличается от let для вывода литерала, который питает сужение. Привязка const к литералу получает литеральный тип; привязка let расширяется до базового типа:
const dir = "up";
// ^? "up" — литеральный тип, годится как дискриминант
let dir2 = "up";
// ^? string — расширен, больше не дискриминантЭто колоссально важно для размеченных объединений (через урок): дискриминант, прочитанный в const, сохраняет литеральный тип; скопированный в let, он расширяется, и сужение молча перестаёт работать.
Исчерпывание через never
Когда вы сузили объединение, убрав каждый его член, оставшийся тип — never. Присваивание его переменной типа never превращает «я забыл случай» в ошибку компиляции:
function area(s: { kind: "circle"; r: number } | { kind: "square"; side: number }): number {
switch (s.kind) {
case "circle": return Math.PI * s.r ** 2;
case "square": return s.side ** 2;
default: {
const _exhaustive: never = s;
// Если добавить новый вариант, s здесь уже не never →
// Error: Type '{ kind: "triangle"; ... }' is not assignable to type 'never'.
return _exhaustive;
}
}
}Это сеньорский паттерн безопасного рефакторинга: добавляешь вариант — и каждый необработанный switch загорается красным. Далее вы его формализуете.
Сужение теряется через границы функций
Это ловушка, в которую попадают все. Проверяющий считает, что любой вызов функции между проверкой и использованием мог изменить захваченную переменную, поэтому отбрасывает сужение для переменных, на которые не может доказать отсутствие влияния:
function handle(id: string | number) {
if (typeof id === "string") {
id;
// ^? string
setTimeout(() => {
id;
// ^? string | number — расширено обратно внутри замыкания!
// id.toUpperCase(); // Error: Property 'toUpperCase' does not exist on type 'number'.
});
}
}Замыкание может выполниться позже, когда id (в принципе) уже могли переприсвоить, поэтому проверяющий консервативно расширяет его до объявленного типа. То же происходит после await, ведь управление покинуло функцию и вернулось. Решения: скопировать суженное значение в const до границы (const sid = id;, пока id — string), или сделать захватываемую привязку const, чтобы проверяющий знал о невозможности переприсваивания.
function handle(id: string | number) {
if (typeof id === "string") {
const sid = id; // const захватывает суженную строку
setTimeout(() => sid.toUpperCase()); // OK — sid это string
}
}Упорядочи шаги, которые проверяющий выполняет, чтобы вывести тип переменной внутри защищённой ветки.
- 1 Начать с объявленного (возможно, объединённого) типа переменной
- 2 Дойти до выражения-проверки (typeof / in / равенство)
- 3 Расщепить поток управления: ребро true и ребро false
- 4 На ребре true оставить только члены, совместимые с проверкой
- 5 Расширить обратно до объявленного типа, если вмешался вызов функции или await
Почему тип снова расширяется?
1/3Внутри `setTimeout(() => { /* использование id */ })` ранее суженный `id` снова показан как `string | number`. Почему?
Для `v: number | undefined` — почему `if (v) { ... }` рискованная проверка сужения?
- 01Объясни, что вычисляет анализ потока управления и почему у переменной разные типы на разных строках.
- 02Перечисли пять повседневных операторов сужения и одну подножку для каждого.
- 03Почему сужение теряется через замыкание или await и как его сохранить?
Сужение — это анализ потока управления проверяющего в действии: в каждой точке программы он держит самый узкий тип, совместимый с проверками на пути туда, поэтому одна и та же переменная может быть string на одной строке и number на следующей. Повседневные операторы — typeof, истинность, равенство, in, instanceof — каждый расщепляет поток и оставляет совместимые члены, ветка false наследует дополнение, а исчерпанное объединение становится never (основа проверок исчерпания). Ловушки, которые надо усвоить: истинность отбрасывает 0 из числовых объединений, let расширяет литеральные дискриминанты, а замыкание или await расширяют до объявленного типа, если не захватить суженное значение в const заранее. Далее защитники типов позволят вам писать собственные функции-предикаты, участвующие ровно в этой машинерии потока управления. Теперь, когда тип расширится внутри колбэка или после await, сразу напишете const sid = id до границы — и перестанете спорить с компилятором.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.