Вложенные union типы

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


Базовая идея union-типа

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

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

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

Такое определение допускает либо строку, либо число.

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


Переход к вложенным union-типам

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

Это позволяет описывать данные с многослойной вариативностью:

  • разные формы объектов
  • альтернативные структуры внутри объектов
  • гибкие API-ответы
  • конфигурации с несколькими режимами

Простейшая вложенность union внутри union

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

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

const ExtendedPrimitive = union([
  Primitive,
  boolean()
])

Здесь ExtendedPrimitive является вложенным union-типом, так как один из вариантов сам является union.

Логически это эквивалентно «объединению объединений», которое при проверке разворачивается в плоский набор вариантов.


Вложенные union в структурах объектов

На практике наиболее важный сценарий — использование union внутри объектов.

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

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

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

const Account = union([Admin, User])

Здесь Account может быть либо администратором, либо обычным пользователем.


Глубокая вложенность: union внутри поля объекта

Более сложный случай — когда union используется не на верхнем уровне, а внутри структуры:

const Response = object({
  status: string(),
  data: union([
    object({ kind: string(), value: string() }),
    object({ kind: string(), value: number() }),
    object({ kind: string(), value: boolean() })
  ])
})

В этом примере поле data содержит вложенный union, определяющий разные формы значения.


Многоуровневые nested union

При усложнении модели данные могут содержать несколько уровней альтернатив:

const EventA = object({
  type: string(),
  payload: object({ a: string() })
})

const EventB = object({
  type: string(),
  payload: object({ b: number() })
})

const EventC = object({
  type: string(),
  payload: union([
    object({ c: string() }),
    object({ c: number() })
  ])
})

const Event = union([EventA, EventB, EventC])

Здесь присутствует двойная вложенность:

  • верхний union определяет тип события
  • внутри одного из вариантов находится дополнительный union

Поведение проверки вложенных union

При валидации Superstruct действует по принципу последовательного перебора:

  1. Проверяется первый вариант union
  2. При неуспехе — следующий
  3. Для каждого варианта запускается полная рекурсивная проверка вложенных структур

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


Разворачивание вложенных union на практике

Логически вложенные union можно представить как плоский набор альтернатив:

union([
  A,
  B,
  union([C, D])
])

эквивалентно:

union([
  A,
  B,
  C,
  D
])

Однако в коде важно сохранять структуру, потому что вложенность часто отражает смысловые границы модели.


Комбинация union и discriminated patterns

В сложных системах вложенные union часто комбинируются с дискриминатором:

const Shape = union([
  object({ type: string(), radius: number() }),
  object({
    type: string(),
    shape: union([
      object({ kind: string(), width: number() }),
      object({ kind: string(), height: number() })
    ])
  })
])

Здесь структура становится иерархической: сначала определяется форма, затем её внутренний вариант.


Ошибки проектирования вложенных union

При работе с nested union часто возникают типовые проблемы:

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

Слишком глубокие union затрудняют чтение и замедляют проверку.

2. Перекрывающиеся варианты

Если структуры внутри union слишком похожи, проверка становится неоднозначной.

3. Потеря дискриминатора

Без явного различающего поля (type, kind) выбор варианта превращается в последовательный перебор, что ухудшает производительность.


Оптимизация структуры nested union

Практическая оптимизация строится вокруг трёх принципов:

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

Пример:

const BaseEvent = object({
  id: string(),
  timestamp: number()
})

const Event = union([
  object({
    ...BaseEvent.schema,
    type: literal('A'),
    payload: object({ a: string() })
  }),
  object({
    ...BaseEvent.schema,
    type: literal('B'),
    payload: object({ b: number() })
  })
])

Рекурсивные вложенные union

Особый случай — когда union ссылается на сам себя через вложенные структуры:

const Tree = union([
  object({
    value: string(),
    children: array(Tree)
  }),
  object({
    value: number(),
    children: array(Tree)
  })
])

Такая конструкция используется для деревьев, AST и графоподобных структур.


Практическое значение вложенных union

Вложенные union-типы становятся основой для моделирования:

  • сложных API ответов
  • событийных систем
  • парсеров данных
  • конфигурационных DSL
  • иерархических структур

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