TypeScript Runtime Validation with Zod: Type-Safe Schemas for APIs, Forms, and JSON
zodruntime validationapi typingschemasjson validationtype safety

TypeScript Runtime Validation with Zod: Type-Safe Schemas for APIs, Forms, and JSON

TTypeScript Toolbox Editorial Team
2026-11-01
8 min read

Learn how Zod validates untrusted API, form, and JSON data at runtime, with practical comparisons to Yup and Valibot.

TypeScript can describe the shape of data during development, but it cannot verify values that arrive from an API, form, environment, or JSON file at runtime. This guide shows how to use Zod for TypeScript runtime validation, how to compare it with Yup and Valibot, and how to build reusable schemas that keep boundaries such as API routes and forms dependable.

Overview

TypeScript types are removed when your application is compiled to JavaScript. A type such as type User = { id: string; email: string } helps the compiler check code you write, but it does not inspect an object returned by fetch. At runtime, that object could be missing email, contain a number instead of a string, or include a value that is structurally valid but unacceptable to the application.

This distinction is the reason runtime validation belongs at trust boundaries. Typical boundaries include:

  • JSON responses from third-party or internal APIs
  • Request bodies, query parameters, and route parameters
  • Browser form submissions
  • Environment variables, which arrive as strings
  • Persisted data read from files, caches, or databases
  • Messages received from queues, webhooks, or other services

Zod provides a schema-first approach. You define a runtime schema, use it to parse unknown data, and infer a TypeScript type from that same schema. This avoids maintaining separate declarations for validation and static typing. A schema should not replace domain rules or authorization checks, but it creates a clear, testable first line of defense.

import { z } from "zod";

const UserSchema = z.object({
  id: z.string(),
  email: z.string().email(),
  displayName: z.string().min(1)
});

type User = z.infer<typeof UserSchema>;

The inferred User type follows the schema. If the schema changes, the type changes with it, reducing the risk that validation and application code gradually drift apart.

How to compare options

A useful comparison is less about finding a universal winner and more about matching a validator to the boundary, team, and runtime constraints. Evaluate each option against the same questions.

  1. How are schemas authored? Consider whether the API reads naturally to your team and whether common rules such as unions, nested objects, optional fields, transformations, and custom refinements are easy to express.
  2. How are TypeScript types produced? A schema that can generate types reduces duplication. Also check whether the inferred output represents the data before or after transformations.
  3. How are failures represented? API handlers often need structured field errors, while logs need a readable summary. Look for a predictable error format and a practical way to map issues to fields.
  4. What is the cost at your boundaries? Validation adds work, so consider payload size, parse frequency, bundle requirements, and whether schemas are shared between browser and server code.
  5. Can the schema be tested independently? Boundary rules should be easy to exercise with valid, invalid, incomplete, and unexpected inputs.
  6. Does it fit the surrounding ecosystem? A form library, web framework, generated API client, or existing validation convention may make one choice easier to maintain than another.

For a new TypeScript project, start with one representative boundary rather than converting every object at once. A request body with nested fields, an array, an optional value, and a business constraint will reveal more than a small toy example.

Feature-by-feature breakdown

Zod: schema-first TypeScript validation

Zod is a strong fit when TypeScript is the primary language and you want the schema to be both executable validation logic and the source of an inferred type. Its object composition makes it practical to build shared pieces.

const AddressSchema = z.object({
  city: z.string().min(1),
  postalCode: z.string().min(3)
});

const CustomerSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1),
  address: AddressSchema,
  roles: z.array(z.enum(["user", "admin"]))
});

type Customer = z.infer<typeof CustomerSchema>;

Use parse when invalid input should immediately produce an exception that your boundary handles. Use safeParse when you want an explicit success or failure result, which is often convenient for HTTP responses and forms.

const result = CustomerSchema.safeParse(requestBody);

if (!result.success) {
  return Response.json(
    { error: "Invalid customer", issues: result.error.issues },
    { status: 400 }
  );
}

const customer = result.data;

After the success check, result.data is validated data. Treat this as the point where unknown input becomes trusted application data. Do not cast the original value with as Customer; a cast changes the compiler's view without checking the value.

Yup: a familiar choice for form-oriented validation

Yup is commonly considered when a project already uses a form ecosystem built around Yup-style schemas or when the team prefers its validation vocabulary. It can describe nested values, required fields, defaults, and transformations.

When comparing Yup with Zod for TypeScript, pay particular attention to the relationship between the runtime result and the inferred type. Check how optionality, defaults, coercion, and transformed values are represented. A schema that changes a value during validation should make that output type clear to callers.

Yup may be a sensible continuation choice for an existing application with established schemas and form integrations. For a new codebase, compare the APIs using the exact patterns your application needs rather than choosing based on a short example.

Valibot: modular validation with a small-footprint focus

Valibot is another option for TypeScript runtime validation. Its modular style can be attractive when a team wants validation capabilities assembled from focused functions and is attentive to client-side bundle composition.

Evaluate Valibot using the same test cases as Zod and Yup: nested objects, discriminated unions, transformations, custom rules, error formatting, and inferred output types. The most important result is not a benchmark in isolation; it is whether the chosen library produces maintainable boundary code in your application.

Handling API errors and form errors

Validation errors should be useful to the consumer without exposing internal details. For an API, return a stable top-level error code and a list of field issues. For a form, convert paths into the field naming convention used by the form library.

const result = CustomerSchema.safeParse(input);

if (!result.success) {
  const fields = Object.fromEntries(
    result.error.issues.map((issue) => [
      issue.path.join("."),
      issue.message
    ])
  );

  return { ok: false, fields };
}

return { ok: true, value: result.data };

Do not assume that client-side validation is sufficient. A browser form can improve feedback, but the server must validate again because requests can be sent without using the intended interface.

JSON validation and transformations

JSON has no native date, URL, or integer distinction beyond its basic value types. A schema can validate a string format or transform a value, but decide carefully where transformation belongs. Keeping transport data close to its original representation can simplify logging and API responses, while transforming at a service boundary can make domain code safer.

Separate syntactic validation from business validation where useful. Checking that quantity is an integer is a schema concern. Checking whether a customer is allowed to order that quantity may require a database lookup and belongs in application logic.

Best fit by scenario

  • New TypeScript API: Start with a schema-first validator such as Zod, then place request parsing close to route handlers. Export inferred types only when they represent a useful contract for other modules.
  • React or Next.js forms: Choose the library that integrates cleanly with the form solution already used by the project. Keep browser validation for feedback, but repeat the validation in the server action or API route.
  • Shared client and server schemas: Put transport schemas in a small shared module with minimal environment-specific dependencies. Avoid importing server-only code into browser bundles.
  • Existing Yup application: Keep the current choice if its schemas, error handling, and team knowledge work well. Migration is worthwhile when duplication, integration problems, or unclear output types create measurable maintenance costs.
  • Bundle-sensitive frontend: Compare Valibot and other modular options alongside the actual build output. The right choice depends on which schemas are imported and how your build tool processes them.
  • Generated API contracts: If an external contract is the source of truth, consider how generated types and runtime schemas will stay aligned. Do not allow a generated interface alone to stand in for runtime validation.

In a larger codebase, keep schemas organized by boundary or domain rather than placing every validator in one global file. This works well with a deliberate TypeScript project structure. For Next.js applications, route-level validation also fits naturally into a clear project and data-fetching structure.

When to revisit

Review your validation approach when an API contract changes, a new form framework is introduced, a shared schema begins increasing client bundle size, or the project adds another runtime such as a worker or mobile client. Revisit the comparison when new validator options or major integration features appear; feature support and ergonomics can change over time.

Also inspect the implementation when these warning signs appear:

  • Types are repeatedly asserted with as immediately after parsing external data.
  • Client and server schemas have diverged for the same request.
  • Validation errors are returned as unstable, UI-specific strings.
  • Every route performs slightly different checks for the same field.
  • Transformations are hidden inside validation and surprise downstream code.

A practical maintenance routine is to keep a small contract test for each important boundary. Include a valid fixture, a missing-field case, a wrong-type case, and a boundary-value case. When choosing between Zod, Yup, and Valibot, run the same fixtures through a small proof of concept and compare readability, inferred types, error handling, and build behavior. Then document the decision in the repository so a future contributor understands not only which validator was selected, but why.

For related TypeScript practices, pair runtime validation with strict compiler settings, a consistent project layout, and type-safe environment configuration. Validation is most effective when it is treated as one layer in a broader boundary design rather than as a substitute for careful domain modeling.

Related Topics

#zod#runtime validation#api typing#schemas#json validation#type safety
T

TypeScript Toolbox Editorial Team

Senior SEO Editor

Senior editor and content strategist. Writing about technology, design, and the future of digital media. Follow along for deep dives into the industry's moving parts.