TypeScript catches many mistakes before code runs, but it cannot verify data arriving from an API, environment, form, or JSON file. Runtime validation closes that gap. This guide explains how schema libraries such as Zod, Yup, and Valibot compare, then shows maintainable patterns for validating untrusted data while keeping TypeScript types synchronized with the checks your application actually performs.
Overview
TypeScript types are removed during compilation. An annotation such as const user: User = await response.json() does not prove that the response has the fields required by User. It only tells the compiler to trust the value.
That distinction matters at every application boundary:
- HTTP responses from services you do not control
- Request bodies and query parameters supplied by clients
- Environment variables, which arrive as strings or may be missing
- Form submissions and URL parameters
- Configuration and JSON files loaded at runtime
- Messages received from queues, webhooks, or browser storage
A runtime validation library uses a schema to inspect an actual value. The schema can reject malformed input, report useful errors, and often generate a TypeScript type from the same definition. This creates a practical form of type-safe JavaScript development: static types guide implementation, while runtime checks protect the system at its boundaries.
For a broader implementation walkthrough, see TypeScript Runtime Validation with Zod. The central principle is simple: treat external data as unknown until it has passed validation.
How to compare options
The best schema library depends less on a feature checklist than on how and where your team validates data. Compare options using the following criteria.
1. Schema and type synchronization
Prefer a workflow that gives your team one clear source of truth. Some libraries infer a TypeScript type from a schema; others are commonly used with a separately declared interface or with types inferred from validation rules. Duplicating both definitions can be reasonable for unusual cases, but it creates another place for the contract to drift.
2. Error shape and reporting
API code usually needs structured field errors, while a startup configuration check may need one readable message and a list of missing variables. Check whether the library makes it easy to identify paths, preserve the original issue, and convert errors into your application's response format.
3. Transformations and coercion
Environment variables and query parameters often arrive as strings. A useful library should let you express whether a value is merely validated, transformed, or coerced. Be explicit: silently turning an invalid value into a default can conceal deployment or client errors.
4. Runtime footprint and project fit
For a server application, developer ergonomics may matter most. For a browser bundle, edge function, or shared package, installation size and compatibility may receive more attention. Evaluate the library in the environment where it will run rather than choosing solely from a tutorial.
5. Testing and team familiarity
A library is only useful if the team can maintain its schemas. Consider readability, documentation, editor support, testability, and the number of custom conventions your codebase will need. A smaller, familiar API can be a better long-term choice than a more elaborate one.
Feature-by-feature breakdown
Zod
Zod is a natural choice for TypeScript projects that want schemas and inferred types in the same workflow. A basic API response schema might look like this:
import { z } from 'zod';
const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
displayName: z.string().min(1),
role: z.enum(['admin', 'member'])
});
type User = z.infer<typeof UserSchema>;
The inferred User type reflects the schema, reducing the risk of editing an interface without updating validation. Use safeParse when invalid input is an expected outcome that your application should handle:
const result = UserSchema.safeParse(payload);
if (!result.success) {
return { ok: false, issues: result.error.issues };
}
const user = result.data;
Use a throwing parse operation when invalid data should stop the current operation, such as an application configuration failure during startup. Keep that decision at the boundary so the rest of the application can work with validated values.
Yup
Yup is widely recognized in form-validation workflows and can be a practical fit when a project already uses it for client-side forms. Its established patterns can also make migration easier when the primary need is validating user input rather than defining contracts across many services.
When comparing Yup with Zod in TypeScript, pay particular attention to how types are inferred, how optional and nullable values are represented, and how transformations behave. Do not assume that a schema's runtime behavior and a manually written interface are equivalent. Test representative valid, missing, null, and incorrectly typed values.
Valibot
Valibot is another option for schema-based validation. Teams may consider it when they want a modular API and a small runtime footprint, especially in code that is sensitive to client or edge bundle size. The important comparison is not simply which library has more methods; it is whether the chosen API expresses your domain rules clearly and works consistently across your build targets.
API response validation
Keep transport validation close to the client or adapter that calls the external service. Do not spread uncertain response types through business logic:
const UserListSchema = z.object({
items: z.array(UserSchema),
nextCursor: z.string().nullable()
});
export async function fetchUsers(): Promise<z.infer<typeof UserListSchema>> {
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error(`User request failed: ${response.status}`);
}
const payload: unknown = await response.json();
return UserListSchema.parse(payload);
}
The unknown annotation is deliberate. It prevents an unchecked JSON value from gaining credibility through an assertion. In larger applications, return a domain-specific error or result object instead of exposing a library-specific error throughout every layer.
Environment and configuration validation
Validate configuration once during startup and export the parsed result. This avoids repeated checks and gives the application a typed configuration object:
const ConfigSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']),
API_BASE_URL: z.string().url(),
REQUEST_TIMEOUT_MS: z.coerce.number().int().positive()
});
export const config = ConfigSchema.parse(process.env);
For deployment-sensitive values, fail early with an actionable message. A separate guide on type-safe environment variables in TypeScript can help you organize this boundary.
Forms and request bodies
Reuse domain rules where possible, but do not force every context to share the same schema. A registration form may permit an empty value while a persisted user record may require a normalized value. You can compose a base schema and create context-specific versions, keeping transformations and error presentation appropriate to each boundary.
Best fit by scenario
- TypeScript API clients: Choose a library with reliable inference and structured errors. Validate responses immediately after decoding JSON.
- Node.js services: Put request validation at the controller or route boundary, then pass validated domain data to services. This keeps business code independent of transport details.
- Next.js applications: Share carefully selected schemas between server actions, route handlers, and form code. Avoid importing server-only configuration into browser bundles. For project organization, see TypeScript with Next.js.
- Environment variables: Parse once at startup, distinguish missing values from malformed values, and avoid exposing private configuration to client code.
- Forms: Favor clear field paths and error messages. Yup may be convenient for an existing form-focused codebase; Zod or Valibot may fit better when the same contracts are shared with server code.
- Shared packages: Prefer schemas that can run in every intended consumer environment. Document whether parsing also transforms values, because that behavior is part of the package contract.
Whatever library you choose, separate three concerns: validation, normalization, and business rules. A schema can confirm that a string is an email address; a service may still need to decide whether that email is allowed to register. Keeping those decisions separate makes tests and future changes easier.
When to revisit
Runtime validation is not a one-time tooling decision. Revisit your approach when an API changes its response contract, when a new deployment target is introduced, or when schemas begin to appear in multiple packages with slightly different meanings. Also review the choice when bundle constraints, compiler settings, or team conventions change.
Use this practical maintenance checklist:
- Search for unchecked
response.json(),JSON.parse, and type assertions at external boundaries. - Replace broad assertions with
unknownfollowed by schema validation. - Test valid input, missing fields, extra fields, null values, wrong primitive types, and malformed nested data.
- Confirm that error responses do not expose secrets or internal implementation details.
- Review transformations and defaults so they are intentional rather than accidental.
- Check that inferred types, generated API contracts, and documentation still describe the same data.
- Recompare Zod, Yup, Valibot, and emerging alternatives when a project changes runtime, framework, or performance requirements.
A strong TypeScript validation strategy is less about selecting a permanent winner and more about making trust boundaries explicit. Start with the inputs that can break production most easily—configuration, authentication claims, webhooks, and third-party API responses—then add schemas where they provide clear protection. Keep validation close to the boundary, test it like application code, and let the resulting types describe only data that has earned your trust.