Yup TypeScript: Strongly Typed Applications
Schema validation is a cornerstone of any robust application. Whether you are parsing user input from a form, validating an API response, or guarding configuration values, you need a way to ensure data conforms to an expected shape. Yup is a popular, schema-based JavaScript validator with a fluent, chainable API. When paired with TypeScript, Yup becomes significantly more powerful: you can derive static types directly from your schemas, eliminating the duplication between runtime validation and compile-time types.
This tutorial walks through what Yup is, why combining it with TypeScript matters, how to use it effectively, and the best practices that will keep your codebase maintainable as it scales.
>>> NEW: The Self-Hosted Agent Factory — Production Kit
Run a 3-agent production line (orchestrator + workers + adversarial QC) on a $6/mo VPS. 120-page book + the complete working Docker stack: task ledger, model router, 15-min watchdog, QC rubric.
What Is Yup?
Yup is a schema builder for runtime value parsing and validation. It lets you define schemas that describe the structure of your data — strings, numbers, booleans, dates, arrays, and objects — along with constraints such as minimum length, required fields, regex patterns, and custom rules. At runtime, Yup validates incoming data against the schema and either returns a cleaned, cast value or throws a validation error.
Unlike JSON Schema, Yup is designed to be written as code. Its API is expressive and chainable, which makes schemas easy to read and modify. Because Yup performs both validation and type casting, it is well-suited for transforming raw input (such as form strings) into properly typed values before they reach your business logic.
Why Yup With TypeScript Matters
Without a tool like Yup, TypeScript developers often face a painful choice. They can define TypeScript interfaces for their data and trust that incoming values match those types — but TypeScript types are erased at runtime, so malformed data from an API or a form will silently slip through. Alternatively, they can write runtime validation logic by hand, but then they must keep that logic in sync with their TypeScript types manually. This duplication is error-prone: change the interface, forget to update the validator, and you have a bug.
Yup solves this by being the single source of truth. You define a Yup schema once, and from it you derive both the runtime validator and the TypeScript type. If the schema changes, the type changes automatically. This approach, often called schema-first design, gives you:
- Runtime safety: Invalid data is caught before it reaches your application logic.
- Compile-time safety: TypeScript knows the exact shape of validated data.
- No duplication: One schema definition drives both validation and types.
- Self-documenting code: The schema reads as a description of your data.
Installing Yup
Yup ships with its own TypeScript types, so no additional @types package is needed. Install it with your preferred package manager:
npm install yup
# or
yarn add yup
# or
pnpm add yup
Yup version 1.x is the current major version and includes significantly improved TypeScript inference compared to earlier releases. The examples below assume Yup 1.x.
Defining a Basic Schema
Let us start with a simple user registration schema. We will validate a username, email, age, and whether the user has accepted the terms of service.
import * as yup from 'yup';
const userSchema = yup.object({
username: yup.string().required().min(3).max(20),
email: yup.string().email().required(),
age: yup.number().positive().integer().required(),
acceptsTerms: yup.boolean().oneOf([true], 'You must accept the terms'),
});
type User = yup.InferType<typeof userSchema>;
// Equivalent to:
// {
// username: string;
// email: string;
// age: number;
// acceptsTerms: boolean;
// }
The yup.InferType utility extracts a TypeScript type from the schema. Notice that we did not write a separate interface User — the type is derived entirely from the schema. If we later add a field to the schema, the User type updates automatically.
Validating Data
Yup provides two primary methods for validation: validate and validateSync. The asynchronous version is preferred when your schema might include async tests, such as checking whether an email is already registered.
import * as yup from 'yup';
const userSchema = yup.object({
username: yup.string().required().min(3),
email: yup.string().email().required(),
});
const rawInput = {
username: 'ada',
email: 'not-an-email',
};
try {
const validUser = await userSchema.validate(rawInput);
console.log(validUser);
} catch (err) {
if (err instanceof yup.ValidationError) {
console.error(err.message); // "email must be a valid email"
}
}
By default, validate aborts on the first error. If you want to collect every error — useful for form validation where you want to display all problems at once — pass the abortEarly: false option:
try {
const validUser = await userSchema.validate(rawInput, { abortEarly: false });
} catch (err) {
if (err instanceof yup.ValidationError) {
err.inner.forEach((error) => {
console.error(error.path, error.message);
});
}
}
Optional and Nullable Fields
Yup distinguishes between undefined (the field is absent) and null (the field is present but empty). By default, fields are optional and allow undefined. The required() method forbids both undefined and null, while nullable() explicitly allows null.
const profileSchema = yup.object({
displayName: yup.string().required(),
bio: yup.string().optional().default(''),
avatarUrl: yup.string().url().nullable(),
age: yup.number().nullable().notRequired(),
});
type Profile = yup.InferType<typeof profileSchema>;
// {
// displayName: string;
// bio?: string;
// avatarUrl: string | null;
// age?: number | null;
// }
Understanding these modifiers is essential because they directly affect the inferred TypeScript type. A field marked nullable() becomes a union with null, and a field marked optional() becomes optional in the type.
Nested Objects and Arrays
Real-world data is rarely flat. Yup handles nested objects and arrays naturally, and TypeScript inference follows along.
const orderSchema = yup.object({
id: 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(),
unitPrice: yup.number().positive().required(),
})
).min(1, 'Order must contain at least one item'),
shippingAddress: yup.object({
street: yup.string().required(),
city: yup.string().required(),
postalCode: yup.string().required(),
country: yup.string().length(2).required(),
}).required(),
});
type Order = yup.InferType<typeof orderSchema>;
const sampleOrder: Order = {
id: '550e8400-e29b-41d4-a716-446655440000',
customer: { name: 'Grace', email: 'grace@example.com' },
items: [
{ sku: 'ABC-001', quantity: 2, unitPrice: 19.99 },
],
shippingAddress: {
street: '1 Main St',
city: 'London',
postalCode: 'SW1A1AA',
country: 'GB',
},
};
Because Order is inferred from the schema, TypeScript will flag any structural mismatch — a missing field, a wrong type, or an extra property — at compile time, before you ever run the code.
Type Casting and Transformation
Form inputs almost always arrive as strings. Yup can cast values during validation, converting strings to numbers, parsing dates, or trimming whitespace. This is one of Yup's most valuable features when combined with TypeScript, because the inferred type reflects the cast type, not the input type.
const formSchema = yup.object({
quantity: yup.number().transform((value, originalValue) => {
if (typeof originalValue === 'string') {
return originalValue.trim() === '' ? NaN : Number(originalValue);
}
return value;
}).typeError('Quantity must be a number').positive().required(),
startDate: yup.date().required(),
tags: yup.array(yup.string().required()).transform((value, originalValue) => {
if (typeof originalValue === 'string') {
return originalValue.split(',').map((t) => t.trim()).filter(Boolean);
}
return value;
}).default([]),
});
const rawFormData = {
quantity: '5',
startDate: '2024-01-15',
tags: 'typescript, yup, validation',
};
const parsed = await formSchema.validate(rawFormData);
// parsed.quantity is the number 5
// parsed.startDate is a Date object
// parsed.tags is ['typescript', 'yup', 'validation']
Here, the inferred type of quantity is number, even though the raw input is a string. This means downstream code can safely perform arithmetic without additional checks.
Custom Validation Tests
Yup's built-in constraints cover most needs, but sometimes you need custom logic. The test method lets you add arbitrary validation rules. You can also create reusable test functions.
const passwordSchema = yup
.string()
.required()
.min(8)
.matches(/[A-Z]/, 'Must contain an uppercase letter')
.matches(/[a-z]/, 'Must contain a lowercase letter')
.matches(/[0-9]/, 'Must contain a number')
.test(
'no-common-passwords',
'Password is too common',
(value) => !['password', '12345678', 'qwertyui'].includes(value ?? '')
);
const signupSchema = yup.object({
email: yup.string().email().required(),
password: passwordSchema,
confirmPassword: yup
.string()
.required()
.oneOf([yup.ref('password')], 'Passwords must match'),
});
The yup.ref function lets one field reference another, which is perfect for cross-field validation like password confirmation. For more complex cross-field logic, use the object-level test method:
const dateRangeSchema = yup.object({
startDate: yup.date().required(),
endDate: yup.date().required(),
}).test('date-range', 'End date must be after start date', (value) => {
if (!value?.startDate || !value?.endDate) return true;
return value.endDate > value.startDate;
});
Conditional Validation With when
Sometimes a field's validation rules depend on another field's value. Yup's when method handles this elegantly.
const paymentSchema = yup.object({
method: yup.string().oneOf(['card', 'paypal', 'bank']).required(),
cardNumber: yup.string().when('method', {
is: 'card',
then: (schema) => schema.required().matches(/^\d{16}$/, 'Invalid card number'),
otherwise: (schema) => schema.optional(),
}),
paypalEmail: yup.string().when('method', {
is: 'paypal',
then: (schema) => schema.email().required(),
otherwise: (schema) => schema.optional(),
}),
bankAccount: yup.string().when('method', {
is: 'bank',
then: (schema) => schema.required(),
otherwise: (schema) => schema.optional(),
}),
});
Be aware that complex when conditions can sometimes produce union types in the inferred output. If the inferred type becomes unwieldy, consider casting the schema or splitting it into multiple schemas selected by a discriminator.
Integrating With React Forms
Yup pairs naturally with form libraries. The most common pairing is with react-hook-form, which accepts a Yup resolver via @hookform/resolvers.
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('Enter a valid email').required('Email is required'),
password: yup.string().min(8, 'Password must be at least 8 characters').required(),
});
type FormData = yup.InferType<typeof schema>;
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
resolver: yupResolver(schema),
});
const onSubmit = (data: FormData) => {
// data is fully typed and validated
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>
);
}
Because FormData is inferred from the same schema that the resolver uses, the form values, the validation rules, and the TypeScript type are all guaranteed to stay in sync.
Validating API Responses
Another high-value use case is validating data fetched from external APIs. Network responses are inherently untrusted — a backend change or a proxy issue can alter the shape of your data. Validating at the boundary prevents malformed data from propagating through your application.
import * as yup from 'yup';
const apiUserSchema = yup.object({
id: yup.number().integer().positive().required(),
name: yup.string().required(),
email: yup.string().email().required(),
role: yup.string().oneOf(['admin', 'editor', 'viewer']).required(),
createdAt: yup.string().required(),
});
type ApiUser = yup.InferType<typeof apiUserSchema>;
async function fetchUser(id: number): Promise<ApiUser> {
const res = await fetch(`/api/users/${id}`);
const json = await res.json();
return apiUserSchema.validate(json, { abortEarly: false });
}
const user = await fetchUser(42);
// user.role is 'admin' | 'editor' | 'viewer'
// TypeScript narrows it automatically
By validating at the fetch boundary, every consumer of fetchUser receives data that is both type-safe and runtime-verified. If the API ever returns an unexpected shape, the error is caught immediately rather than causing a cryptic failure deep in a component.
Reusing and Composing Schemas
As your application grows, you will want to reuse schema fragments. Yup schemas are plain objects, so composition is straightforward.
import * as yup from 'yup';
const addressSchema = yup.object({
street: yup.string().required(),
city: yup.string().required(),
postalCode: yup.string().required(),
country: yup.string().length(2).required(),
});
const contactSchema = yup.object({
phone: yup.string().matches(/^\+?[\d\s-]+$/).required(),
email: yup.string().email().required(),
});
const customerSchema = yup.object({
name: yup.string().required(),
contact: contactSchema,
billingAddress: addressSchema,
shippingAddress: addressSchema.nullable().notRequired(),
});
type Customer = yup.InferType<typeof customerSchema>;
Composing schemas this way keeps your code DRY and makes the data model easy to reason about. The inferred Customer type automatically reflects the nested structure.
Best Practices
To get the most out of Yup with TypeScript, follow these guidelines:
- Always derive types from schemas. Never hand-write a TypeScript interface that duplicates a Yup schema. Use
yup.InferTypeoryup.InferTypeon the schema to keep types in sync automatically. - Validate at boundaries. Validate data when it enters your system — form submissions, API responses, URL parameters, localStorage reads. Once validated, you can trust the data throughout your application.
- Use
abortEarly: falsefor forms. Collecting all errors at once provides a much better user experience than showing one error at a time. - Prefer casting over manual conversion. Let Yup transform raw input into the correct types rather than writing ad-hoc parsing code. This keeps your types accurate and your logic centralized.
- Keep schemas small and composable. Break large schemas into reusable fragments. This improves readability and makes testing easier.
- Write descriptive error messages. Default messages are often generic. Provide custom messages for user-facing fields so that error display requires no additional mapping logic.
- Be cautious with
whenand complex unions. Deeply conditional schemas can produce complex inferred types. If inference becomes unwieldy, consider splitting into multiple schemas or using a type assertion with a documented reason. - Test your schemas. Schemas are code. Write unit tests that assert both valid and invalid inputs, including edge cases like empty strings,
null, andundefined. - Avoid
anyin custom tests. When writingtestcallbacks, type your parameters explicitly to keep TypeScript's safety guarantees intact.
Common Pitfalls
Even with a good understanding of Yup, a few traps catch developers repeatedly:
- Forgetting that TypeScript types are erased at runtime. A variable typed as
Useris not validated. You must run the Yup schema on the actual data to guarantee its shape. - Confusing
optionalandnullable.optional()allowsundefined;nullable()allowsnull. They are independent and produce different TypeScript types. - Using
castwithoutvalidate.casttransforms values but does not enforce constraints. If you need both transformation and validation, usevalidatewith thestripUnknownorstrictoptions as appropriate. - Ignoring
stripUnknown. By default, Yup preserves unknown keys in objects. If you want to silently drop unexpected fields, pass{ stripUnknown: true }tovalidate.
Conclusion
Yup and TypeScript are a natural pairing for building strongly typed applications. By making your Yup schema the single source of truth, you gain runtime validation, accurate compile-time types, and a self-documenting description of your data — all without the maintenance burden of keeping separate interfaces and validators in sync. Whether you are validating form input, guarding API responses, or parsing configuration, a schema-first approach with Yup keeps your data trustworthy and your codebase resilient to change. Start small by validating one boundary in your application, derive the type with InferType, and expand from there. Once you experience the confidence that comes from end-to-end type safety, you will not want to go back.