Yup from Beginner to Expert: A Learning Path
Form validation is one of those tasks every developer encounters, and doing it well can be the difference between a smooth user experience and a frustrating one. Yup is a JavaScript schema builder for value parsing and validation. It allows you to define schemas that describe the shape and constraints of your data, then validate values against those schemas in a clean, declarative way. In this tutorial, we will walk through Yup from the basics all the way to advanced patterns used by seasoned developers.
What is Yup?
Yup is a lightweight, schema-based validation library inspired by Joi but designed to work seamlessly in the browser and Node.js. A "schema" in Yup is an object that describes the expected structure, types, and constraints of your data. You can validate objects, strings, numbers, arrays, dates, and even custom types. Yup also supports type casting, which means it can transform input values into the types your schema expects.
Why Yup Matters
Writing validation logic by hand quickly becomes error-prone and hard to maintain. You end up with long chains of if statements, duplicated logic, and inconsistent error messages. Yup solves this by letting you express validation rules declaratively. This matters because:
- Readability: Schemas read like a description of your data.
- Reusability: Schemas can be composed, extended, and shared across forms or API layers.
- Consistency: Error messages and validation logic are centralized.
- Integration: Yup works beautifully with form libraries like Formik and React Hook Form.
- Type safety: When paired with TypeScript, Yup schemas can generate types, reducing drift between runtime validation and compile-time types.
Getting Started: Installation and First Schema
Install Yup using your preferred package manager:
npm install yup
# or
yarn add yup
# or
pnpm add yup
Let's create a simple schema that validates a user object:
import * as yup from 'yup';
const userSchema = yup.object({
name: yup.string().required('Name is required'),
email: yup.string().email('Must be a valid email').required('Email is required'),
age: yup.number().positive().integer().required(),
});
const validUser = {
name: 'Alice',
email: 'alice@example.com',
age: 30,
};
userSchema.validate(validUser)
.then(value => console.log('Valid:', value))
.catch(err => console.log('Error:', err.errors));
Here we defined an object schema with three fields. Each field has its own type and constraints. The validate method returns a promise that resolves with the validated (and optionally cast) value or rejects with an error containing the errors array of messages.
Core Concepts: Types and Methods
Yup provides schema constructors for common types: string, number, boolean, date, array, object, mixed (for any value), and tuple schemas in newer versions. Each schema type exposes a fluent API of validation methods.
Common string methods include min, max, matches, email, url, required, and oneOf. Number methods include min, max, positive, negative, integer, and lessThan. Here is a richer example:
const passwordSchema = yup.string()
.min(8, 'Password must be at least 8 characters')
.matches(/[A-Z]/, 'Must contain an uppercase letter')
.matches(/[0-9]/, 'Must contain a number')
.matches(/[^A-Za-z0-9]/, 'Must contain a special character')
.required('Password is required');
Validating Objects and Nested Structures
Real-world data is rarely flat. Yup handles nested objects and arrays naturally:
const orderSchema = yup.object({
orderId: yup.string().uuid().required(),
customer: yup.object({
name: yup.string().required(),
email: yup.string().email().required(),
}).required(),
items: yup.array(
yup.object({
sku: yup.string().required(),
quantity: yup.number().integer().positive().required(),
price: yup.number().positive().required(),
})
).min(1, 'Order must contain at least one item'),
shippingAddress: yup.object({
street: yup.string().required(),
city: yup.string().required(),
zip: yup.string().matches(/^\d{5}$/, 'Invalid zip code').required(),
}),
});
Notice how yup.array takes a sub-schema that each element must satisfy. Nested objects are simply yup.object schemas embedded within the parent.
Conditional Validation with when
One of Yup's most powerful features is conditional validation using when. This lets you change the schema for a field based on the value of sibling fields:
const registrationSchema = yup.object({
accountType: yup.string().oneOf(['personal', 'business']).required(),
companyName: yup.string().when('accountType', {
is: 'business',
then: schema => schema.required('Company name is required for business accounts'),
otherwise: schema => schema.notRequired(),
}),
taxId: yup.string().when('accountType', {
is: 'business',
then: schema => schema.required('Tax ID is required for business accounts'),
otherwise: schema => schema.notRequired(),
}),
});
The when method accepts a field name (or array of names) and a configuration object. The is predicate determines which branch to use, and then / otherwise return the modified schema.
Custom Validation with test
When built-in methods are not enough, you can write custom validators using test:
const usernameSchema = yup.string().test(
'unique-username',
'Username is already taken',
async function (value) {
if (!value) return true;
const isAvailable = await checkUsernameAvailability(value);
return isAvailable;
}
);
The test method accepts a name, a message, and a function that returns a boolean or a promise. Inside the test function, this gives you access to the schema context and the createError helper for more control:
const evenNumberSchema = yup.number().test(
'is-even',
'Number must be even',
function (value) {
if (value === undefined) return true;
if (value % 2 !== 0) {
return this.createError({ message: `${value} is not even` });
}
return true;
}
);
Working with Context and Cross-Field Validation
Sometimes you need data that is not part of the validated object. Yup lets you pass context to validate:
const transferSchema = yup.object({
amount: yup.number().positive().required(),
}).test(
'sufficient-balance',
'Insufficient balance',
function (value) {
const balance = this.options.context?.balance ?? 0;
return value && value.amount <= balance;
}
);
transferSchema.validate(
{ amount: 500 },
{ context: { balance: 300 } }
).catch(err => console.log(err.errors));
This pattern is useful for server-side validation where you need to check against database values without embedding them in the payload.
Validation Options: abortEarly and stripUnknown
By default, Yup stops validating at the first error. For forms, you usually want all errors at once. Pass abortEarly: false:
try {
await schema.validate(values, { abortEarly: false });
} catch (err) {
// err.inner is an array of all validation errors
const formErrors = err.inner.reduce((acc, error) => {
acc[error.path] = error.message;
return acc;
}, {});
}
Other useful options include stripUnknown to remove fields not in the schema, strict to disable type casting, and recursive to control nested validation.
Integrating Yup with React Hook Form
Yup pairs naturally with React Hook Form via the @hookform/resolvers package:
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
const schema = yup.object({
email: yup.string().email().required(),
password: yup.string().min(8).required(),
});
function LoginForm() {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: yupResolver(schema),
});
const onSubmit = (data) => console.log(data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" {...register('password')} />
{errors.password && <p>{errors.password.message}</p>}
<button type="submit">Submit</button>
</form>
);
}
Yup and TypeScript: Generating Types
Keeping TypeScript types in sync with validation schemas is a common pain point. The yup package exports a type inference helper. In newer versions, you can use InferType:
import * as yup from 'yup';
const productSchema = yup.object({
id: yup.string().required(),
name: yup.string().required(),
price: yup.number().positive().required(),
tags: yup.array(yup.string()).default([]),
});
type Product = yup.InferType<typeof productSchema>;
// Equivalent to:
// { id: string; name: string; price: number; tags: string[] }
For more advanced scenarios, libraries like @sinclair/typebox or code generators can produce both Yup schemas and TypeScript types from a single source of truth.
Best Practices
- Keep schemas modular: Define small, reusable schemas and compose them. This avoids duplication and makes changes easier.
- Use descriptive messages: Always provide custom error messages that guide the user, rather than relying on Yup's defaults.
- Validate on the server too: Never trust client-side validation alone. Reuse the same Yup schemas on your backend for consistency.
- Use
abortEarly: falsefor forms: Users want to see all errors at once, not one at a time. - Prefer
oneOfandnotOneOffor enums: They produce clearer errors than customtestfunctions for simple cases. - Avoid heavy async tests in hot paths: Custom async tests (like uniqueness checks) can slow validation. Debounce them on the client and run them on the server.
- Strip unknown fields: Use
stripUnknownwhen validating untrusted input to avoid passing unexpected data downstream. - Test your schemas: Treat schemas like code. Write unit tests that assert both valid and invalid inputs produce the expected results.
Advanced Pattern: Schema Composition and Reuse
As your application grows, you will want to share schemas. Yup schemas are immutable, so methods return new schema instances. You can extend them with concat or shape:
const baseAddressSchema = yup.object({
street: yup.string().required(),
city: yup.string().required(),
zip: yup.string().required(),
});
const billingAddressSchema = baseAddressSchema.shape({
zip: yup.string().matches(/^\d{5}$/, 'Invalid zip'),
});
const shippingAddressSchema = baseAddressSchema.concat(
yup.object({
deliveryInstructions: yup.string().max(200),
})
);
This approach lets you build a library of small schemas and assemble them into complex shapes without repeating yourself.
Conclusion
Yup takes the pain out of validation by turning it into a declarative, composable, and reusable exercise. Starting from simple string and number checks, you can grow into nested objects, conditional rules, custom async tests, and full integration with your form and API layers. By following best practices like modular schemas, descriptive messages, and server-side reuse, you will build validation logic that is robust, maintainable, and pleasant to work with. Whether you are validating a login form or a complex multi-step checkout flow, Yup gives you the tools to handle it with confidence.