Yup JSON Validation: Schemas, Async Validate, Formik Integration & Error Handling

Last updated:

Yup validates JSON objects at runtime and infers TypeScript types statically via InferType<typeof schema> — a single yup.object().shape({}) replaces both a TypeScript interface and a hand-written validation function. .validate(data) returns a Promise resolving to the cast value on success and rejecting with a ValidationError on failure; .validateSync(data) is synchronous and throws. Setting abortEarly: false in options makes Yup collect every validation error in one pass rather than stopping at the first failure, returning an errors array with all issue messages. This guide covers defining Yup schemas for JSON objects and arrays, TypeScript type inference with InferType, casting and coercion with .cast(), async validation, custom .test() refinements, error formatting for API responses, and integrating Yup with Formik and React Hook Form.

Defining JSON Object and Array Schemas with yup.object and yup.array

Yup schemas map directly to JSON types: yup.string(), yup.number(), yup.boolean(), and yup.mixed() cover JSON scalar values. yup.object().shape({'{}'}) validates JSON objects with named fields; yup.array().of(schema) validates JSON arrays of a uniform type. Fields are optional by default — add .required() to make a field mandatory. Nest schemas to mirror any JSON structure and reuse schemas across related definitions.

import * as yup from 'yup'
import type { InferType } from 'yup'

// ── Primitive schemas ──────────────────────────────────────────
const StringSchema  = yup.string()
const NumberSchema  = yup.number()
const BooleanSchema = yup.boolean()

// ── yup.object().shape() — validates a JSON object ────────────
const UserSchema = yup.object().shape({
  id:        yup.string().uuid().required(),
  name:      yup.string().min(1).max(100).required(),
  email:     yup.string().email().required(),
  age:       yup.number().integer().min(0).max(150).optional(),
  bio:       yup.string().nullable().optional(),  // string | null | undefined
  createdAt: yup.string().required(),
})

// InferType — extracts TypeScript type from the schema
type User = InferType<typeof UserSchema>
// {
//   id: string
//   name: string
//   email: string
//   age?: number
//   bio?: string | null
//   createdAt: string
// }

// ── yup.array().of() — validates a JSON array ─────────────────
const UsersSchema = yup.array().of(UserSchema).required()

// ── Nested objects ─────────────────────────────────────────────
const OrderSchema = yup.object().shape({
  id:    yup.string().required(),
  total: yup.number().positive().required(),
  items: yup.array().of(
    yup.object().shape({
      productId: yup.string().required(),
      quantity:  yup.number().integer().min(1).required(),
      price:     yup.number().positive().required(),
    })
  ).min(1).required(),
  user: UserSchema.required(), // nested schema reuse
})

// ── Validating data ────────────────────────────────────────────
const json = {
  id: '550e8400-e29b-41d4-a716-446655440000',
  name: 'Alice',
  email: 'alice@example.com',
  bio: null,
  createdAt: '2026-05-28T10:00:00Z',
}

// async validate — rejects with ValidationError on failure
UserSchema.validate(json, { abortEarly: false })
  .then(user => console.log(user.email))        // typed as string
  .catch(err => console.error(err.errors))      // string[] of messages

// sync validateSync — throws ValidationError on failure
try {
  const user: User = UserSchema.validateSync(json)
  console.log(user.name)
} catch (err) {
  if (err instanceof yup.ValidationError) {
    console.error(err.message)
  }
}

Chain validation methods on primitives to add constraints: .string().min(1) rejects empty strings, .number().integer() rejects floats, .string().url() validates URL format, .string().matches(/regex/) tests against a regular expression. All Yup schemas are lazy by default — methods return new schema instances rather than mutating the original, making schema composition safe. Use yup.object().shape({'{}'}) in preference to the legacy yup.object({'{}'}) shorthand for clarity.

validate vs validateSync: Choosing the Right Boundary

The choice between validate() and validateSync() depends on two factors: whether the schema contains async validators, and whether a synchronous throw or an async rejection better fits the call site. Always prefer validate() at API boundaries and form submission handlers. Reserve validateSync() for schema introspection, configuration file validation at process startup, or test assertions where async adds friction.

import * as yup from 'yup'

// ── validate() — async, returns Promise ───────────────────────
// Good for: API handlers, Formik/React Hook Form, async .test() validators
const CreateUserSchema = yup.object().shape({
  name:  yup.string().min(1).max(100).required(),
  email: yup.string().email().required(),
  age:   yup.number().integer().min(18).required(),
})

// Next.js App Router POST handler
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'

export async function POST(req: NextRequest) {
  const body = await req.json()

  try {
    const data = await CreateUserSchema.validate(body, { abortEarly: false })
    // data is cast and typed as InferType<typeof CreateUserSchema>
    const user = await db.createUser(data)
    return NextResponse.json({ success: true, user }, { status: 201 })
  } catch (err) {
    if (err instanceof yup.ValidationError) {
      // Build field → message map from err.inner
      const errors = err.inner.reduce<Record<string, string>>((acc, e) => {
        if (e.path) acc[e.path] = e.message
        return acc
      }, {})
      return NextResponse.json({ success: false, errors }, { status: 422 })
    }
    throw err
  }
}

// ── validateSync() — sync, throws ValidationError ─────────────
// Good for: startup config validation, unit tests, no async validators
const EnvSchema = yup.object().shape({
  DATABASE_URL: yup.string().url().required(),
  PORT:         yup.number().integer().min(1024).max(65535).required()
    .default(3000),
  NODE_ENV: yup.mixed<'development' | 'production' | 'test'>()
    .oneOf(['development', 'production', 'test'])
    .required(),
})

// Throws immediately if env is misconfigured — fail fast at startup
const env = EnvSchema.validateSync(process.env)
// env.DATABASE_URL is string — no ! assertion needed

// ── abortEarly: false — collect ALL errors ─────────────────────
// Default: abortEarly: true (stops at first error)
// Set false to gather every field failure in one pass
const result = await CreateUserSchema.validate(
  { name: '', email: 'not-an-email', age: 15 },
  { abortEarly: false }
).catch(err => err as yup.ValidationError)

// result.errors: ['name is a required field', 'email must be a valid email',
//                 'age must be greater than or equal to 18']
// result.inner: ValidationError[] — one per field path
result.inner.forEach(e => {
  console.log(e.path, '→', e.message)
  // 'name'  → 'name is a required field'
  // 'email' → 'email must be a valid email address'
  // 'age'   → 'age must be greater than or equal to 18'
})

ValidationError.inner is only populated when abortEarly: false is passed — with the default abortEarly: true, inner is an empty array and only the first error is reported in err.message and err.errors[0]. For API handlers, always use abortEarly: false so clients receive complete error feedback rather than needing to re-submit multiple times to discover all problems.

TypeScript Type Inference with InferType

InferType<typeof Schema> extracts the output TypeScript type from a Yup schema at compile time — zero runtime cost, no code generation, no separate .d.ts maintenance. Import it directly from yup. When the schema changes (a new field, a new constraint, a transform), the inferred type updates automatically, making the schema the single source of truth for both runtime validation and static typing.

import * as yup from 'yup'
import type { InferType } from 'yup'

// ── Basic inference ────────────────────────────────────────────
const ProductSchema = yup.object().shape({
  id:       yup.string().uuid().required(),
  name:     yup.string().required(),
  price:    yup.number().positive().required(),
  tags:     yup.array().of(yup.string().required()).required(),
  metadata: yup.mixed<Record<string, unknown>>().optional(),
})

type Product = InferType<typeof ProductSchema>
// {
//   id: string
//   name: string
//   price: number
//   tags: string[]
//   metadata?: Record<string, unknown>
// }

// ── Optional vs required affects the inferred type ─────────────
const FlexibleSchema = yup.object().shape({
  required: yup.string().required(),        // string
  optional: yup.string().optional(),        // string | undefined
  nullable: yup.string().nullable().required(), // string | null
  both:     yup.string().nullable().optional(), // string | null | undefined
  withDefault: yup.string().default(''),    // string (never undefined)
})
type Flexible = InferType<typeof FlexibleSchema>

// ── Using InferType in function signatures ─────────────────────
// Define once, use everywhere — no separate interface needed
export const ApiUserSchema = yup.object().shape({
  id:    yup.string().required(),
  name:  yup.string().required(),
  email: yup.string().email().required(),
})
export type ApiUser = InferType<typeof ApiUserSchema>

// Frontend — validates response
async function fetchUser(id: string): Promise<ApiUser> {
  const res  = await fetch(`/api/users/${id}`)
  const json = await res.json()
  return ApiUserSchema.validate(json)  // rejects if API changed shape
}

// Backend — uses same type in handler
async function createUser(data: ApiUser): Promise<void> {
  await db.insert('users', data)  // data fully typed as ApiUser
}

// ── Reusing schemas for PATCH vs POST ─────────────────────────
// Full create schema
const CreateProductSchema = yup.object().shape({
  name:  yup.string().min(1).required(),
  price: yup.number().positive().required(),
  tags:  yup.array().of(yup.string().required()).required(),
})

// Partial update schema — all fields optional
const UpdateProductSchema = yup.object().shape({
  name:  yup.string().min(1).optional(),
  price: yup.number().positive().optional(),
  tags:  yup.array().of(yup.string().required()).optional(),
})

type CreateProduct = InferType<typeof CreateProductSchema>
type UpdateProduct = InferType<typeof UpdateProductSchema>

A practical full-stack pattern: export both the schema and the inferred type from a shared types/api.ts module. The frontend imports the schema for form validation and the type for prop typing; the backend imports the schema for request body validation and the type for handler signatures. This creates a compile-time contract — if the schema changes, TypeScript errors surface immediately at every call site that uses the inferred type.

Custom Refinements with .test() and Async Validation

.test(name, message, fn) adds custom validation logic on top of Yup's built-in constraints. The function receives the current field value and must return a boolean (or a ValidationError for dynamic messages). Return a Promise<boolean> for async validators — Yup awaits the result automatically when you call .validate(). Use this.createError({'{ message, path }'}) inside the test function (non-arrow function only) to generate dynamic error messages with access to the context.

import * as yup from 'yup'

// ── .test() — synchronous custom rule ─────────────────────────
const PasswordSchema = yup.string()
  .min(8, 'Password must be at least 8 characters')
  .test('uppercase', 'Must contain at least one uppercase letter', val =>
    !val || /[A-Z]/.test(val)
  )
  .test('number', 'Must contain at least one number', val =>
    !val || /[0-9]/.test(val)
  )
  .required()

// ── Cross-field validation with .test() on the parent object ───
const SignupSchema = yup.object().shape({
  email:           yup.string().email().required(),
  password:        yup.string().min(8).required(),
  confirmPassword: yup.string().required(),
}).test('passwords-match', 'Passwords do not match', function (values) {
  const { password, confirmPassword } = values
  if (password !== confirmPassword) {
    // this.createError gives control over which field the error appears on
    return this.createError({
      path: 'confirmPassword',
      message: 'Passwords do not match',
    })
  }
  return true
})

// ── Async .test() — database uniqueness check ─────────────────
const UniqueEmailSchema = yup.string()
  .email()
  .test('unique-email', 'Email is already registered', async (value) => {
    if (!value) return true  // let .required() handle the empty case
    const exists = await db.users.exists({ email: value })
    return !exists
  })
  .required()

// Must use .validate() (not .validateSync()) with async tests
await UniqueEmailSchema.validate('alice@example.com')
// resolves if email is available, rejects with ValidationError if taken

// ── Dynamic error messages with this.createError ──────────────
const PositiveIntSchema = yup.number().test(
  'positive-int',
  'Must be a positive integer',
  function (value) {
    if (value === undefined || value === null) return true
    if (!Number.isInteger(value)) {
      return this.createError({ message: `${value} is not an integer` })
    }
    if (value <= 0) {
      return this.createError({ message: `${value} must be greater than 0` })
    }
    return true
  }
)

// ── Multiple .test() calls on the same field ──────────────────
// Each .test() has a unique name — duplicate names overwrite previous
const UsernameSchema = yup.string()
  .min(3)
  .max(20)
  .matches(/^[a-z0-9_]+$/, 'Only lowercase letters, numbers, and underscores')
  .test('no-reserved', 'That username is reserved', val =>
    !['admin', 'root', 'system'].includes(val ?? '')
  )
  .test('available', 'Username already taken', async val => {
    if (!val || val.length < 3) return true
    return !(await db.users.exists({ username: val }))
  })
  .required()

When multiple .test() validators are chained on the same field with abortEarly: false, Yup runs all of them — both sync and async — and accumulates failures. The test name (first argument) is a unique identifier; defining two tests with the same name on one field replaces the first with the second. Use descriptive names like 'unique-email', 'no-reserved-words' rather than generic names, both for debugging and to prevent accidental overwrites during schema composition.

Casting and Coercing JSON Data with .cast() and Transforms

Yup's .cast(value) coerces a value to the schema's type without running validation rules. String "42" cast through yup.number() becomes 42; string "true" cast through yup.boolean() becomes true. .validate() also applies casting before running constraints, so the returned value is always the cast type. Use .transform(fn) to define custom transformations that run as part of the cast pipeline.

import * as yup from 'yup'

// ── .cast() — coerce without validating ───────────────────────
const NumberSchema = yup.number()

console.log(NumberSchema.cast('42'))    // 42 (number)
console.log(NumberSchema.cast('3.14'))  // 3.14 (number)
console.log(NumberSchema.cast(true))    // 1 (boolean → number)
// console.log(NumberSchema.cast('abc')) // throws CastError

const BoolSchema = yup.boolean()
console.log(BoolSchema.cast('true'))   // true
console.log(BoolSchema.cast('false'))  // false
console.log(BoolSchema.cast(1))        // true
console.log(BoolSchema.cast(0))        // false

// ── .cast() on objects — processes all fields ─────────────────
const ApiQuerySchema = yup.object().shape({
  page:  yup.number().integer().min(1).default(1),
  limit: yup.number().integer().min(1).max(100).default(20),
  sort:  yup.string().default('createdAt'),
})

// URL query params are always strings — .cast() coerces them
const rawQuery = { page: '2', limit: '50', sort: 'name' }
const query = ApiQuerySchema.cast(rawQuery)
// { page: 2, limit: 50, sort: 'name' }

// ── .transform() — custom transformation in the cast pipeline ──
const DateStringSchema = yup.string()
  .transform((value, originalValue) => {
    // Normalize date strings to ISO 8601 before validation
    if (typeof originalValue === 'string' && originalValue.includes('/')) {
      const [month, day, year] = originalValue.split('/')
      return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`
    }
    return value
  })
  .matches(/^\d{4}-\d{2}-\d{2}$/, 'Must be YYYY-MM-DD format')

// '05/28/2026' → '2026-05-28' (transform runs, then validation passes)
await DateStringSchema.validate('05/28/2026')  // '2026-05-28'

// ── Trim strings with transform ────────────────────────────────
const TrimmedStringSchema = yup.string()
  .transform(value => (typeof value === 'string' ? value.trim() : value))
  .min(1, 'Cannot be empty')
  .required()

await TrimmedStringSchema.validate('  alice  ')  // 'alice'

// ── stripUnknown — remove extra fields during cast ─────────────
const StrictSchema = yup.object().shape({
  name:  yup.string().required(),
  email: yup.string().email().required(),
})

// Extra 'role' field is stripped from the validated output
const data = await StrictSchema.validate(
  { name: 'Alice', email: 'alice@example.com', role: 'admin' },
  { abortEarly: false, stripUnknown: true }
)
// data = { name: 'Alice', email: 'alice@example.com' }

The stripUnknown: true option in validate options removes fields not declared in the schema from the output — useful for sanitizing request bodies before persisting to a database. Pass { stripUnknown: true } to .validate() or to .cast() as the second argument. Yup's built-in type coercion runs before .transform() calls in the cast pipeline — a number field receiving the string "42" will already be the number 42 inside a transform function.

Formatting ValidationError for API Responses

ValidationError is the error class Yup throws (or rejects with) on validation failure. It carries message (first error string), errors (all error message strings), path (the failing field path), and inner (array of ValidationError per field when abortEarly: false). Map inner to a Record<string, string> for HTTP 422 responses and form field displays.

import * as yup from 'yup'

const Schema = yup.object().shape({
  name:  yup.string().min(1, 'Name is required').required(),
  email: yup.string().email('Invalid email address').required(),
  age:   yup.number().integer().min(18, 'Must be 18 or older').required(),
})

// Validate with abortEarly: false to capture all errors
const validationResult = await Schema.validate(
  { name: '', email: 'not-an-email', age: 15 },
  { abortEarly: false }
).catch(err => err as yup.ValidationError)

if (validationResult instanceof yup.ValidationError) {
  // ── err.errors — flat array of all error message strings ────
  console.log(validationResult.errors)
  // ['name is a required field', 'email must be a valid email address',
  //  'age must be greater than or equal to 18']

  // ── err.inner — ValidationError[] per field ─────────────────
  // Each inner error: { path, message, value, type }
  validationResult.inner.forEach(e => {
    console.log(e.path, '→', e.message)
    // 'name'  → 'name is a required field'
    // 'email' → 'email must be a valid email address'
    // 'age'   → 'age must be greater than or equal to 18'
  })

  // ── Field → message map for API responses ────────────────────
  const fieldErrors = validationResult.inner.reduce<Record<string, string>>(
    (acc, e) => {
      if (e.path) acc[e.path] = e.message
      return acc
    },
    {}
  )
  // fieldErrors = {
  //   name:  'name is a required field',
  //   email: 'email must be a valid email address',
  //   age:   'age must be greater than or equal to 18',
  // }

  // ── HTTP 422 JSON response ────────────────────────────────────
  // return Response.json({ success: false, errors: fieldErrors }, { status: 422 })
}

// ── Custom error messages on validators ───────────────────────
const StrictSchema = yup.object().shape({
  username: yup.string()
    .min(3, 'Username must be at least 3 characters')
    .max(20, 'Username cannot exceed 20 characters')
    .matches(/^[a-z0-9_]+$/, 'Only lowercase letters, numbers, and underscores')
    .required('Username is required'),
  password: yup.string()
    .min(8, 'Password must be at least 8 characters')
    .required('Password is required'),
})

// ── Nested object error paths ──────────────────────────────────
const AddressSchema = yup.object().shape({
  address: yup.object().shape({
    street: yup.string().required(),
    city:   yup.string().required(),
    zip:    yup.string().matches(/^\d{5}$/, 'Must be a 5-digit zip code').required(),
  }).required(),
})

// On failure with abortEarly: false, inner paths are dot-notated:
// 'address.street', 'address.city', 'address.zip'

When a schema has nested objects, inner error paths use dot notation — "address.zip", "items[0].price". To reconstruct a nested error object matching the schema's shape, use a path-splitting utility or a library like lodash.set: _.set(errors, e.path, e.message). This produces a deeply nested object useful for displaying inline errors in complex forms with nested field groups.

Integrating Yup with Formik and React Hook Form

Yup has first-class integrations with both major React form libraries. Formik ships with built-in Yup support via the validationSchema prop — no extra package needed. React Hook Form requires @hookform/resolvers/yup which wraps Yup in the standard resolver interface. Both integrations call validate() internally with abortEarly: false and map the resulting ValidationError.inner to field-level errors.

import * as yup from 'yup'
import type { InferType } from 'yup'

// ── Shared schema ──────────────────────────────────────────────
const SignupSchema = yup.object().shape({
  email:    yup.string().email('Enter a valid email').required('Email is required'),
  password: yup.string().min(8, 'At least 8 characters').required('Password is required'),
  age:      yup.number().integer().min(18, 'Must be 18 or older').required('Age is required'),
})
type SignupValues = InferType<typeof SignupSchema>

// ── Formik integration — validationSchema prop ─────────────────
import { Formik, Form, Field, ErrorMessage } from 'formik'

function SignupFormFormik() {
  return (
    <Formik<SignupValues>
      initialValues={{ email: '', password: '', age: 18 }}
      validationSchema={SignupSchema}
      onSubmit={async (values, { setSubmitting }) => {
        // values is typed as SignupValues (number age, not string)
        await api.signup(values)
        setSubmitting(false)
      }}
    >
      {({ isSubmitting }) => (
        <Form>
          <Field name="email" type="email" />
          <ErrorMessage name="email" component="span" />

          <Field name="password" type="password" />
          <ErrorMessage name="password" component="span" />

          <Field name="age" type="number" />
          <ErrorMessage name="age" component="span" />

          <button type="submit" disabled={isSubmitting}>Sign up</button>
        </Form>
      )}
    </Formik>
  )
}

// ── React Hook Form with yupResolver ──────────────────────────
// npm install @hookform/resolvers
import { useForm } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup'

function SignupFormRHF() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<SignupValues>({
    resolver: yupResolver(SignupSchema),
  })

  const onSubmit = async (data: SignupValues) => {
    // data.age is number (Yup cast the string input value)
    await api.signup(data)
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} type="email" />
      {errors.email && <span>{errors.email.message}</span>}

      <input {...register('password')} type="password" />
      {errors.password && <span>{errors.password.message}</span>}

      <input {...register('age')} type="number" />
      {errors.age && <span>{errors.age.message}</span>}

      <button type="submit" disabled={isSubmitting}>Sign up</button>
    </form>
  )
}

// ── Async validators work with both integrations ───────────────
const UniqueEmailSchema = yup.object().shape({
  email: yup.string()
    .email()
    .test('unique', 'Email already registered', async value => {
      if (!value) return true
      return !(await api.checkEmailExists(value))
    })
    .required(),
})
// Formik and React Hook Form both await the Promise from .test()

In Formik, validateOnChange and validateOnBlur default to true — every keystroke and focus-loss triggers Yup validation, which can be expensive for schemas with async validators. Disable validateOnChange for schemas with async .test() calls and validate only on blur or submit. React Hook Form's mode: 'onBlur' or mode: 'onSubmit' provides the same control. Both integrations pass abortEarly: false automatically, collecting all field errors in one validation pass.

Key Terms

schema
A Yup schema is a composable, chainable validator object that describes the expected shape, type, and constraints of a JSON value. Schemas are created by calling Yup factory functions — yup.string(), yup.number(), yup.object(), yup.array() — and chaining methods like .min(), .required(), .nullable(), and .test(). Every schema exposes .validate(), .validateSync(), .cast(), and .isValid() methods. Schemas are immutable — modifier methods return new schema instances. The TypeScript type produced by a schema is extracted with InferType<typeof schema>.
ValidationError
ValidationError is the error class Yup throws or rejects with on validation failure. It extends Error and carries: message (the first or only error message string), errors (array of all error message strings), path (dot/bracket-notated field path to the failing value), value (the value that failed), type (the rule that failed), and inner (array of child ValidationError objects when abortEarly: false is passed). Check instanceof yup.ValidationError to distinguish Yup errors from unexpected runtime errors in catch blocks.
abortEarly
The abortEarly validate option controls whether Yup stops at the first validation failure (true, the default) or continues through the entire schema (false). With abortEarly: true, ValidationError.inner is empty and only the first error is reported. With abortEarly: false, inner contains one ValidationError per failing field, with path and message populated for each. API handlers and form validators should always pass { abortEarly: false } to give users complete feedback. Internal startup validation (config files, environment) may use the default to fail fast on the first misconfiguration.
cast
Casting in Yup converts an input value to the schema's expected type before validation rules run. yup.number().cast("42") returns the number 42. yup.boolean().cast("true") returns true. yup.object().cast(rawQueryObject) recursively casts all fields. .cast(value) does not run .test() refinements or check .required() — it only applies type coercion and .transform() functions. .validate() runs casting internally before applying constraints, so the returned value is always the cast type. Casting is essential for processing URL query parameters (always strings) through typed schemas.
InferType
InferType is a TypeScript utility type exported by Yup that extracts the output type of a schema at compile time. type User = InferType<typeof UserSchema> produces the TypeScript type representing the validated and cast output — the type you receive from a successful .validate() call. Fields with .required() are non-optional in the inferred type; fields with .optional() get | undefined; fields with .nullable() get | null; fields with .default() are non-optional regardless of schema optionality. InferType is a compile-time operation — zero runtime cost.
stripUnknown
The stripUnknown validate option removes fields not declared in the schema from the validated output object. Pass { stripUnknown: true } as the second argument to .validate() or .cast(). This is useful for sanitizing HTTP request bodies before database writes — extra fields submitted by clients are silently dropped rather than persisted. stripUnknown works recursively on nested objects. Without it, Yup passes through unknown fields by default (unlike Zod's .strict() mode which rejects them). To reject unknown fields instead of stripping them, use .noUnknown() in the schema definition.

FAQ

What is the difference between yup.validate() and yup.validateSync()?

.validate() is asynchronous — it returns a Promise that resolves to the cast, validated value on success and rejects with a ValidationError on failure. This makes it the correct choice for schemas containing async .test() validators (database uniqueness checks, remote API calls). .validateSync() runs synchronously and either returns the cast value or throws a ValidationError. Use .validateSync() only when you are certain no async validators are in the schema — calling it with async tests throws immediately rather than awaiting the async operations. Passing { abortEarly: false } to either method collects all validation errors instead of stopping at the first failure, returning a ValidationError whose inner array contains one error per field. For Formik and React Hook Form integrations, the resolver libraries call .validate() over the async path regardless of whether your schema has async validators, so you do not need to manually choose between them.

How do I collect all validation errors at once in Yup?

Pass { abortEarly: false } as the second argument to .validate() or .validateSync(). By default, Yup stops at the first error (abortEarly: true). With abortEarly: false, Yup traverses the entire schema and accumulates all failures before rejecting. The resulting ValidationError has an inner property — an array of ValidationError objects, one per field failure. Each inner error has a path string (e.g. "email", "address.zip", "items[0].price") and a message string. To build a field-to-message map: err.inner.reduce((acc, e) => ({ ...acc, [e.path]: e.message }), {}). Yup collects up to 1 error per field path — if a field fails multiple rules, only the first failing rule for that path appears in inner. The top-level err.errors is a flat string[] of all messages regardless of path.

How does Yup's InferType work for TypeScript?

Import InferType from yup and pass typeof yourSchema as the type argument: type User = InferType<typeof UserSchema>. Yup infers the output type — the shape after casting, transforms, and defaults — from the schema definition. A field marked .required() produces a non-optional type; .optional() adds | undefined; .nullable() adds | null. Fields with .default(value) produce a non-optional output type even if the input field is absent, because Yup fills in the default during validation. InferType is a pure TypeScript compile-time operation with zero runtime cost — no code generation, no separate .d.ts file. For schemas with .transform() calls that change the type (e.g., string to Date), InferType reflects the post-transform type. Yup 1.x significantly improved InferType accuracy for nullable and optional combinations compared to 0.32.x.

How do I validate optional and nullable fields in Yup?

Yup treats optional and nullable as distinct. Use .optional() to allow a field to be absent or undefinedInferType adds | undefined. Use .nullable() to allow a field to be nullInferType adds | null. Use .nullable().optional() to allow both null and undefined. By default, Yup string fields reject null even without .required() — always add .nullable() explicitly for fields that may be null in your JSON. Fields are optional by default in Yup schemas (absent fields pass unless .required() is set), which is the opposite of Zod's default behavior. For PATCH endpoints, declare all fields as .optional() in a separate update schema rather than modifying the base create schema. Yup 1.0 changed how nullable interacts with the type inference — consult the 1.x migration guide if you are upgrading from 0.32.x.

How do I convert a JSON Schema to a Yup schema?

Use the json-schema-to-yup package (npm install json-schema-to-yup) to programmatically convert a JSON Schema Draft-07 document to a Yup schema. Call buildYup(jsonSchema, config) where jsonSchema is your JSON Schema object and config provides an optional error message map. The converter handles type, required, properties, minimum/maximum, minLength/maxLength, pattern, enum, and basic nested objects. For more complex schemas with oneOf, allOf, $defs, or $ref cross-file references, the conversion is partial — review and augment the generated schema manually. JSON Schema keywords map to Yup methods: "minimum".min(n), "pattern".matches(/regex/), "maxItems".array().max(n). The round-trip is lossy — Yup schemas are more expressive in some areas (async validators, custom transforms) and less expressive in others ($ref cross-file, complex composition).

How does Yup integrate with Formik?

Formik has built-in Yup support — no extra package required. Pass your Yup schema to the validationSchema prop of <Formik> or useFormik: <Formik validationSchema={MySchema} ...>. Formik calls schema.validate(values, { abortEarly: false }) internally on submit and (optionally) on change/blur depending on the validateOnChange and validateOnBlur props (both default to true). The resulting errors object is automatically shaped to match the Yup schema — errors.email gives the message string for the email field. Formik has supported Yup since version 1.x, released in 2018, making it the most mature Yup integration. Use InferType<typeof MySchema> as the generic type for useFormik<InferType<typeof MySchema>> to get fully typed values, errors, and touched objects. For schemas with async validators, disable validateOnChange to avoid triggering remote calls on every keystroke.

How do I write async custom validators in Yup?

Use .test(name, message, asyncFn) where asyncFn returns a Promise<boolean> or a Promise<yup.ValidationError>. The function receives the current field value and a context object (this) that exposes this.createError({'{ message, path }'}) for dynamic error messages — use a regular function (not an arrow function) to access this. Example: yup.string().email().test("unique", "Email registered", async val => { if (!val) return true; return !(await db.exists({ email: val })) }). Always return true for absent or empty values inside async tests — let .required() handle presence separately. The test name must be unique per field; duplicate names overwrite earlier tests. Because the function is async, you must call schema.validate() (not validateSync()). Yup runs multiple .test() calls on the same field in parallel, so 2 async tests on one field fire concurrently, not sequentially. Wrap async calls in a timeout if the external service may be slow.

How do I validate a JSON array of objects with Yup?

Use yup.array().of(itemSchema) where itemSchema describes each element. Chain .min(1) to require at least 1 item, .max(100) to cap the array length, and .required() to disallow undefined. Example: yup.array().of(yup.object().shape({ id: yup.string().required(), price: yup.number().positive().required() })).min(1).required(). When validating with { abortEarly: false }, item errors appear in ValidationError.inner with paths like "items[0].price" and "items[2].id". The InferType of yup.array().of(ItemSchema) resolves to InferType<typeof ItemSchema>[] — a typed array with zero extra declarations. Yup validates each array element sequentially by default and checks all elements when abortEarly: false is set. For large arrays with 1000+ elements, validation time scales linearly — consider validating a representative sample or streaming validation for very large payloads. yup.array() without .of() accepts any array.

Validate your JSON instantly

Paste any JSON into Jsonic's validator to check syntax and structure — no setup required.

Open JSON Validator

Further reading and primary sources