Учебник веб-разработки
Разделы учебника
На этой странице

II–III. JS, TypeScript и модели данных

Модели данных: 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.