Модели данных: ADT и runtime-валидация
Оглавление · Типы · FP
Задача: выразить допустимые варианты данных и разобрать внешний вход до использования в приложении. Нужны union-типы, сужение и различие типа и значения. Здесь ADT означает algebraic data types — алгебраические типы данных. Не путайте с abstract data type: это другой смысл той же аббревиатуры.
Произведение и размеченная сумма
Объект с полями title и location требует оба значения: модель похожа на
произведение Title × Location. Если множества конечны, число комбинаций —
|Title| · |Location|. Это модель полей, а не точная семантика всех объектов JS.
Сумма выбирает один из вариантов. Метка позволяет их различить, даже если остальные поля похожи:
type Outcome<T> = { kind: "success"; data: T } | { kind: "failure"; message: string };
function describe(result: Outcome<string[]>): string {
if (result.kind === "failure") return result.message;
return `Получено: ${result.data.length}`;
}
Для модели конечных множеств размеченная сумма имеет
|Success| + |Failure| значений. Метки делают варианты непересекающимися.
В ветке failure поля data нет; TypeScript требует сначала различить вариант.
Исходник схемы
flowchart TD Product["Событие: заголовок И площадка"] --> Title["title: string"] Product --> Location["location: string"] Sum["Результат: успех ИЛИ ошибка"] --> Success["kind=success, data"] Sum --> Failure["kind=failure, message"]
Стрелки здесь показывают состав модели. Для произведения нужны обе части, для суммы выбирается одна ветка. Это не схема движения данных.
Состояние запроса без противоречивых флагов
loading: boolean, error?: string, data?: T допускают сочетание «загрузка,
ошибка и новые данные одновременно», даже если интерфейс такого не понимает.
Вместо независимых флагов зададим учебную сумму:
type LoadState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "failure"; message: string };
function unreachable(value: never): never {
throw new Error("Unexpected state");
}
function stateLabel(state: LoadState<string[]>): string {
switch (state.status) {
case "idle":
return "Ещё не загружено";
case "loading":
return "Загрузка";
case "success":
return `Событий: ${state.data.length}`;
case "failure":
return state.message;
default:
return unreachable(state);
}
}
После обработки всех вариантов state в последней ветке имеет тип never.
Добавление нового варианта требует обновить разбор; иначе аргумент уже
не совместим с never. Это проверка исчерпывающего разбора известных типов,
а не защита от непроверенного JSON. Discriminated unions и never.
Модель намеренно не хранит старые данные при обновлении, не представляет отмену и параллельные попытки. Если они нужны, вводим варианты и правила переходов явно. Непредставимость некоторых плохих состояний ещё не гарантирует допустимость всех переходов; reducer и автомат — следующая глава программы.
Внешнее значение надо разобрать
JSON описывает формат передачи, но не гарантирует поля предметной модели.
Разбор состоит из проверки и, если нужно, преобразования. Математически удобно
писать parse: Unknown → Success(Data) + Failure(Issues), где Unknown —
все внешние значения, а не утверждение об их форме.
Учебный пример использует установленный в проекте Zod 4:
import { z } from "zod";
const lessonEventSchema = z.object({
title: z.string().trim().min(1),
location: z.string().trim().min(1),
});
type LessonEvent = z.infer<typeof lessonEventSchema>;
const raw: unknown = { title: " Открытие ", location: "Площадь" };
const result = lessonEventSchema.safeParse(raw);
if (result.success) {
console.log(result.data.title); // "Открытие"
} else {
console.log(result.error.issues);
}
parse возвращает разобранное значение или бросает ошибку; safeParse
возвращает размеченный результат без исключения при несоответствии схемы.
Работаем с result.data, а не с исходным raw. Схема может нормализовать данные:
здесь trim меняет строку до проверки длины.
Разбор Zod.
По умолчанию z.object удаляет неизвестные ключи из результата разбора.
z.strictObject отвергает их, .passthrough() сохраняет. Это разные политики
совместимости. Выбирайте их осознанно, не считая удаление поля доказательством
ошибки отправителя. При трансформациях типы z.input и z.output могут
различаться; z.infer описывает выход. Схемы объектов.
Исходник схемы
flowchart LR Raw["unknown"] --> Parse["Проверка и нормализация"] Parse --> Good["success: проверенные данные"] Parse --> Bad["failure: issues"] Good --> Use["Преобразование / интерфейс"]
Стрелки показывают два исхода разбора. Ошибка не идёт дальше как допустимая модель. Как показать её пользователю или превратить в HTTP-ответ — ответственность границы приложения, а не самой схемы.
Форма, смысл и полномочия
| Уровень | Что проверяет | Чего не подтверждает |
|---|---|---|
| Форма | Наличие строк, число, варианты event | Существование записи в БД |
| Инвариант | Конец не раньше начала | Право редактировать событие |
| Политика доступа | Кто может выполнить операцию | Корректность её полей |
| Согласованность записи | Уникальность и связи при сохранении | Успешную доставку ответа клиенту |
Например, endsAt и startsAt могут быть корректными ISO-строками, но описывать
отрицательную длительность. Для этого нужна отдельная проверка отношения.
Уникальность при конкурентных запросах нельзя обеспечить одной локальной
проверкой перед записью: она требует механизма хранения. Для HTTP webhook
валидный payload также не заменяет аутентификацию.
Реальные контракты Atmanki
Webhook-схема различает entry.*,
media.* и trigger-test по event. Обработчик
POST /api/webhooks/strapi
сначала проверяет Bearer-секрет, затем JSON и схему. Неуспешный разбор даёт 400;
trigger-test не инвалидирует кеш. Схема разрешает непустую строку model,
а не только news | event: это реальная граница текущего контракта.
В контрактах контента
eventItemSchema проверяет формат дат, но пока не проверяет их порядок.
mediaUrlSchema проверяет форму ссылки; разрешённый origin отдельно проверяет
CMS-адаптер. Нельзя обещать проверку,
которой нет в коде.
Там же blockSchema — рекурсивная runtime-сумма по type, но публичный
BlockNode объявлен шире: type: string и много необязательных полей.
Например, тип допускает { type: "heading" }, а схема требует level и children.
Из-за явной аннотации z.ZodType<BlockNode> вывод тоже не восстанавливает
узкую сумму. Это ограничение текущего объявления, не свойство всех ADT.
Практика и самопроверка
В отдельном временном учебном файле apps/docs/src/lesson.ts создайте
LoadState<string[]> и stateLabel. Добавьте вариант cancelled и запустите
pnpm --filter @atmanki/docs typecheck: неполный разбор должен дать ошибку.
Добавьте ветку, повторите проверку и удалите временный файл.
Для runtime-примера используйте временный apps/web/src/lesson.mts: Zod уже
доступен этому пакету. Запустите его из каталога apps/web командой
node src/lesson.mts (обычный TS с удаляемыми аннотациями, без JSX). .mts явно задаёт ESM-модуль. Node при таком запуске не проверяет
типы и не читает tsconfig; ограничения встроенного запуска TS.
Сравните разбор корректного объекта, заголовка из пробелов, числа вместо
location и лишнего поля. Для каждого случая предскажите исход и значение
result.data, если оно есть. Удалите файл после упражнения.
Затем добавьте в отдельную учебную схему проверку порядка двух дат. Это упражнение не меняет production-контракт. Объясните, почему успешный разбор данных не подтверждает права пользователя и наличие записи в CMS.