Discriminated unions

В Superstruct объединения (union) используются для описания значений, которые могут соответствовать одному из нескольких структурных типов. Однако при работе со сложными объектами возникает необходимость однозначно определять, к какому варианту относится конкретный объект. Для этого применяется подход, известный как discriminated unions — дискриминируемые объединения.

Ключевая идея заключается в наличии общего поля-дискриминатора, которое однозначно указывает на конкретную ветку структуры.


Базовая модель union в Superstruct

В библиотеке Superstruct объединение строится с помощью функции union, которая принимает массив структур:

import { union, string, number, object } from 'superstruct';

const StringOrNumber = union([string(), number()]);

Такое определение позволяет значению быть либо строкой, либо числом. Проверка происходит последовательно: каждый структурный вариант тестируется до первого совпадения.

Особенность такого подхода — отсутствие контекста выбора ветки. Каждый вариант проверяется независимо, что при сложных объектах может снижать эффективность и усложнять диагностику ошибок.


Принцип дискриминируемого объединения

Дискриминируемое объединение вводит явный маркер типа. Обычно это строковое поле, например type, kind или tag.

Структура каждого варианта содержит фиксированное значение дискриминатора:

import { object, string, number, literal } from 'superstruct';

const Cat = object({
  type: literal('cat'),
  meows: number(),
});

const Dog = object({
  type: literal('dog'),
  barks: number(),
});

Здесь поле type выполняет роль дискриминатора. Оно позволяет однозначно определить структуру без попытки проверки всех вариантов.


Построение discriminated union через union

Дискриминируемое объединение в Superstruct формируется через обычный union, где каждый вариант уже содержит уникальный литерал-дискриминатор:

import { union } from 'superstruct';

const Animal = union([Cat, Dog]);

При валидации происходит следующее:

  1. Сначала проверяется поле type.
  2. Значение дискриминатора сопоставляется с возможными литералами.
  3. Выбирается соответствующая структура.
  4. Выполняется проверка оставшихся полей.

Такой подход уменьшает количество проверок и повышает предсказуемость ошибок.


Роль literal в дискриминации

Функция literal играет ключевую роль, фиксируя значение поля:

import { literal } from 'superstruct';

literal('cat')

В отличие от string(), который допускает любые строки, literal ограничивает значение строго заданной константой. Это создаёт стабильный маркер, на который опирается механизм выбора ветки.


Валидация и выбор структуры

При вызове validate, assert или create происходит автоматический выбор подходящей ветки:

import { validate } from 'superstruct';

const value = {
  type: 'dog',
  barks: 3,
};

const [error, result] = validate(value, Animal);

Алгоритм работы:

  • извлекается поле-дискриминатор;
  • сопоставляется с возможными literal значениями;
  • выполняется проверка только выбранной структуры.

Если дискриминатор отсутствует или не совпадает ни с одной веткой, возвращается ошибка валидации.


Типизация веток и строгая структура

Каждая ветка в discriminated union должна иметь:

  • обязательное поле-дискриминатор;
  • уникальное значение этого поля;
  • согласованную структуру остальных полей.

Пример расширенного случая:

const Shape = union([
  object({
    kind: literal('circle'),
    radius: number(),
  }),
  object({
    kind: literal('rectangle'),
    width: number(),
    height: number(),
  }),
  object({
    kind: literal('square'),
    size: number(),
  }),
]);

Такое представление позволяет формировать строго типизированные и легко расширяемые модели данных.


Вложенные discriminated unions

Дискриминируемые объединения могут быть вложенными. Это используется для моделирования сложных доменных структур.

const Event = union([
  object({
    type: literal('mouse'),
    action: union([
      object({
        subtype: literal('click'),
        x: number(),
        y: number(),
      }),
      object({
        subtype: literal('move'),
        x: number(),
        y: number(),
      }),
    ]),
  }),
]);

Здесь происходит многоуровневая дискриминация: сначала по type, затем по subtype.


Ошибки проектирования дискриминаторов

На практике часто встречаются ошибки, связанные с нарушением структуры дискриминируемых объединений:

1. Отсутствие уникального значения

Если два варианта используют одинаковый literal, выбор ветки становится неоднозначным.

2. Использование изменяемого поля

Дискриминатор должен быть неизменяемым значением. Использование вычисляемых или опциональных значений приводит к нестабильной валидации.

3. Несогласованность структуры

Если ветки имеют разную семантику одного и того же поля, например type, но с разным смыслом, модель становится трудно поддерживаемой.


Производительность и поведение union

Discriminated unions в Superstruct позволяют сократить количество проверок за счёт раннего выбора ветки. Вместо перебора всех структур используется логика:

  • O(1) определение кандидата по discriminator;
  • валидация только одной структуры;
  • отказ от полного перебора при корректных данных.

Это особенно важно при больших схемах с десятками вариантов.


Композиция и переиспользование структур

Ветви discriminated union могут строиться из переиспользуемых структурных блоков:

const Timestamped = object({
  createdAt: string(),
});

const UserEvent = object({
  type: literal('user_event'),
  userId: number(),
  ...Timestamped.schema,
});

Такой подход уменьшает дублирование и делает структуру более модульной, сохраняя при этом дискриминацию на уровне верхнего поля.


Проверка корректности данных на границе системы

Дискриминируемые объединения часто применяются при обработке внешних данных:

  • API ответы;
  • сообщения очередей;
  • события;
  • конфигурации.

Типичный сценарий:

const Message = union([
  object({
    kind: literal('text'),
    content: string(),
  }),
  object({
    kind: literal('image'),
    url: string(),
  }),
]);

Каждое сообщение однозначно классифицируется, что упрощает обработку на уровне бизнес-логики.


Расширение существующих объединений

Добавление новых вариантов не требует изменения существующих структур. Достаточно добавить новую ветку с уникальным discriminator:

const ExtendedMessage = union([
  ...Message.types,
  object({
    kind: literal('video'),
    url: string(),
  }),
]);

Такая модель хорошо масштабируется при росте системы.