Lazy валидация

Механизм lazy() в библиотеке Yup предназначен для динамического построения схемы валидации во время выполнения. В отличие от статических схем, где структура заранее фиксирована, lazy() позволяет выбирать правила на основе текущего значения поля, структуры объекта или внешнего состояния.

В связке с yupResolver из React Hook Form это особенно полезно для:

  • условных форм;
  • многошаговых интерфейсов;
  • динамических полей;
  • polymorphic-структур;
  • массивов со смешанными типами;
  • вложенных объектов с изменяемой схемой.

Базовый принцип работы lazy()

Метод принимает функцию, возвращающую схему валидации.

yup.lazy((value) => {
  return schema
})

Аргумент value — текущее значение валидируемого поля.

Пример:

import * as yup from 'yup'

const schema = yup.object({
  value: yup.lazy((value) => {
    if (typeof value === 'string') {
      return yup.string().min(3)
    }

    if (typeof value === 'number') {
      return yup.number().positive()
    }

    return yup.mixed().notRequired()
  })
})

Поведение:

Значение Используемая схема
"abc" string().min(3)
15 number().positive()
null mixed()

Интеграция с YupResolver

Подключение к форме:

import { useForm } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup'
import * as yup from 'yup'

const schema = yup.object({
  data: yup.lazy((value) => {
    return typeof value === 'string'
      ? yup.string().required()
      : yup.number().required()
  })
})

const form = useForm({
  resolver: yupResolver(schema)
})

yupResolver не требует специальной настройки для lazy(). Resolver автоматически вызывает динамическую схему во время каждой проверки.


Динамическая валидация по типу данных

Строка или объект

Частый сценарий — API может возвращать поле либо строкой, либо объектом.

const schema = yup.object({
  user: yup.lazy((value) => {
    if (typeof value === 'string') {
      return yup.string().required()
    }

    return yup.object({
      id: yup.number().required(),
      name: yup.string().required()
    })
  })
})

Примеры валидных данных:

{
  user: "admin"
}
{
  user: {
    id: 1,
    name: "Alex"
  }
}

Валидация массива смешанных типов

lazy() особенно полезен для массивов polymorphic-элементов.

const itemSchema = yup.lazy((value) => {
  switch (value.type) {
    case 'text':
      return yup.object({
        type: yup.string().required(),
        value: yup.string().required()
      })

    case 'number':
      return yup.object({
        type: yup.string().required(),
        value: yup.number().required()
      })

    default:
      return yup.mixed().test({
        name: 'invalid-type',
        message: 'Unsupported type',
        test: () => false
      })
  }
})

const schema = yup.object({
  items: yup.array().of(itemSchema)
})

Валидный объект:

{
  items: [
    {
      type: 'text',
      value: 'hello'
    },
    {
      type: 'number',
      value: 100
    }
  ]
}

Условная схема на основе значения

Проверка пустого значения

const schema = yup.object({
  config: yup.lazy((value) => {
    if (value == null) {
      return yup.mixed().notRequired()
    }

    return yup.object({
      enabled: yup.boolean().required()
    })
  })
})

Особенность:

  • null и undefined проходят без ошибок;
  • при наличии объекта включается строгая схема.

Изменение правил по длине массива

const schema = yup.object({
  tags: yup.lazy((value) => {
    if (!Array.isArray(value)) {
      return yup.array()
    }

    if (value.length > 5) {
      return yup.array().max(10)
    }

    return yup.array().max(5)
  })
})

Lazy внутри вложенных структур

Динамический nested object

const addressSchema = yup.lazy((value) => {
  if (value?.country === 'US') {
    return yup.object({
      country: yup.string().required(),
      zip: yup.string().required().matches(/^\d{5}$/)
    })
  }

  return yup.object({
    country: yup.string().required(),
    postalCode: yup.string().required()
  })
})

const schema = yup.object({
  address: addressSchema
})

Глубокая вложенность

const nodeSchema = yup.lazy(() => {
  return yup.object({
    id: yup.number().required(),
    children: yup.array().of(nodeSchema)
  })
})

Такая конструкция позволяет валидировать рекурсивные деревья.

Пример:

{
  id: 1,
  children: [
    {
      id: 2,
      children: []
    }
  ]
}

Комбинация lazy() и when()

when() и lazy() решают похожие задачи, но работают по-разному.

when()

Используется для изменения правил существующей схемы.

yup.string().when('role', {
  is: 'admin',
  then: (schema) => schema.required()
})

lazy()

Полностью заменяет схему.

yup.lazy((value) => {
  return typeof value === 'string'
    ? yup.string()
    : yup.number()
})

Совместное использование

const schema = yup.object({
  type: yup.string().required(),

  payload: yup.lazy((value, options) => {
    const type = options.parent.type

    if (type === 'email') {
      return yup.object({
        email: yup.string().email().required()
      })
    }

    return yup.object({
      phone: yup.string().required()
    })
  })
})

Использование options.parent

Внутри lazy() доступен второй аргумент.

yup.lazy((value, options) => {
  console.log(options)
})

Основные свойства:

Поле Назначение
parent родительский объект
context внешний контекст
path путь до поля
originalValue исходное значение

Доступ к соседним полям

const schema = yup.object({
  mode: yup.string().required(),

  settings: yup.lazy((value, options) => {
    const mode = options.parent.mode

    if (mode === 'advanced') {
      return yup.object({
        cache: yup.boolean().required(),
        retries: yup.number().required()
      })
    }

    return yup.object({
      cache: yup.boolean()
    })
  })
})

Использование контекста

Resolver поддерживает передачу context.

const form = useForm({
  resolver: yupResolver(schema, {
    context: {
      isAdmin: true
    }
  })
})

Получение внутри lazy():

const schema = yup.object({
  permissions: yup.lazy((value, options) => {
    const isAdmin = options.context.isAdmin

    if (isAdmin) {
      return yup.array().min(1)
    }

    return yup.array().max(0)
  })
})

Динамические формы

Поля на основе выбранного типа

const schema = yup.object({
  type: yup.string().required(),

  data: yup.lazy((value, options) => {
    switch (options.parent.type) {
      case 'login':
        return yup.object({
          email: yup.string().email().required(),
          password: yup.string().required()
        })

      case 'register':
        return yup.object({
          username: yup.string().required(),
          email: yup.string().email().required(),
          password: yup.string().min(8).required()
        })

      default:
        return yup.mixed()
    }
  })
})

Многошаговая форма

const stepSchema = yup.lazy((value, options) => {
  switch (options.context.step) {
    case 1:
      return yup.object({
        name: yup.string().required()
      })

    case 2:
      return yup.object({
        email: yup.string().email().required()
      })

    case 3:
      return yup.object({
        password: yup.string().min(8).required()
      })

    default:
      return yup.object()
  }
})

Lazy и TypeScript

Базовая типизация

const schema = yup.lazy((value: unknown) => {
  if (typeof value === 'string') {
    return yup.string()
  }

  return yup.number()
})

Проблемы infer-типа

lazy() ухудшает автоматическое определение типов.

type FormData = yup.InferType<typeof schema>

В сложных схемах тип может стать:

any

или:

unknown

Явное описание union-типов

type Payload =
  | string
  | {
      id: number
      name: string
    }

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

Особенности работы

lazy() вызывается:

  • при каждой валидации;
  • при trigger;
  • при submit;
  • при re-validation;
  • при изменении связанных полей.

Сложные схемы могут создавать лишнюю нагрузку.


Антипаттерн: создание тяжёлых объектов

Плохо:

yup.lazy(() => {
  return yup.object({
    items: yup.array().of(
      yup.object({
        huge: yup.string()
      })
    )
  })
})

На каждой проверке создаётся новая схема.


Оптимизация

Хорошо:

const hugeSchema = yup.object({
  items: yup.array().of(
    yup.object({
      huge: yup.string()
    })
  )
})

const schema = yup.lazy(() => hugeSchema)

Lazy и nullable

Комбинация с nullable()

const schema = yup.lazy((value) => {
  if (value === null) {
    return yup.mixed().nullable()
  }

  return yup.string().required()
})

Lazy и default()

const schema = yup.lazy((value) => {
  if (typeof value === 'object') {
    return yup.object({
      enabled: yup.boolean().default(false)
    })
  }

  return yup.string()
})

Обработка неизвестных структур

Универсальный JSON validator

const jsonSchema = yup.lazy((value) => {
  if (Array.isArray(value)) {
    return yup.array().of(jsonSchema)
  }

  if (typeof value === 'object' && value !== null) {
    return yup.object(
      Object.keys(value).reduce((acc, key) => {
        acc[key] = jsonSchema
        return acc
      }, {})
    )
  }

  switch (typeof value) {
    case 'string':
      return yup.string()

    case 'number':
      return yup.number()

    case 'boolean':
      return yup.boolean()

    default:
      return yup.mixed()
  }
})

Частые ошибки

Возврат не-схемы

Неправильно:

yup.lazy((value) => {
  return {}
})

Правильно:

yup.lazy((value) => {
  return yup.object()
})

Отсутствие fallback-схемы

Неправильно:

yup.lazy((value) => {
  if (typeof value === 'string') {
    return yup.string()
  }
})

Если условие не выполнится — возникнет ошибка.

Правильно:

yup.lazy((value) => {
  if (typeof value === 'string') {
    return yup.string()
  }

  return yup.mixed()
})

Использование async-логики

lazy() не предназначен для асинхронного получения схем.

Плохо:

yup.lazy(async () => {
  return yup.string()
})

Практический пример полноценной формы

import * as yup from 'yup'

const paymentSchema = yup.object({
  method: yup.string().required(),

  details: yup.lazy((value, options) => {
    switch (options.parent.method) {
      case 'card':
        return yup.object({
          cardNumber: yup.string().required(),
          cvv: yup.string().required()
        })

      case 'paypal':
        return yup.object({
          email: yup.string().email().required()
        })

      case 'crypto':
        return yup.object({
          wallet: yup.string().required(),
          network: yup.string().required()
        })

      default:
        return yup.mixed()
    }
  })
})

Варианты валидных данных:

{
  method: 'card',
  details: {
    cardNumber: '1111 2222 3333 4444',
    cvv: '123'
  }
}
{
  method: 'paypal',
  details: {
    email: 'test@mail.com'
  }
}
{
  method: 'crypto',
  details: {
    wallet: '0x123',
    network: 'ETH'
  }
}

Архитектурные рекомендации

Использование фабрик схем

const createAdminSchema = () => {
  return yup.object({
    role: yup.string().required()
  })
}

const createUserSchema = () => {
  return yup.object({
    name: yup.string().required()
  })
}

const schema = yup.lazy((value) => {
  return value?.isAdmin
    ? createAdminSchema()
    : createUserSchema()
})

Разделение схем по модулям

schemas/
 ├── user.schema.js
 ├── payment.schema.js
 ├── product.schema.js
 └── dynamic.schema.js

Минимизация логики внутри lazy()

Плохо:

yup.lazy((value) => {
  // 200 строк условий
})

Хорошо:

const resolveSchema = (value) => {
  if (typeof value === 'string') {
    return stringSchema
  }

  return objectSchema
}

const schema = yup.lazy(resolveSchema)

Отличия lazy() от альтернатив

Подход Назначение
when() изменение правил
test() кастомная проверка
transform() преобразование данных
lazy() динамическая замена схемы

Когда lazy() особенно полезен

Оптимальные сценарии

  • polymorphic API;
  • dynamic form builders;
  • CMS-конструкторы;
  • JSON editors;
  • recursive structures;
  • nested dynamic arrays;
  • формы с режимами работы;
  • schema-driven UI.

Когда лучше избегать lazy()

Неудачные сценарии

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

В таких случаях when() обычно проще и читаемее.