Pipe и flow

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

Одним из ключевых инструментов становится pipe, позволяющий последовательно применять несколько структур и трансформаций к одному значению, превращая простую проверку в полноценный конвейер обработки данных.


Pipe как основа цепочки преобразований

pipe в Superstruct используется для последовательного применения нескольких структур или функций валидации. Каждый шаг получает результат предыдущего, формируя линейный поток обработки данных.

Ключевая идея:

  • входное значение проходит через первую проверку;
  • результат передаётся следующему шагу;
  • каждый этап может либо трансформировать данные, либо отклонить их;
  • итоговый результат — это уже нормализованное значение.

Простейшая модель:

pipe(structA, structB, structC)

Где:

  • structA — первичная проверка или преобразование;
  • structB — дополнительное ограничение;
  • structC — финальная форма результата.

Базовый пример цепочки

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

import { string, size, pipe, refine } from 'superstruct'

const trim = (value) => value.trim()
const toLower = (value) => value.toLowerCase()

const CleanString = pipe(
  string(),
  (value) => trim(value),
  (value) => toLower(value)
)

В этой конструкции:

  • string() гарантирует тип;
  • первая функция нормализует пробелы;
  • вторая приводит строку к единому виду.

Разделение валидации и трансформации

Одно из преимуществ pipe — разделение ответственности между этапами.

Можно явно выделить:

  • структурные проверки (тип, диапазон, форма данных);
  • трансформации (изменение значения);
  • финальные ограничения (бизнес-логика).

Пример:

import { string, size, pipe, refine } from 'superstruct'

const Trim = (v) => v.trim()
const Lower = (v) => v.toLowerCase()

const Username = pipe(
  string(),
  Trim,
  Lower,
  size(3, 20),
  refine((value) => /^[a-z0-9_]+$/.test(value))
)

Здесь логика выстраивается как конвейер:

  1. приведение к строке;
  2. очистка;
  3. нормализация регистра;
  4. проверка длины;
  5. проверка допустимых символов.

Pipe как средство декларативной композиции

Важное свойство pipe — декларативность. Вместо императивного кода с промежуточными переменными формируется линейная цепочка описаний.

Сравнение подходов:

Императивный стиль:

let value = input.trim()
value = value.toLowerCase()

if (value.length < 3) {
  throw new Error('Too short')
}

Декларативный через pipe:

const Struct = pipe(
  string(),
  (v) => v.trim(),
  (v) => v.toLowerCase(),
  size(3, 20)
)

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


Вложенные пайпы и композиция уровней

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

Пример: объект пользователя с нормализацией полей.

import { object, string, pipe } from 'superstruct'

const Trim = (v) => v.trim()

const Username = pipe(string(), Trim)
const Email = pipe(string(), Trim)

const User = object({
  username: Username,
  email: Email
})

Здесь каждый компонент остаётся переиспользуемым, а объектная структура лишь собирает их вместе.


Flow как концепция последовательной обработки

Параллельно с pipe часто используется концептуальная модель flow — поток обработки данных, где каждое преобразование рассматривается как часть единого процесса.

Если pipe можно представить как строго линейную цепочку, то flow чаще трактуется шире:

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

Хотя Superstruct формально ориентирован на pipe как основной механизм, модель flow помогает проектировать сложные схемы валидации.


Flow как архитектурный подход

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

Пример: обработка формы регистрации.

const normalize = (v) => v.trim().toLowerCase()

const preprocess = (value) => ({
  original: value,
  normalized: normalize(value)
})

Такой этап можно встроить в pipe:

const Username = pipe(
  string(),
  preprocess,
  (v) => v.normalized,
  size(3, 20)
)

Здесь flow выражается в том, что данные сначала расширяются, затем извлекается нужное поле.


Комбинирование pipe с другими структурами

Superstruct позволяет сочетать pipe с базовыми конструкциями:

  • string
  • number
  • object
  • array
  • union
  • refine

Пример с массивами:

import { array, string, pipe } from 'superstruct'

const Trim = (v) => v.trim()

const Tags = pipe(
  array(string()),
  (arr) => arr.map(Trim),
  (arr) => arr.filter(Boolean)
)

Каждый элемент массива проходит через нормализацию, после чего массив очищается от пустых значений.


Использование pipe для нормализации чисел

Pipe особенно полезен для приведения данных к числовому виду.

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

const toNumber = (v) => Number(v)
const ensureFinite = (v) => Number.isFinite(v) ? v : NaN

const Age = pipe(
  string(),
  toNumber,
  ensureFinite,
  (v) => (v >= 0 ? v : NaN)
)

Такой подход позволяет обрабатывать данные, поступающие из внешних источников (формы, API), где типы часто не гарантированы.


Ошибки в цепочках pipe

Каждый этап pipe может прервать выполнение всей цепочки. Это делает поведение предсказуемым: ошибка локализуется в конкретном шаге.

Типичная ситуация:

  • входное значение проходит первую проверку;
  • на этапе трансформации возникает некорректное значение;
  • последующие структуры не выполняются.

Пример:

const Struct = pipe(
  string(),
  (v) => JSON.parse(v),
  object({
    id: string()
  })
)

Если строка не является валидным JSON, цепочка прерывается на этапе парсинга.


Переиспользование шагов pipeline

Одним из преимуществ pipe является возможность вынесения отдельных шагов в независимые функции.

const trim = (v) => v.trim()
const normalize = (v) => v.toLowerCase()
const removeSpaces = (v) => v.replace(/\s+/g, '_')

const Slug = pipe(
  string(),
  trim,
  normalize,
  removeSpaces
)

Такая структура упрощает тестирование и поддержку, поскольку каждый шаг можно проверять отдельно.


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

При росте системы валидации важно избегать монолитных структур. Pipe решает эту проблему за счёт:

  • декомпозиции логики;
  • повторного использования шагов;
  • линейной читаемости;
  • предсказуемого поведения.

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


Ограничения линейного pipeline

Несмотря на гибкость, pipe имеет естественные ограничения:

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

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


Сочетание pipe и бизнес-логики

В реальных системах pipe часто используется для отделения бизнес-логики от инфраструктурной обработки данных.

Например:

  • нормализация входных данных;
  • приведение типов;
  • базовые проверки;
  • передача в доменную логику.
const PreparedEmail = pipe(
  string(),
  (v) => v.trim(),
  (v) => v.toLowerCase(),
  refine((v) => v.includes('@'))
)

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