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

Пользовательские защитники типов

Пользовательские защитники типов возвращают `x is T`. Проверяющий доверяет предикату несоундно — лгущий защитник компилируется и портит всё сужение ниже по коду.

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

В вашей общей библиотеке появляется хелпер isUser(x): x is User. Каждое место вызова, прошедшее его проверку, трактует значение как полностью типизированный User — читает .email, .roles, .id. Спустя месяцы — падение в проде: Cannot read properties of undefined (reading 'toLowerCase'). Тело защитника проверяло лишь typeof x === "object". Оно солгало. TypeScript ему поверил, сузил тысячи мест вызова до User и никого не предупредил. Предикат типа — это обещание, которое проверяющий принимает на веру, а нарушенное обещание невидимо до рантайма.

Что такое предикат типа

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

Обычная функция, возвращающая boolean, ничего не сужает для вызывающего — проверяющий не знает, что означает результат true. Аннотация возврата предиката типа, arg is T, говорит проверяющему: «когда вернётся true, считай arg типом T; когда false — вычти T».

function isString(x: unknown): x is string {
  return typeof x === "string";
}

function handle(v: string | number) {
  if (isString(v)) {
    v;
    // ^? string   — предикат сузил, точь-в-точь как встроенная проверка
    return v.toUpperCase();
  }
  v;
  // ^? number    — ветка false вычитает string
}

Параметр предиката (x / именованный аргумент) должен быть одним из параметров функции или this. Тип возврата x is T заменяет обычный boolean — в рантайме функция всё равно просто возвращает булево значение.

Проверяющий доверяет вам несоундно

Это надо усвоить: TypeScript не проверяет, что тело предиката действительно доказывает заявленный тип. Аннотация is T — это ваше утверждение; проверяющий принимает его на веру. Защитник, чьё тело не соответствует его предикату, компилируется без ошибок:

type User = { id: string; email: string; roles: string[] };

// ЛГУЩИЙ защитник — тело проверяет куда меньше, чем заявляет предикат
function isUser(x: unknown): x is User {
  return typeof x === "object" && x !== null;
}

const maybe: unknown = { id: "u1" };   // ни email, ни roles
if (isUser(maybe)) {
  maybe.email.toLowerCase();
  // ^? проверяющий думает, что maybe: User, email: string
  // Рантайм: TypeError — email это undefined
}

Никакой ошибки на этапе компиляции. Предикат — контракт, за соблюдение которого отвечаете только вы. Корректный защитник проверяет каждое поле, обещанное предикатом:

function isUser(x: unknown): x is User {
  return (
    typeof x === "object" && x !== null &&
    "id" in x && typeof (x as Record<string, unknown>).id === "string" &&
    "email" in x && typeof (x as Record<string, unknown>).email === "string" &&
    "roles" in x && Array.isArray((x as Record<string, unknown>).roles)
  );
}
Почему это работает

Почему TypeScript вообще допускает эту несоундность? Потому что валидация в рантайме в общем случае неразрешима — проверяющий не может статически доказать, что произвольное булево выражение устанавливает структурный тип. Поэтому он проводит намеренную границу: проверяет ваше использование предиката (сужение), но делегирует корректность тела предиката вам. Это та же модель доверия, что и у as-утверждений, просто упакованная в переиспользуемую функцию. Дисциплина, которая это чинит: выводить защитники из единой схемы (Zod z.infer + safeParse, io-ts, Valibot), чтобы валидатор и тип не разошлись.

Защитники на основе in и различение без тега

Когда у членов нет общего литерального тега, проверка in внутри предиката сужает структурно:

type WithData = { data: string };
type WithError = { error: string };

function hasData(r: WithData | WithError): r is WithData {
  return "data" in r;
}

Это работает, но хрупко: если оба члена могли бы нести ключ data (пусть даже опционально), тест in больше не различает их. Когда вы владеете формами, предпочитайте явный тег-дискриминант — это следующий урок.

Защитники для массивов и записей

Частая задача — сузить unknown (например, JSON) до типизированного массива. Предикат должен проверить и контейнер, и выборку элементов — но проверка лишь одного элемента сама по себе несоундный срез:

function isStringArray(x: unknown): x is string[] {
  return Array.isArray(x) && x.every((e) => typeof e === "string");
}

function isRecordOfNumbers(x: unknown): x is Record<string, number> {
  return (
    typeof x === "object" && x !== null && !Array.isArray(x) &&
    Object.values(x).every((v) => typeof v === "number")
  );
}

x.every(...) по всему массиву честен; typeof x[0] === "string" (выборка одного элемента) — ложь, которой проверяющий всё равно верит. Стоимость every реальна для огромных массивов — именно поэтому разобрать-один-раз-на-границе (валидировать payload при входе в систему) лучше, чем перепроверять защитниками повсюду.

Закончи аналогию

Предикат типа — как вышибала, ставящий браслет «проверенный User». Проверяющий никогда не перепроверяет браслет — поэтому если вышибала ___, все внутри типизированы неверно.

Взгляд вперёд: функции-утверждения

Близкий родственник бросает исключение вместо возврата булева значения. Функция-утверждение использует asserts x is T: если она вернулась нормально, проверяющий сужает x до T на остаток области видимости — без if:

function assertUser(x: unknown): asserts x is User {
  if (!isUser(x)) throw new Error("not a User");
}

function f(x: unknown) {
  assertUser(x);
  x;
  // ^? User   — сужено отсюда и дальше, ведь управление продолжилось, только если не было броска
}

Действует то же несоундное доверие плюс обязанность реально бросить на отрицательном случае. Функции-утверждения вы изучите глубже в юните 06 (functions-this).

Найди лгущий защитник

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

Защитнику `isUser(x): x is User`, чьё тело проверяет лишь `typeof x === 'object'`, передан объект без обязательных полей. Что произойдёт?

Викторина

Какое тело предиката честно доказывает `x is string[]`?

Вспомните перед уходом
  1. 01
    Что делает аннотация возврата `x is T`, чего не делает обычный возврат `boolean`?
  2. 02
    Объясни, почему лгущий защитник типа опасен и как возникает несоундность.
  3. 03
    При структурном сужении почему предпочесть тег-дискриминант защитнику на `in` и каков безопасный паттерн защитника массива?
Итог

Пользовательские защитники типов распространяют машинерию сужения из прошлого урока на ваши собственные функции: аннотация возврата x is T заставляет проверяющего сузить аргумент до T на ветке true и вычесть его на ветке false. Ключевое свойство — это доверие несоундно: TypeScript никогда не проверяет, что тело предиката доказывает заявленный тип, поэтому неполный или лгущий защитник компилируется чисто и молча портит каждое место вызова, всплывая лишь как падение в рантайме. Честные защитники проверяют каждое обещанное поле, используют every по всему массиву, а не выборку, и предпочитают явные теги-дискриминанты тестам in, когда вы владеете формами; для продакшен-безопасности выводите защитники из схемы, чтобы валидатор и тип не разошлись. Бросающий вариант, asserts x is T, показан здесь и детально разбирается в юните 06. Далее размеченные объединения превращают хрупкое сужение через in/предикат в надёжный паттерн на тегах с проверкой исчерпания. Теперь, когда встречаешь защитник x is T на ревью, сразу проверяешь его тело поле за полем — потому что компилятор этого за тебя не сделает.

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.