Articles

Zod vs Yup: TypeScript Schema Validation Compared

Zod infers static TypeScript types directly from its schemas; Yup was built for JavaScript form validation first. How the two approaches differ.

Takina Takina · · 5 min read
Dark-themed code editor with syntax highlighting

Zod and Yup are both runtime schema validation libraries for JavaScript and TypeScript — they check that data (an API response, a form submission, an environment variable) actually matches the shape your code expects, something TypeScript’s own types can’t do because they vanish at compile time. The core difference is direction: Zod schemas are the single source of truth that TypeScript types are inferred from, while Yup schemas describe validation rules that you typically type separately.

Why runtime validation exists at all

TypeScript’s type system is a compile-time check. It’s happy to let you declare const user: User = JSON.parse(response), but at runtime that’s just an assertion — nothing actually verifies the parsed JSON matches User. If an API changes shape, a form field is missing, or a config file has a typo, TypeScript won’t catch it; the mismatch surfaces later as an obscure runtime error. Schema validation libraries close that gap by checking data against a schema at runtime and either returning validated data or a structured list of errors. See type guards for the narrower, hand-written version of the same idea.

How Zod works

Zod defines a schema as executable code, and derives both the runtime validator and the static type from it:

import { z } from "zod";

const UserSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  age: z.number().int().positive().optional(),
});

type User = z.infer<typeof UserSchema>;

const result = UserSchema.safeParse(input);
if (result.success) {
  // result.data is typed as User
} else {
  // result.error.issues has structured validation errors
}

z.infer<typeof Schema> generates the TypeScript type directly from the schema, so the type and the validation logic can never drift apart — there’s only one definition to maintain. Zod schemas compose with .extend(), .merge(), .pick(), and .omit(), and support unions, discriminated unions (a natural match for TypeScript’s own discriminated unions), and custom refinements via .refine().

How Yup works

Yup predates the current wave of TypeScript-first validators — it grew up alongside Formik as a form validation library for plain JavaScript, and TypeScript support was added on top rather than being the design’s starting point:

import * as yup from "yup";

const userSchema = yup.object({
  id: yup.string().uuid().required(),
  email: yup.string().email().required(),
  age: yup.number().integer().positive(),
});

type User = yup.InferType<typeof userSchema>;

try {
  const user = await userSchema.validate(input);
} catch (err) {
  // err.errors is a string array
}

Yup also supports type inference via InferType, and its API (.required(), .min(), .shape()) will feel familiar to anyone who’s used it inside a Formik or React Hook Form setup — that ecosystem integration is where Yup has the longest track record.

Comparison

ZodYup
Type inferenceFirst-class (z.infer)Supported (InferType), added later
API styleChainable, TypeScript-firstChainable, JS-first with TS added on
Async validationSupportedSupported, historically a Yup strength
Ecosystem fitFramework-agnostic, common in API/RPC layersDeep roots in Formik and React Hook Form forms
Error outputStructured issue objects with pathsString-based error messages by default
Schema composition.extend(), .merge(), discriminated unions.shape(), .concat()

Error handling in practice

The two libraries also differ in how they surface failures, which matters once validation errors need to reach a user or an API response rather than just a developer console.

Zod’s safeParse() never throws — it returns a discriminated union with a success boolean, so handling both branches is a plain if check rather than a try/catch:

const result = UserSchema.safeParse(input);
if (!result.success) {
  return result.error.issues.map((issue) => ({
    path: issue.path.join("."),
    message: issue.message,
  }));
}

Each issue carries a path array pointing at exactly which nested field failed, which is useful for mapping errors back onto specific form fields or JSON keys without string-parsing an error message.

Yup’s validate() throws a ValidationError on failure (with a validateSync() variant for synchronous use), and that error’s .errors array is a flat list of message strings by default, though .inner exposes per-field detail similarly to Zod’s issue paths:

try {
  await userSchema.validate(input, { abortEarly: false });
} catch (err) {
  if (err instanceof yup.ValidationError) {
    console.log(err.inner.map((e) => e.message));
  }
}

The { abortEarly: false } option is worth knowing about specifically — without it, Yup stops at the first failing field, which is rarely what you want when showing a user every problem with a form at once.

Where each fits

Both libraries solve the same problem, so the choice usually comes down to context rather than capability:

  • Validating API responses or a REST API’s request bodies, where you want one schema to double as your TypeScript types — Zod’s inference-first design fits naturally, and it’s become the default choice in newer full-stack TypeScript frameworks and tRPC-style setups.
  • Validating forms in an existing Formik or React Hook Form codebase — Yup’s tight integration with those libraries and its long history there make it the path of least resistance.
  • Validating loosely-typed input (environment variables, config files, JSON from unknown) — either works, since both replace an as cast on unknown with an actual runtime check; Zod’s .safeParse() returning a discriminated result object tends to compose more cleanly with further type narrowing.

Neither replaces JSON Schema for cross-language contracts — both are JavaScript-specific — but both can generate or consume JSON Schema-like shapes for documentation and interop.

The takeaway

Zod and Yup both validate data at runtime and can produce a matching TypeScript type, but they start from different premises: Zod treats the schema as the canonical source that types are inferred from, which suits API and RPC boundaries in TypeScript-first codebases; Yup grew up validating forms in JavaScript and carries the deepest integration with Formik and React Hook Form. If you’re starting a new TypeScript project with no legacy form library to match, Zod’s inference-first model needs less glue code to keep types and validation in sync.

Takina Takina · · 4 min read

Structural Typing vs Nominal Typing in TypeScript

Structural typing checks shape, not name — TypeScript treats two differently-named types as compatible if their members match, unlike nominal systems.

#TypeScript #JavaScript #Web Development
Takina Takina · · 5 min read

TypeScript Variance Explained

Variance decides when TypeScript accepts Array<Dog> where Array<Animal> is expected — covariant, contravariant, or invariant, explained with examples.

#TypeScript #JavaScript #Web Development
Takina Takina · · 5 min read

TypeScript Declaration Files (.d.ts) Explained

A .d.ts file describes a library's types without its implementation. How TypeScript declaration files work, and how to write one for an untyped JS package.

#TypeScript #JavaScript #Web Development