Результат операции и ошибка
Состояние интерфейса и результат операции решают разные задачи. Каталог может находиться в loading, пока функция ещё работает. Когда чтение завершилось, вызывающая сторона получает успешные данные или причину отказа. Для этого не всегда удобно опираться только на выброшенное исключение и помнить, где обязательно расположен catch.
Опишем Result<T, E>: успешный вариант содержит значение типа T, неуспешный — ошибку типа E. Применим его к синхронному чтению JSON-каталога. Разделим неверный синтаксис текста и неверную форму разобранных данных, сохранив runtime-парсер шестого урока. Асинхронную операцию не вводим: это отдельный запланированный результат.
Успех и ошибка имеют разные поля
Поле ok будет общим дискриминатором. При true присутствует value, при false — error. Нельзя просто объявить оба поля необязательными: такая форма разрешила бы пустой результат или одновременно данные и ошибку. Нам нужен конечный договор двух взаимоисключающих вариантов.
В CatalogError тоже есть дискриминатор. Значение syntax означает, что текст не удалось разобрать как JSON; schema означает, что полученное значение не удовлетворяет принятой форме каталога. Для обоих вариантов есть сообщение, но их причины и последующие решения могут различаться.
Полный src/app.ts снимка lesson-16:
import { courses } from './data.js';
import { parseCourses } from './model.js';
import type { Course } from './model.js';
type Result<T, E> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: E };
type CatalogError =
| { readonly kind: 'syntax'; readonly message: string }
| { readonly kind: 'schema'; readonly message: string };
function readCatalog(source: string): Result<readonly Course[], CatalogError> {
let external: unknown;
try {
external = JSON.parse(source);
} catch {
return { ok: false, error: { kind: 'syntax', message: 'Текст не является JSON' } };
}
try {
return { ok: true, value: parseCourses(external) };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Неизвестная ошибка схемы';
return { ok: false, error: { kind: 'schema', message } };
}
}
function describe(result: Result<readonly Course[], CatalogError>): string {
if (result.ok) return `Принято курсов: ${result.value.length}`;
return `${result.error.kind}: ${result.error.message}`;
}
console.log(describe(readCatalog(JSON.stringify(courses))));
console.log(describe(readCatalog('{')));
console.log(describe(readCatalog('[]')));
console.log(describe(readCatalog('[{"id":"broken"}]')));
Ожидаемые строки:
Принято курсов: 4
syntax: Текст не является JSON
Принято курсов: 0
schema: Некорректные поля курса
Первая операция получает сериализованные локальные данные. Вторая получает незавершённый объект. Третья получает корректный пустой массив. Четвёртая получает массив с неполной записью. Каждая строка соответствует определённой границе чтения, а не общему сообщению «что-то сломалось».
Две границы чтения
Первый try относится только к JSON.parse. Если разбор не завершился, возвращается ошибка синтаксиса и проверка схемы не вызывается. Если разбор успешен, значение записано как unknown: правильный JSON по-прежнему может быть числом, объектом или массивом неверных записей.
Второй try вызывает parseCourses. Только успешный возврат парсера становится value. Поэтому Result не обходит проверку данных: он меняет способ передачи её результата вызывающей стороне. Все проверки положительных чисел, тем, URL и уникальности id остаются в model.ts.
Общий catch вокруг парсера превращает любые выброшенные им ошибки в schema. Для этого маленького примера парсер имеет ограниченную область. В большом проекте полезно различать ожидаемый отказ схемы и неожиданную ошибку реализации отдельными классами или более точной внутренней границей. Нельзя автоматически относить любой баг программы к неверным данным пользователя.
Синтаксически правильный [] принимается успешно. Это выбранная политика источника: отсутствие курсов не нарушает форму массива. Если продукт требует хотя бы один материал, нужно добавить это реальное правило в парсер и изменить объяснение. Тип readonly Course[] сам минимальную длину не устанавливает.
Вызывающая сторона выбирает реакцию
В describe проверяется result.ok. В успешной ветке можно читать value.length; в неуспешной — error.kind и error.message. Сужение происходит по точному логическому литералу, а не по фактическому наличию произвольного поля. Это делает договор результата понятным даже без чтения парсера.
Если результат передать модели состояния предыдущего урока, вызывающая сторона должна выбрать соответствующее событие: успех даст loaded, отказ — rejected. Result не меняет интерфейс автоматически и не знает, актуальна ли попытка загрузки. Такое соединение требуется проектировать отдельно, особенно когда появится Promise.
Рассмотрим небольшой самостоятельный фрагмент после функции readCatalog:
const result = readCatalog('[{"id":"broken"}]');
if (!result.ok) {
const advice = result.error.kind === 'syntax'
? 'Исправьте синтаксис JSON'
: 'Проверьте обязательные поля и значения';
console.log(advice);
}
Ожидается предложение проверить обязательные поля и значения. У текста правильные скобки, поэтому исправлять только синтаксис бессмысленно. В реальном редакторском интерфейсе такая разница помогает дать человеку следующий конкретный шаг. Однако сырое внутреннее сообщение не всегда следует показывать посетителю без обработки.
Что гарантирует Result
Параметры T и E сохраняют связи успешного значения и ошибки. Другой модуль может использовать тот же общий тип для сохранения настроек или поиска записи, выбрав собственные частные типы. Это не означает, что все операции должны возвращать одинаковые тексты ошибок или одинаковые данные.
Result не является встроенной конструкцией JavaScript. Здесь это обычное объявление типа и обычные объектные литералы. Поля в памяти существуют потому, что функции их создают, а не потому, что имя типа что-то исполняет. После компиляции остаются объектные операции и условия, а параметров типа в браузере нет.
Объединение также не предотвращает любые исключения. Внешние функции, непредусмотренные ветки или ошибка доступа к DOM могут всё ещё бросить значение. Обещание «все ожидаемые отказы этой операции возвращаются в Result» требует реализации соответствующих границ, а не одного изменения сигнатуры.
В нашем случае readCatalog сама не меняет исходный массив и не записывает файлы. Она получает текст и возвращает новый результат. Поэтому вызывающая сторона может читать несколько строк независимо, сравнивая причины отказа. Никакое успешное чтение не означает автоматическую публикацию материалов или сохранение данных в каталоге.
Ошибка операции и подробность сообщения
Отказ схемы может быть ожидаемым результатом работы редактора: человек передал запись без названия. Ошибка реализации, напротив, означает, что сама программа нарушила договор. При расширении парсера полезно различить эти случаи, чтобы не сообщать посетителю «проверьте данные» при внутреннем баге. Текущий общий catch вокруг малого парсера является ограничением примера, а не универсальным правилом любого сервиса.
Тип ошибки также не должен содержать секретные сведения только ради удобства диагностики. Например, сырые ответы сервера или внутренние пути можно сохранять отдельно для разрешённого журнала, а пользовательскому сообщению дать понятную причину. Наш fixture содержит лишь учебные строки и не выполняет отправку сообщений или телеметрию.
Проверка result.ok не означает, что исходные данные уже опубликованы. Она говорит только об успешном чтении по схеме. Сохранение, регистрация нового адреса и вывод в интерфейсе — отдельные операции, каждая со своими результатами. Хорошая модель не объединяет их в один неясный успех, иначе вызывающий код начнёт ожидать скрытые действия.
Представьте, что операция читает пустой массив и возвращает успешный Result. Вызывающий интерфейс может показать «Материалов пока нет», сохранив смысл успешного ответа. Это лучше, чем объявить отсутствие данных сетевой ошибкой без оснований. Если же каталог обязан содержать запись, ограничение минимальной длины должно находиться в схеме, а не появляться случайно в функции описания результата.
Для будущего Promise та же форма Result сможет стать значением асинхронного завершения. Но ошибки ожидания и непредвиденные исключения всё равно требуют отдельной границы обработки. Мы не создаём несуществующую гарантию, что любой async-код автоматически перестанет бросать ошибки после добавления обобщённого имени типа.
Принцип дискриминируемых объединений объясняется в Handbook. Собственная форма Result соединяет уже изученные обобщения и сужение в практическую операцию. Исходники при подготовке не запускались; показанные четыре строки являются ожидаемыми по принятой схеме.
На этом готова первая часть типизированной модели: от исходной формы курса до проверенного результата операции. Дальше планируется работа с Promise, DOM, формами и настоящей API-границей. Эти будущие уроки пока не созданы, поэтому продолжение не оформлено неработающей ссылкой. При самостоятельном чтении начните с ошибки схемы и проследите путь: строка, unknown, проверка полей, неуспешный Result, выбранная реакция вызывающей стороны.