Partial objects

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

Partial-объекты в Superstruct позволяют трансформировать строгую схему объекта в её «ослабленную» версию, где каждое поле становится необязательным, но при этом сохраняет свои правила валидации при наличии значения.

Базовый принцип partial-объектов

Основная идея partial заключается не в изменении типов полей, а в изменении их обязательности. Если исходная структура требует строку, число или вложенный объект, partial-версия допускает отсутствие этого поля, но не допускает некорректное значение, если поле всё же передано.

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

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

Эта структура требует оба поля. Применение partial:

const PartialUser = partial(User)

Теперь допустимы следующие данные:

PartialUser.create({})
PartialUser.create({ name: 'Alex' })
PartialUser.create({ age: 30 })
PartialUser.create({ name: 'Alex', age: 30 })

Но при этом сохраняется строгая проверка типов:

PartialUser.create({ name: 123 }) // ошибка

Поле name остаётся строкой, просто перестаёт быть обязательным.

Поведение на уровне валидации

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

При передаче значения происходит полный цикл валидации:

  1. Проверяется наличие поля.
  2. Если поле отсутствует — оно игнорируется без ошибки.
  3. Если поле присутствует — применяется исходный валидатор.

Это поведение важно, поскольку позволяет использовать partial-структуры как слой поверх существующих схем без их дублирования.

Разница между optional и partial

Superstruct поддерживает два подхода к необязательным полям:

  • optional(struct) — делает конкретное поле необязательным
  • partial(object) — делает все поля объекта необязательными

Пример с optional:

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

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

Здесь только age может отсутствовать.

Partial действует глобально:

const PartialUser = partial(
  object({
    name: string(),
    age: number(),
  })
)

Разница проявляется особенно сильно при больших схемах. optional управляет точечно, partial — массово.

Вложенные структуры и partial

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

const Address = object({
  city: string(),
  zip: number(),
})

const User = object({
  name: string(),
  address: Address,
})

const PartialUser = partial(User)

Результат:

  • name становится необязательным
  • address становится необязательным
  • но если address передан, он обязан быть валидным объектом Address

Это означает:

PartialUser.create({
  address: {} // ошибка: city и zip всё ещё обязательны внутри Address
})

Partial не «спускается» внутрь вложенных объектов. Он работает только на уровне первого слоя структуры.

Поведение с массивами и сложными типами

Partial не изменяет поведение массивов и вложенных структур.

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

const Group = object({
  users: array(string()),
  count: number(),
})

const PartialGroup = partial(Group)

Теперь:

PartialGroup.create({})
PartialGroup.create({ users: ['a', 'b'] })

Но при этом массив остаётся строго типизированным:

PartialGroup.create({ users: [123] }) // ошибка

Partial не влияет на элементы массива, только на само поле users.

Использование partial для патч-операций

Одно из основных применений partial-объектов — реализация PATCH-запросов в API.

Если есть ресурс:

const Post = object({
  title: string(),
  content: string(),
  likes: number(),
})

То полное обновление требует всех полей:

Post.create({
  title: 'A',
  content: 'B',
  likes: 10,
})

Но частичное обновление:

const PatchPost = partial(Post)

Теперь допустимы операции вида:

PatchPost.create({ title: 'New title' })
PatchPost.create({ likes: 11 })

Это отражает реальную модель серверных API, где PATCH не требует полного объекта.

Типизация в TypeScript

Superstruct интегрируется с TypeScript через вывод типов.

import { Infer, object, string, number, partial } from 'superstruct'

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

type UserType = Infer<typeof User>
type PartialUserType = Infer<typeof partial(User)>

Результат:

type UserType = {
  name: string
  age: number
}

type PartialUserType = {
  name?: string
  age?: number
}

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

Поведение при отсутствии значений

Partial различает три состояния:

  • поле отсутствует
  • поле равно undefined
  • поле содержит значение

В Superstruct отсутствие поля и undefined часто трактуются по-разному в зависимости от структуры.

Для partial-объектов:

PartialUser.create({ name: undefined })

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

Это важно при работе с JSON, где undefined обычно удаляется при сериализации.

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

Partial можно комбинировать с другими модификаторами:

Partial + refinement

import { refine } from 'superstruct'

const PositiveNumber = refine(number(), 'PositiveNumber', (value) => {
  return value > 0
})

const Struct = partial(
  object({
    score: PositiveNumber,
  })
)

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

Partial + pick/omit

Перед применением partial часто используется сокращение структуры:

import { pick } from 'superstruct'

const Base = object({
  a: string(),
  b: number(),
  c: string(),
})

const Selected = pick(Base, ['a', 'b'])
const PartialSelected = partial(Selected)

Так формируется частичная версия уже ограниченного набора полей.

Ошибки и диагностика

При нарушении правил partial-структуры ошибки сохраняют контекст исходной схемы.

PartialUser.create({ name: 123 })

Ошибка будет указывать, что поле name должно быть строкой, несмотря на partial-обёртку. Это связано с тем, что partial не изменяет валидаторы, а только их обязательность.

Типичная ошибка разработчиков — ожидание, что partial «смягчает» проверки. На практике он изменяет только presence constraint.

Ограничения partial-объектов

Partial имеет несколько принципиальных ограничений:

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

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

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

Partial-объекты чаще всего используются в следующих ситуациях:

  • обновление сущностей через API
  • формы с поэтапным заполнением
  • патчи состояния в Redux-подобных системах
  • конфигурации, где часть параметров опциональна
  • объединение дефолтных и пользовательских настроек

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

Композиция схем с partial

В сложных приложениях partial часто становится частью цепочки трансформаций схем:

const Base = object({
  id: number(),
  name: string(),
  meta: object({
    created: string(),
  }),
})

const Update = partial(Base)

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

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