Раздел 4 · TypeScript

Динамические изменения CRM: путь не доказывает тип

middle-senior~45 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Динамические изменения CRM: путь не доказывает тип

Менеджер добавил человеку тег vip, убрал старый источник обращения и поднял оценку клиента на два пункта. Вместо трёх отдельных методов CRM принимает список изменений:

const changes = [
  { op: 'push', path: 'tags', value: 'vip' },
  { op: 'unset', path: 'custom.source' },
  { op: 'inc', path: 'custom.score', value: 2 },
];

В предыдущем уроке ты связал идентификаторы и поля с доменной моделью. Здесь задача другая: общий механизм изменений не знает, где Person, а где Company. Он видит строковый путь и значение.

Проверить движение по объекту не значит сохранить смысл объекта. Ты можешь безопасно найти поле, записать туда число и при этом сломать контракт Person. В этом уроке отделишь гарантии механизма от гарантий домена.

Примеры восстановлены по разбору лекции, а не скопированы из её репозитория. Реализация исходных помощников в расшифровке не приведена целиком. Поэтому правила изменения ниже выбраны явно: ты можешь проверить их, а не угадывать поведение скрытого кода.

1. Что обещают unknown, object и Record

Представь три коробки с разными надписями:

ТипЧто известноЧего он не обещает
unknownЗначение существует, но его форму надо проверитьЧто это объект, число или массив
objectЗначение не относится к примитивамЧто у него есть произвольное строковое поле
Record<string, unknown>Можно обращаться по строковому ключу, результат требует проверкиЧто ключ существует или значение подходит домену

object включает массивы, функции, даты и экземпляры классов. Это не синоним словаря. При strictNullChecks значения null и undefined в object не входят, хотя JavaScript возвращает 'object' для typeof null.

function inspect(value: unknown) {
  // value.name; // Ошибка: сначала нужна проверка.
}

function inspectObject(value: object) {
  // value['name']; // Ошибка: строковая индексация не обещана.
}

function inspectRecord(value: Record<string, unknown>) {
  const name = value['name']; // unknown, а не string.
  if (typeof name === 'string') {
    return name.toUpperCase();
  }
  return null;
}

unknown заставляет доказать возможность операции. any эту обязанность убирает. Для неизвестного значения из custom нужен первый вариант.

Record<string, unknown> тоже не проверяет объект во время выполнения и не запрещает ему иметь дополнительные символьные свойства. Наш путь состоит из строк, поэтому символы механизм не обрабатывает. Числовые ключи обычного объекта JavaScript становятся строками; из этого не следует поддержка индексов массива.

Если сужение типов пока непривычно, вернись к типам, проверкам и выводу. Здесь оно будет отвечать за каждый следующий шаг пути.

2. Операция должна описывать свою нагрузку

У unset нет нового значения. У inc есть числовая прибавка. set и push пока ничего не знают о типе значения:

type Operation =
  | { op: 'set'; path: string; value: unknown }
  | { op: 'unset'; path: string }
  | { op: 'push'; path: string; value: unknown }
  | { op: 'inc'; path: string; value: number };

Это объединение с дискриминатором op. Когда ты проверишь op === 'unset', TypeScript выберет вариант без value. Когда проверишь op === 'inc', прибавка станет number.

Почему не написать один объект с op: 'set' | 'unset' | 'push' | 'inc' и value?: unknown? Такая запись не связывает вид операции с её данными. Она разрешает inc без прибавки, и каждая ветка вынуждена заново выяснять, что ей передали.

Объявленный тип не превращает тело HTTP-запроса в Operation[]. На внешней границе список ещё надо разобрать, включая имя операции, путь и нагрузку. Об этом урок о проверках во время выполнения и следующий разбор API.

3. Исчерпывающий разбор требует правильного потока управления

Добавь функцию для случая, которого не должно остаться после разбора всех вариантов:

function assertNever(value: never): never {
  throw new Error('Unexpected operation: ' + JSON.stringify(value));
}

Параметр never означает: сюда нельзя передать ни один оставшийся вариант. Возвращаемый never означает: функция не возвращает управление обычным способом.

В лекции добавление assertNever обнаружило проблему с независимыми if. Рассмотри форму кода:

function explain(op: Operation): string {
  let description = '';
  if (op.op === 'set') description = 'Записать';
  if (op.op === 'unset') description = 'Удалить';
  if (op.op === 'push') description = 'Добавить';
  if (op.op === 'inc') description = 'Увеличить';

  // assertNever(op); // Ошибка: выполнение всех веток продолжается сюда.
  return description;
}

Каждая ветка выполнила действие и продолжила работу. Последняя строка не стала недостижимой. Это вопрос анализа потока управления, а не того, что TypeScript «не умеет понимать JavaScript».

Вырази взаимоисключающий выбор через switch с return в каждой ветке или через цепочку if / else if / else. В полной реализации ниже выбран switch. После всех четырёх вариантов в default останется never.

Такая переделка JavaScript при внедрении TypeScript нормальна. Ты уточняешь структуру решения, чтобы намерение читалось и человеком, и анализатором. Но сохранение поведения подтверждают тесты: перестановка веток или замена независимых проверок не всегда равнозначны.

ts-pattern умеет проверять полноту через .exhaustive(), в том числе для вложенных вариантов. Для четырёх операций здесь хватает стандартного switch, без новой зависимости.

4. Проверка должна остановить выполнение

Начни с маленькой функции, которая проверяет условие и сообщает о нём TypeScript:

function assert(condition: unknown, message: string): asserts condition {
  if (!condition) throw new Error(message);
}

asserts condition означает: если функция вернулась, условие истинно. Реализация обязана соответствовать обещанию. Пустая функция с такой сигнатурой обманула бы анализатор.

В Node.js похожую задачу решает import assert from 'node:assert/strict'. В браузерном примере можно оставить функцию выше: ей не нужны модули Node.js.

noUncheckedIndexedAccess не доказывает границы цикла

При включённом noUncheckedIndexedAccess чтение parts[i] имеет тип string | undefined, даже если ты написал i < parts.length. Анализатор не доказывает произвольные отношения между индексом и длиной массива.

const parts = 'custom.score'.split('.');
const key = parts[0];
assert(key !== undefined, 'Missing segment');
// Теперь key: string.

Восклицательный знак parts[0]! только убрал бы undefined из статического типа. Он не сделал бы проверку во время выполнения. Здесь проверка полезнее: при нарушении предположения будет понятная ошибка.

assert(key) дополнительно отвергает пустую строку, потому что она ложная в логическом контексте. Если нужны только проверки наличия, пиши key !== undefined; пустые сегменты пути проверяй отдельно.

Когда нужен каждый элемент массива, for...of убирает индекс и даёт значение напрямую. Когда надо пройти все сегменты, кроме последнего, сначала отдели последний или явно задай границу цикла. Замена такого цикла на перебор всего массива меняет алгоритм.

Результат .at сохрани в переменную

Этот фрагмент не доказывает тип результата второго вызова:

const parts = 'custom.score'.split('.');
assert(parts.at(-1) !== undefined, 'Missing last segment');
// const key: string = parts.at(-1); // Ошибка: string | undefined.

TypeScript не переносит проверку одного вызова на другой. В общем случае метод может зависеть от изменяемого состояния или иметь другую реализацию. Для pop() два последовательных вызова даже у обычного массива возвращают разные элементы.

Проверь одно конкретное значение:

const last = parts.at(-1);
assert(last !== undefined, 'Missing last segment');
const key: string = last;

Это сужение локальной переменной, а не доказательство чистоты метода. Array.prototype.at доступен при соответствующей библиотеке типов, например ES2022, и поддержке среды выполнения.

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.