Состояния интерфейса как типы
Каталог может ещё не запрашиваться, загружаться, быть готовым или показывать ошибку. Если хранить независимые флаги loading, hasError и необязательный массив, программа легко получит противоречивую комбинацию. Например, загрузка завершена, ошибка объявлена, а данные одновременно считаются успешными.
Опишем состояние как объединение осмысленных вариантов. Готовая ветка будет содержать курсы, ошибочная — сообщение, а начальная и загрузочная не получат лишних полей. Затем введём явную функцию переходов. В этом уроке загрузка моделируется синхронными событиями; настоящий запрос и Promise остаются будущей части курса, поэтому мы не изображаем временное поведение сети.
Поля принадлежат варианту состояния
В CatalogState общее поле kind различает четыре варианта. Оно является дискриминатором: сравнение его значения даёт информацию о доступных остальных полях. В состоянии ready можно читать courses, в состоянии failed — message. Отдельное необязательное поле для каждого возможного случая не требуется.
CatalogEvent описывает команды перехода: начать загрузку, сообщить загруженные курсы или сообщить отказ. Состояние и событие нельзя считать одним объектом: первое говорит, что сейчас отображается, второе — что произошло и какие данные принесло действие.
Полный src/app.ts снимка lesson-15:
import { courses } from './data.js';
import type { Course } from './model.js';
type CatalogState =
| { readonly kind: 'idle' }
| { readonly kind: 'loading' }
| { readonly kind: 'ready'; readonly courses: readonly Course[] }
| { readonly kind: 'failed'; readonly message: string };
type CatalogEvent =
| { readonly type: 'start' }
| { readonly type: 'loaded'; readonly courses: readonly Course[] }
| { readonly type: 'rejected'; readonly message: string };
function unreachable(value: never): never {
throw new Error(`Неизвестный вариант: ${String(value)}`);
}
function transition(state: CatalogState, event: CatalogEvent): CatalogState {
switch (event.type) {
case 'start': return { kind: 'loading' };
case 'loaded': return state.kind === 'loading' ? { kind: 'ready', courses: event.courses } : state;
case 'rejected': return state.kind === 'loading' ? { kind: 'failed', message: event.message } : state;
default: return unreachable(event);
}
}
function describeState(state: CatalogState): string {
switch (state.kind) {
case 'idle': return 'Каталог ещё не запрошен';
case 'loading': return 'Загрузка';
case 'ready': return `Готово курсов: ${state.courses.length}`;
case 'failed': return `Ошибка: ${state.message}`;
default: return unreachable(state);
}
}
let state: CatalogState = { kind: 'idle' };
console.log(describeState(state));
state = transition(state, { type: 'start' });
console.log(describeState(state));
state = transition(state, { type: 'loaded', courses });
console.log(describeState(state));
Ожидаемые строки:
Каталог ещё не запрошен
Загрузка
Готово курсов: 4
Данные не загружаются по HTTP. Событие loaded получает прежний локальный массив после явного start. Между вызовами нет задержки. Так можно прочитать договор переходов, не приписывая примеру замер или реальное сетевое состояние.
Тип формы и правило перехода
Само объединение исключает часть бессмысленных объектных форм. Но оно не запрещает написать событие loaded раньше события start. Типы отдельных аргументов не знают истории исполнения. Поэтому transition содержит обычное условие: успех и отказ принимаются только из loading, в остальных случаях возвращается прежнее состояние.
Событие start переводит любой текущий вариант в загрузку. Это наша политика повторной попытки. В учебной модели прежние данные при таком переходе не сохраняются; мы не выдаём это за обязательное поведение каждого интерфейса. Продукт мог бы показывать прежний результат во время обновления, но тогда нужна отдельная форма состояния и понятная подпись.
Для самостоятельного чтения добавьте после основного сценария фрагмент:
state = transition(state, { type: 'start' });
state = transition(state, { type: 'rejected', message: 'Источник недоступен' });
console.log(describeState(state));
state = transition(state, { type: 'loaded', courses });
console.log(describeState(state));
Ожидаются два одинаковых сообщения ошибки. После отказа событие успеха без новой загрузки игнорируется текущим правилом. Это не универсальная защита от гонок запросов: нет идентификатора конкретной попытки, а в реальном асинхронном интерфейсе старый ответ может попасть в новую загрузку. Такой сценарий потребовал бы дополнительного договора актуальности.
Исчерпывающий разбор и never
describeState перечисляет все варианты. После рассмотрения idle, loading, ready и failed по текущему договору не должно остаться допустимого значения. Функция unreachable принимает never: тип ситуации, в которой корректный статический разбор не оставил ни одного варианта.
Если добавить новое состояние, например empty, но не обработать его в switch, последняя ветка уже получит реальное допустимое значение, которое не подходит never. Это помогает заметить забытое обновление. Мы не приводим номер диагностики как полученный результат: компилятор при подготовке не запускался.
Runtime-ошибка в unreachable остаётся на случай, если статический договор был обойдён или внешний объект оказался непроверенным. Само имя функции не доказывает невозможность. Типовое исчерпание относится к известным исходникам, а произвольные внешние данные требуют проверки до передачи в автомат.
Не следует заменить последнюю ветку строкой «Неизвестно» только ради отсутствия ошибок инструмента. Такое решение иногда является нужной продуктовой политикой, но тогда оно скрывает пропущенный известный вариант. Внутренний конечный набор полезно рассматривать явно, особенно когда новая ветка требует другого набора элементов интерфейса.
Готовый пустой каталог
Массив в ready может быть пустым. Это успех с нулём записей, а не обязательно ошибка. В текущем describeState ожидается «Готово курсов: 0». Если интерфейс должен отдельно объяснять отсутствие материалов, можно сделать это внутри готовой ветки по длине или ввести дополнительное состояние осознанно.
Пустота после фильтра и отсутствие данных источника тоже различаются по смыслу, хотя обе выражаются пустым массивом. Полноценное приложение может хранить исходный набор, фильтр и производное представление отдельно. Этот урок показывает модель загрузки, поэтому не объединяет в один автомат все возможные задачи интерфейса.
Readonly в объявлениях состояний выражает, что переход создаёт новые значения вместо записи полей существующего объекта. Это делает функцию легче для чтения: входное состояние не перестраивается на месте. Но массив внутри готового варианта всё ещё разделяет ссылки на объекты курсов, а физическое замораживание не выполняется.
Повторная попытка и старый ответ
Рассмотрим последовательность: первая загрузка началась, затем пользователь начал вторую, после чего пришёл успех первой. В текущей модели состояние просто loading; номера попытки нет. Событие loaded будет принято, потому что условие проверяет только вид состояния. Следовательно, модель не решает гонки автоматически, и это нужно прямо понимать до соединения с HTTP.
Одно возможное расширение — хранить id попытки в загрузочном варианте и в событии результата. Тогда функция перехода сможет сравнить их. Другое — отменять прежний запрос и отдельно всё равно проверять актуальность результата. Конкретную стратегию следует вводить вместе с асинхронной реализацией, а не обещать её одним названием «автомат состояний».
Условия показа элементов также можно вывести из текущего варианта. Например, сообщение загрузки требуется в loading, данные списка — в ready, кнопка повторной попытки может оставаться доступной после failed. Но сами DOM-элементы не создаются типом состояния. Функция представления должна прочитать вариант и обновить интерфейс, сохраняя доступность сообщений.
Для редакторского приложения может понадобиться готовое состояние с устаревшими данными во время обновления. Это отдельная осмысленная комбинация, которую следует назвать и описать полями. Цель объединения не запретить все сложные состояния, а исключить случайные сочетания независимых флагов. Если сочетание действительно нужно продукту, добавьте его явно и обновите исчерпывающий разбор.
Наконец, неизменность входного объекта облегчает историю переходов только при сохранении устойчивых данных. Если курсы меняются через другой alias, сохранённое прежнее состояние может показывать новые поля. Поэтому функция перехода и управление владением моделью остаются взаимосвязанными, но разными решениями. Наш синхронный сценарий фиксирован и демонстрирует только формы и правила принятия событий.
Дискриминируемые объединения и исчерпывающий разбор описаны в Narrowing. В нашей модели главный результат — невозможность безусловно прочитать данные из загрузки и явные правила принятия событий. Первая последовательность даёт три ожидаемых сообщения, второй сценарий показывает политику отказа.
Исходник не исполнялся при подготовке. Перед реальным асинхронным использованием нужно отдельно спроектировать актуальность попыток и удержание данных при обновлении. В следующем уроке отделим состояние всего интерфейса от результата одной операции чтения: она будет возвращать либо проверенные курсы, либо описанную ошибку.