Schema Validation with Yup: Strings, Numbers, Objects, Arrays, Conditional and Async Rules
Key takeaways
Yup is a schema-based validation library for JavaScript. It provides expressive schema definitions, async validation, and excellent integration with form libraries. This guide covers why schema validation beats manual checks, Yup's async and lazy validation model, common .test() and .when() gotchas, schema composition, TypeScript inference limits, and how Yup compares to Zod in practice.
Introduction
Yup is a JavaScript schema builder for value parsing and validation. It’s widely used with form libraries like Formik and React Hook Form.
Without Yup
function validateUser(data) {
const errors = {};
if (!data.name) {
errors.name = 'Name is required';
} else if (data.name.length < 2) {
errors.name = 'Name must be at least 2 characters';
}
if (!data.email) {
errors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
errors.email = 'Invalid email format';
}
if (!data.age) {
errors.age = 'Age is required';
} else if (data.age < 18) {
errors.age = 'Must be 18 or older';
}
return Object.keys(errors).length > 0 ? errors : null;
}
With Yup
import * as yup from 'yup';
const userSchema = yup.object({
name: yup.string().required().min(2),
email: yup.string().required().email(),
age: yup.number().required().min(18),
});
await userSchema.validate(data);
The difference between these two snippets is not just line count. The manual validateUser function mixes three concerns that schema-based validation deliberately separates: what shape the data must have, what error message to show, and how to run the checks. As the number of fields grows, the imperative version accumulates nested if/else branches that are easy to get wrong — a forgotten else silently lets invalid data through, and a copy-pasted branch can validate the wrong field. Schema-based validation instead treats the shape of valid data as a declarative value you can inspect, reuse, compose, and serialize. That declarative schema becomes a single source of truth that can drive form validation on the client, request validation on the server, and (with InferType, covered later) even your TypeScript types — all from one definition instead of three that can drift out of sync.
This matters in production too: manual validation functions tend to be rewritten per-form, which means the “same” business rule (e.g., “email is required and must be valid”) ends up implemented slightly differently in five places across a codebase. A shared Yup schema, by contrast, can be imported everywhere the rule applies, so a bug fix in one place fixes it everywhere.
Installation
npm install yup
Basic Schemas
import * as yup from 'yup';
// String
const nameSchema = yup.string();
const emailSchema = yup.string().email();
const urlSchema = yup.string().url();
// Number
const ageSchema = yup.number();
const priceSchema = yup.number().positive();
const quantitySchema = yup.number().integer().min(1);
// Boolean
const acceptedSchema = yup.boolean().oneOf([true]);
// Date
const birthdaySchema = yup.date();
const futureSchema = yup.date().min(new Date());
// Array
const tagsSchema = yup.array().of(yup.string());
const numbersSchema = yup.array().of(yup.number()).min(1).max(5);
// Object
const addressSchema = yup.object({
street: yup.string().required(),
city: yup.string().required(),
zipCode: yup.string().matches(/^\d{5}$/),
});
Every Yup schema is built from a small set of primitive types (string, number, boolean, date, array, object) that you compose using method chaining. Each method call — .email(), .min(), .positive() — returns a new schema instance rather than mutating the original, which is why you can safely branch a base schema into several variants (see “Composing schemas with .shape() and .concat()” below) without one variant’s rules leaking into another. This immutability is the same design principle behind libraries like Immutable.js or Immer: it makes schemas safe to share and pass around as plain values, because nothing downstream can accidentally corrupt a schema another part of the codebase is relying on.
One practical implication worth internalizing early: order matters for some chains, but not others. .required().min(2) and .min(2).required() produce the same validation outcome for a string, but with .when() conditional chains (see Conditional Validation) or .transform() calls, the order in which you attach behavior can change what value later checks actually see, because transforms run before subsequent validators evaluate the result.
Validation Methods
const schema = yup.object({
name: yup.string().required(),
email: yup.string().email().required(),
});
// Validate and throw on error
try {
await schema.validate({ name: 'Alice', email: 'invalid' });
} catch (error) {
console.error(error.message);
}
// Validate and return value or undefined
const result = await schema.isValid({ name: 'Alice', email: '[email protected]' });
console.log(result); // true
// Validate and get all errors
try {
await schema.validate({ name: '', email: 'invalid' }, { abortEarly: false });
} catch (error) {
console.log(error.errors); // Array of all error messages
}
// Validate synchronously (no async rules)
try {
schema.validateSync({ name: 'Alice', email: '[email protected]' });
} catch (error) {
console.error(error);
}
Yup exposes four distinct ways to run a schema, and picking the wrong one is a common source of confusing bugs:
.validate()returns a Promise, runs all validators including async.test()rules, and throws on the first failure by default (abortEarly: true). This is what you want for a final submit-time check where you only need to know “is this valid or not, and what’s the first problem.”.validate(data, { abortEarly: false })still throws, but the thrownValidationErrorcarries anerrorsarray with every failing message, not just the first one. This is what form UIs actually need — showing a user only the first of five problems with their input is a poor experience, so almost every form integration (including the React Hook Form resolver later in this guide) usesabortEarly: falseinternally..isValid()never throws; it resolves to a boolean. Reach for this when you only need a yes/no answer and don’t need error messages — for example, disabling a submit button..validateSync()runs the schema synchronously and throws on failure. It’s useful for validating small, purely-synchronous data structures (config objects, URL query params) without the overhead of a Promise chain, but it cannot run async.test()rules: if a test returns a Promise,validateSyncthrows an error saying the test returned a Promise during a synchronous validate — so it’s not a drop-in synchronous replacement for.validate()on arbitrary schemas.
A subtlety worth calling out: because .validate() is async even when none of your individual field rules are, Yup validation always introduces at least one microtask tick. For high-frequency validation (e.g., validating on every keystroke), that’s usually fine, but it does mean you can’t treat .validate() as free — debounce it in real-time input handlers rather than calling it on every onChange.
Required and Optional
import * as yup from 'yup';
const schema = yup.object({
// Required
name: yup.string().required('Name is required'),
// null allowed (and undefined, since fields are optional by default)
middleName: yup.string().nullable(),
// Optional (default value)
role: yup.string().default('user'),
// Optional: undefined or null allowed (Yup 1.x)
bio: yup.string().notRequired(),
// Conditionally required
phoneNumber: yup.string().when('contactMethod', {
is: 'phone',
then: (schema) => schema.required(),
otherwise: (schema) => schema.notRequired(),
}),
});
null and undefined are separate switches in Yup 1.x, and the method names do not make that obvious. Every schema starts out optional (undefined passes) and non-nullable (null fails). .nullable() lets null through, which suits fields backed by a database column that stores NULL for “not set”; .optional() / .defined() control undefined; .notRequired() is shorthand for optional and nullable; and .required() rejects both (and, for strings, the empty string). The common production bug follows from the defaults: yup.string().nullable() accepts null and a missing key, so an API contract that says “this field is always present, possibly null” needs yup.string().nullable().defined() to actually catch an omitted field. When working with a REST API or database layer, check its actual “unset” convention (null vs. omitted key) and match your Yup schema to it deliberately, rather than adding both modifiers defensively out of habit.
String Validation
yup.string()
.required('Required')
.min(2, 'Must be at least 2 characters')
.max(50, 'Must be less than 50 characters')
.email('Invalid email')
.url('Invalid URL')
.matches(/^[a-zA-Z]+$/, 'Only letters allowed')
.trim() // Remove whitespace
.lowercase() // Convert to lowercase
.uppercase(); // Convert to uppercase
// Custom validation
yup.string().test('is-strong-password', 'Password too weak', (value) => {
return /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/.test(value);
});
Note the gotcha hiding in that last example: .test() runs even when value is undefined (i.e., the field was never filled in), because .test() doesn’t know about .required() — they’re independent checks layered on the same schema. Calling .test(/^.../.test(value)) on an undefined value doesn’t throw (regex .test() coerces to the string "undefined"), but it silently produces a wrong verdict rather than the “field is required” message you probably want the user to see. The safe pattern is to short-circuit at the top of every custom test:
yup.string().test('is-strong-password', 'Password too weak', (value) => {
if (!value) return true; // let .required() own the "empty" case
return /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/.test(value);
});
Returning true for an empty value defers responsibility to whatever .required() rule is chained elsewhere on the same schema, avoiding duplicate or conflicting error messages for the same field.
Number Validation
yup.number()
.required()
.min(0, 'Must be non-negative')
.max(100, 'Must be 100 or less')
.positive('Must be positive')
.negative('Must be negative')
.integer('Must be an integer')
.lessThan(10)
.moreThan(0)
.round('floor'); // 'floor', 'ceil', 'trunc', 'round'
Object Validation
const userSchema = yup.object({
name: yup.string().required(),
email: yup.string().email().required(),
address: yup.object({
street: yup.string().required(),
city: yup.string().required(),
country: yup.string().required(),
}),
settings: yup.object().shape({
notifications: yup.boolean().default(true),
theme: yup.string().oneOf(['light', 'dark']).default('light'),
}),
});
// Validate
await userSchema.validate({
name: 'Alice',
email: '[email protected]',
address: {
street: '123 Main St',
city: 'New York',
country: 'USA',
},
});
Composing schemas with .shape() and .concat()
The settings field above uses .object().shape({...}) instead of yup.object({...}) directly — both work for defining fields from scratch, but .shape() is specifically the method you reach for when you already have an object schema and want to add or override fields on it, rather than replace it entirely. This matters once schemas grow past a single form:
const baseUserSchema = yup.object({
name: yup.string().required(),
email: yup.string().email().required(),
});
// Add fields without redefining the base ones
const adminUserSchema = baseUserSchema.shape({
role: yup.string().oneOf(['admin', 'superadmin']).required(),
});
// Merge two independently-defined object schemas
const contactSchema = yup.object({ phone: yup.string().required() });
const fullProfileSchema = baseUserSchema.concat(contactSchema);
.shape() mutates-by-extension on a single schema (technically returns a new schema with merged fields), while .concat() merges two separately defined schemas — useful when the two pieces are owned by different modules (e.g., a shared “auditable entity” schema concatenated onto several domain-specific schemas). A common mistake is trying to .concat() two schemas that define the same field with incompatible types (say, one has email: yup.string() and the other email: yup.number()); Yup does not raise a build-time error for this — the second schema’s rules simply win, which can silently produce a schema that validates nothing meaningfully for that field. When composing schemas from different sources, double-check overlapping keys manually.
Array Validation
// Array of strings
const tagsSchema = yup.array()
.of(yup.string())
.min(1, 'At least one tag required')
.max(5, 'Maximum 5 tags allowed');
// Array of objects
const usersSchema = yup.array().of(
yup.object({
id: yup.number().required(),
name: yup.string().required(),
email: yup.string().email().required(),
})
);
// Unique items
const uniqueEmailsSchema = yup.array()
.of(yup.string().email())
.test('unique', 'Emails must be unique', (values) => {
return values.length === new Set(values).size;
});
await tagsSchema.validate(['react', 'typescript', 'nextjs']);
Array schemas illustrate a subtle behavior difference from object schemas: .of() describes the shape of each element, and any element that fails validation aborts the whole array’s validation (with abortEarly: false, you get one error per failing element, indexed by position — e.g., usersSchema[2].email). This indexed error format is what makes it possible to highlight exactly which row in a dynamic list (say, a “bulk invite users” form with an add-row button) has the problem, rather than just reporting “something in the array is invalid.”
Conditional Validation
const schema = yup.object({
accountType: yup.string().oneOf(['personal', 'business']).required(),
// Required only for business accounts
companyName: yup.string().when('accountType', {
is: 'business',
then: (schema) => schema.required('Company name is required'),
otherwise: (schema) => schema.notRequired(),
}),
// Multiple conditions
taxId: yup.string().when(['accountType', 'country'], {
is: (accountType, country) => accountType === 'business' && country === 'US',
then: (schema) => schema.required('Tax ID required for US businesses'),
}),
});
.when() is Yup’s answer to a validation rule that manual if/else code handles naturally but declarative schemas otherwise struggle with: a field’s validity depends on another field’s value. The is function receives the sibling field’s current value(s) and returns a boolean; then/otherwise receive the field’s existing schema and must return a (possibly modified) schema, not a raw boolean. That distinction — passing a schema-transforming function rather than a schema — is where older examples break. Before Yup 1.0, then: yup.string().required() (a schema object) was accepted; in 1.x then and otherwise must be functions, and code copied from pre-1.0 tutorials fails with a TypeError when the condition is evaluated instead of applying the rule.
.when() is also where lazy evaluation becomes necessary rather than optional: Yup cannot know in advance which branch a given field will take, so it re-derives the applicable schema for each field on every validation run, using the current sibling values as input. This is more expensive than a static schema, which is why deeply nested .when() chains (conditions depending on conditions) are a common performance complaint in large forms — if you find yourself chaining more than two or three levels of .when(), it’s often a sign the form’s state machine belongs in application code (e.g., rendering entirely different form sections based on accountType) rather than being encoded entirely inside one schema.
Custom Validation
// Custom test
const passwordSchema = yup.string()
.test('strong-password', 'Password must include uppercase, lowercase, and number',
(value) => {
if (!value) return false;
return /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/.test(value);
}
);
// Async validation
const emailSchema = yup.string()
.email()
.test('unique-email', 'Email already exists', async (value) => {
const exists = await checkEmailExists(value);
return !exists;
});
// Access other fields
const schema = yup.object({
password: yup.string().required().min(8),
confirmPassword: yup.string()
.required()
.oneOf([yup.ref('password')], 'Passwords must match'),
});
The unique-email test above is where Yup’s async validation model earns its keep. Manual validation functions rarely check server-side uniqueness inline, because doing so cleanly inside nested if statements against a callback-based or Promise-based API check gets messy fast. Yup treats an async .test() exactly like a sync one from the schema author’s perspective — you just return a Promise (or use async/await) instead of a boolean — and the schema’s .validate() call automatically awaits every test, sync or async, before resolving. This is genuinely useful for things like “is this username already taken,” “does this coupon code exist,” or “is this file hash already uploaded,” where the check can only happen against a live backend.
The trade-off: every async .test() adds a network round trip to validation, so calling .validate() on every keystroke against a schema with an async uniqueness check will hammer your API. In practice, teams either debounce the field-level validation call (validating only the email field in isolation via schema.validateAt('email', data), not the whole form) or skip the async check until blur/submit and rely on a lighter sync check (format only) for live feedback. Also worth noting: yup.ref('password') used in confirmPassword above works by lazily resolving to the sibling field’s value at validation time — like .when(), it’s evaluated against whatever object you pass to .validate(), so cross-field checks like “passwords must match” only work when both fields are validated together as part of the same object schema, not when validating confirmPassword in isolation.
TypeScript Integration
import * as yup from 'yup';
import type { InferType } from 'yup';
const userSchema = yup.object({
name: yup.string().required(),
email: yup.string().email().required(),
age: yup.number().required().positive().integer(),
website: yup.string().url().nullable(),
});
// Infer TypeScript type from schema
type User = InferType<typeof userSchema>;
// Type is:
// {
// name: string;
// email: string;
// age: number;
// website?: string | null | undefined;
// }
async function createUser(data: unknown) {
const user: User = await userSchema.validate(data);
return user; // Fully typed!
}
InferType is what lets a Yup schema double as your TypeScript type definition, and it’s genuinely convenient — one schema instead of a schema plus a hand-written interface that can drift out of sync. But it’s important to understand the direction of inference: Yup infers types from the runtime schema shape, which is the opposite of how Zod works (Zod schemas are built specifically to make inference precise, and type-first is baked into its design). Two places where InferType commonly falls short in practice:
- Conditional fields built with
.when(). Because a.when()branch’s required-ness depends on runtime data,InferTypetypically widens the field to optional across the board (e.g.,companyName?: string) even though at runtime it’s required wheneveraccountType === 'business'. TypeScript has no way to encode “required only if this other field equals X” as a static type without a discriminated union, and Yup’s inference doesn’t attempt to generate one for you. .transform()calls that change the output type. If a schema transforms a string into aDate, or coerces a string number into anumber,InferTypereflects the output type correctly in modern Yup versions, but it’s easy to lose track of which type you’re looking at (input vs. output) when reading schema code that mixes casting and transforms — a source of confusing “type X is not assignable to type Y” errors far from where the schema is defined.
When a form has heavily conditional required-ness, many teams find it more maintainable to hand-write the TypeScript interface for the “committed” data shape (post-validation, post-business-logic) and use the Yup schema purely for validation, rather than fighting InferType to produce that same precision automatically.
React Hook Form Integration
npm install react-hook-form @hookform/resolvers
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
const schema = yup.object({
name: yup.string().required('Name is required').min(2),
email: yup.string().required('Email is required').email('Invalid email'),
age: yup.number().required('Age is required').positive().integer().min(18),
});
function SignupForm() {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: yupResolver(schema),
});
const onSubmit = (data) => {
console.log(data); // Validated data
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} />
{errors.name && <p>{errors.name.message}</p>}
<input {...register('email')} type="email" />
{errors.email && <p>{errors.email.message}</p>}
<input {...register('age')} type="number" />
{errors.age && <p>{errors.age.message}</p>}
<button type="submit">Submit</button>
</form>
);
}
Custom Error Messages
const schema = yup.object({
name: yup.string()
.required('Please enter your name')
.min(2, 'Name must be at least 2 characters'),
email: yup.string()
.required('Please enter your email')
.email('Please enter a valid email address'),
age: yup.number()
.required('Please enter your age')
.min(18, 'You must be 18 or older')
.max(100, 'Age must be 100 or under'),
});
// With interpolation
yup.string()
.min(5, 'Must be at least ${min} characters')
.max(20, 'Must be at most ${max} characters');
The ${min} and ${max} placeholders above aren’t real JavaScript template literals — they’re a special interpolation syntax that Yup itself parses out of the message string and substitutes with the actual constraint value at validation time. This is a deliberate design choice: because the message string is defined once when the schema is built but the failing value is only known at validation time, Yup needs its own placeholder mechanism rather than relying on standard template literal interpolation (which would evaluate immediately, before the constraint even runs). Every built-in validator exposes a documented set of interpolation keys — ${path}, ${value}, ${originalValue} are available on nearly all of them in addition to method-specific ones like ${min}/${max}, which is useful for building genuinely dynamic, localized error copy without writing a custom .test() for every field.
Yup vs. Zod
Yup and Zod solve the same underlying problem — schema-based runtime validation — but they were designed around different priorities, and the choice between them usually comes down to one question: is TypeScript your primary language, or an added layer on top of JavaScript?
| Yup | Zod | |
|---|---|---|
| Design priority | JavaScript-first, TypeScript as an add-on | TypeScript-first, JavaScript works too |
| Type inference | InferType, derived after the fact — weaker for conditional/.when() fields | Native inference is the core design goal; generally more precise |
| Async validation | First-class, mature (.test() with async callbacks) | Supported, slightly newer API surface |
| Ecosystem | Formik’s long-time default, mature React Hook Form resolver | Default choice for tRPC, common in newer Next.js/T3 Stack projects |
| Dependencies | A few small packages (property-expr, tiny-case, toposort) | Zero runtime deps |
In practice, this plays out as: if you’re maintaining an existing codebase already built on Formik or an older React Hook Form setup with Yup schemas throughout, there’s rarely a strong enough reason to migrate — Yup’s async validation and .when() conditional logic are both mature and well-documented, and a rewrite risks introducing regressions for marginal type-safety gains. If you’re starting a new TypeScript-first project, especially one using tRPC (which adopted Zod as its default validator) or building heavily on inferred types throughout a full-stack app, Zod’s inference model will save you real friction, particularly around conditional field types where Yup’s InferType tends to fall back to overly permissive unions. Neither library is “wrong” — they optimize for different points in a project’s lifecycle, and the switching cost of moving an established form-heavy codebase from one to the other is usually higher than the marginal benefit.
Frequently Asked Questions (FAQ)
Q. When would I use Yup in practice?
A. Reach for Yup whenever you need to validate a shape of data at runtime — form submissions, API request bodies, config files, or environment variables — and want a single declarative schema instead of scattered if/else checks. It’s especially strong when validation depends on a server round trip (uniqueness checks) or on other fields in the same object (.when(), yup.ref()).
Q. Do I need Formik or React Hook Form to use Yup?
A. No. Yup works standalone — schema.validate(data) returns a Promise regardless of what UI framework, if any, you’re using. Formik and React Hook Form integrations (via @hookform/resolvers/yup) are convenience layers, not a requirement.