open atlas
↑ К треку
Система типов TypeScript вглубь TS · 02 · 02

Сужение и анализ потока управления

Анализ потока управления отслеживает тип переменной в каждой точке программы. typeof, истинность, in, instanceof и присваивание сужают объединение — но замыкание или await расширяют его обратно.

TS Junior ◷ 14 min
Уровень
ОсновыJuniorMiddleSenior

Вы защищаете значение проверкой 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 сужает обе ветки.
  • inif ("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;, пока idstring), или сделать захватываемую привязку const, чтобы проверяющий знал о невозможности переприсваивания.

function handle(id: string | number) {
  if (typeof id === "string") {
    const sid = id;        // const захватывает суженную строку
    setTimeout(() => sid.toUpperCase());  // OK — sid это string
  }
}
Расставь шаги по порядку

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

  1. 1 Начать с объявленного (возможно, объединённого) типа переменной
  2. 2 Дойти до выражения-проверки (typeof / in / равенство)
  3. 3 Расщепить поток управления: ребро true и ребро false
  4. 4 На ребре true оставить только члены, совместимые с проверкой
  5. 5 Расширить обратно до объявленного типа, если вмешался вызов функции или await

Почему тип снова расширяется?

1/3
Викторина

Внутри `setTimeout(() => { /* использование id */ })` ранее суженный `id` снова показан как `string | number`. Почему?

Викторина

Для `v: number | undefined` — почему `if (v) { ... }` рискованная проверка сужения?

Вспомните перед уходом
  1. 01
    Объясни, что вычисляет анализ потока управления и почему у переменной разные типы на разных строках.
  2. 02
    Перечисли пять повседневных операторов сужения и одну подножку для каждого.
  3. 03
    Почему сужение теряется через замыкание или await и как его сохранить?
Итог

Сужение — это анализ потока управления проверяющего в действии: в каждой точке программы он держит самый узкий тип, совместимый с проверками на пути туда, поэтому одна и та же переменная может быть string на одной строке и number на следующей. Повседневные операторы — typeof, истинность, равенство, in, instanceof — каждый расщепляет поток и оставляет совместимые члены, ветка false наследует дополнение, а исчерпанное объединение становится never (основа проверок исчерпания). Ловушки, которые надо усвоить: истинность отбрасывает 0 из числовых объединений, let расширяет литеральные дискриминанты, а замыкание или await расширяют до объявленного типа, если не захватить суженное значение в const заранее. Далее защитники типов позволят вам писать собственные функции-предикаты, участвующие ровно в этой машинерии потока управления. Теперь, когда тип расширится внутри колбэка или после await, сразу напишете const sid = id до границы — и перестанете спорить с компилятором.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources1
expand
  1. 01

Trademarks belong to their respective owners. Editorial reference only.