Union

Валидация данных редко ограничивается единственным допустимым типом. На практике поля часто могут принимать несколько различных форм: строку или число, объект одного из нескольких типов, либо одну из заранее заданных структур. Для описания таких сценариев в Superstruct используется комбинатор union, позволяющий задать набор альтернативных структур, из которых хотя бы одна должна быть валидной.

Базовая идея union

union объединяет несколько структур в одну логическую проверку. Значение считается корректным, если оно соответствует хотя бы одной из заданных структур.

Формально:

  • есть набор структур S1, S2, S3...
  • входное значение проходит проверку
  • если хотя бы одна структура возвращает успех — валидация проходит
  • если ни одна не подошла — возвращается ошибка

Базовый синтаксис:

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

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

В этом примере допустимыми значениями будут строки и числа.


Простые варианты объединений

Строка или число

import { union, string, number, validate } from 'superstruct'

const Struct = union([string(), number()])

validate('hello', Struct) // ok
validate(123, Struct)     // ok
validate(true, Struct)    // ошибка

Union работает как логическое «ИЛИ» между структурами.


Булевое значение или строка

const BoolOrString = union([string(), boolean()])

validate('ok', BoolOrString)   // ok
validate(false, BoolOrString)  // ok
validate(10, BoolOrString)     // ошибка

Union объектов

Наиболее частое применение union — различие между структурами объектов.

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

const Admin = object({
  role: string(),
  permissions: string()
})

const User = object({
  username: string(),
  age: number()
})

const Person = union([Admin, User])

Теперь валидатор принимает либо администратора, либо обычного пользователя.


Поведение при совпадении структур

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

const A = object({ type: string() })
const B = object({ type: string(), extra: string() })

const U = union([A, B])

Значение:

{ type: 'x', extra: 'y' }

будет валидировано как A, потому что проверка идёт последовательно.

Это важно учитывать при проектировании: более общие структуры следует размещать после более специфичных.


Ошибки в union

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

validate(123, union([string(), boolean()]))

Результат содержит информацию о каждой неудачной попытке проверки.

Внутренне формируется список ошибок от каждой структуры, что позволяет понять причину несоответствия.


Глубокие union-композиции

Union можно вкладывать внутрь других структур:

const Data = object({
  value: union([string(), number(), boolean()])
})

Теперь поле value может быть любого из трёх типов.


Union массивов и сложных типов

const StringArray = array(string())
const NumberArray = array(number())

const ArrayUnion = union([StringArray, NumberArray])

Допустимы:

['a', 'b', 'c']
[1, 2, 3]

Но смешанные массивы:

['a', 1]

не пройдут проверку.


Discriminated Union (размеченные объединения)

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

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

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

const Animal = union([Dog, Cat])

Здесь поле type выступает маркером выбора структуры.

Поведение

validate({ type: 'dog', barkVolume: 10 }, Animal) // ok
validate({ type: 'cat', livesLeft: 7 }, Animal)    // ok
validate({ type: 'dog', livesLeft: 7 }, Animal)    // ошибка

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


Оптимизация порядка структур

Поскольку union проверяет структуры последовательно, порядок влияет на скорость.

Рекомендуемые принципы:

  • сначала более специфичные структуры
  • затем более общие
  • избегать перекрывающихся условий

Пример плохой организации:

union([
  object({ value: string() }),
  object({ value: string(), extra: number() })
])

Второй вариант никогда не будет достигнут.

Правильный вариант:

union([
  object({ value: string(), extra: number() }),
  object({ value: string() })
])

Union и преобразования данных

union не только проверяет, но и может участвовать в трансформации, если используются соответствующие структуры.

import { coerce, string, number, union } from 'superstruct'

const Struct = union([
  coerce(number(), string(), value => Number(value)),
  number()
])

Теперь строка "42" преобразуется в число 42.


Вложенные union и сложность

С ростом вложенности увеличивается стоимость проверки:

const Struct = union([
  object({
    type: literal('a'),
    data: union([string(), number()])
  }),
  object({
    type: literal('b'),
    data: array(union([string(), number(), boolean()]))
  })
])

Такие конструкции допустимы, но требуют аккуратного контроля структуры данных.


Типичные ошибки при использовании union

1. Перекрывающиеся структуры

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

2. Отсутствие дискриминатора

Без явного поля выбора объектные union могут давать неоднозначные совпадения.

3. Избыточная вложенность

Глубокие union замедляют валидацию и усложняют сопровождение.


Сравнение union с альтернативами

В системах типизации и валидации union часто сравнивают с аналогами:

  • логическое OR в runtime-валидации
  • tagged unions в TypeScript
  • альтернативные схемы в других библиотеках (например, Zod)

В Superstruct union остаётся простым примитивом без скрытой магии: каждая структура проверяется явно, последовательно и независимо.


Композиция с refine и validate

Union можно комбинировать с дополнительными проверками:

import { union, string, refine } from 'superstruct'

const ShortString = refine(string(), value => value.length < 10)

const Struct = union([ShortString, string()])

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


Производственные сценарии использования

На практике union часто используется для:

  • обработки API ответов разных версий
  • поддержки миграции форматов данных
  • описания событий (event-driven архитектура)
  • парсинга конфигураций с альтернативными форматами
  • моделирования полиморфных сущностей

Пример событий:

const Created = object({
  event: literal('created'),
  id: string()
})

const Deleted = object({
  event: literal('deleted'),
  id: string(),
  reason: string()
})

const Event = union([Created, Deleted])

Сложные union-сценарии

В системах с большим количеством вариантов часто используется комбинация:

  • union
  • object
  • literal
  • array

Пример конфигурационного DSL:

const Config = union([
  object({ mode: literal('dev'), debug: boolean() }),
  object({ mode: literal('prod'), cache: boolean() }),
  object({ mode: literal('test'), seed: number() })
])

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


Поведение при расширении схем

При добавлении новых вариантов важно учитывать обратную совместимость:

  • новые структуры добавляются в конец union
  • старые остаются неизменными
  • порядок не должен ломать существующую логику выбора

В противном случае поведение может измениться из-за приоритета первой подходящей структуры.