Динамические изменения 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.