Back to Blog
When Your Typescript Needs Runtime Validation: Diving into Zod
8 min readOct 14, 20260 views

When Your Typescript Needs Runtime Validation: Diving into Zod

TypeScript gives us amazing static type safety, but it often falls short when data comes from untrusted sources like API responses or user input. Zod closes this gap by providing powerful schema validation at runtime, with excellent TypeScript inference to keep your types in sync.

TypeScriptAPISoftware DesignFrontendBackend
Share

by Sunil Band

The Static vs. Runtime Divide

We all love TypeScript. It catches so many bugs before they even hit the browser or server, making our codebases more robust and our refactors less terrifying. But here's the kicker: TypeScript's magic happens at compile time. Once your code is compiled down to JavaScript, all those beautiful types are gone. This creates a fundamental problem when you're dealing with data from external, untrusted sources.

Think about it. When your frontend fetches data from an API, or your backend receives a request body, that data could be anything. It could be malformed, missing fields, or have values of the wrong type. Your TypeScript types might declare an item.price as a number, but if the API sends "ten dollars", your runtime code will break. You're forced to add repetitive runtime checks like if (typeof item.price !== 'number') everywhere, which is exactly what TypeScript was supposed to save you from.

This is where Zod shines. It's a TypeScript-first schema declaration and validation library that lets you define your data shapes once. From that single definition, Zod gives you both runtime validation and static TypeScript types, bridging that gap between compile-time safety and runtime reality.

Defining Your Schemas with Zod

Zod's API is designed to be fluent and composable. You define schemas for primitives, objects, arrays, and more, all with rich validation options. The real power is how it infers TypeScript types directly from these schemas. This means your single source of truth for data structure and validation lives in Zod.

Let's say we're fetching a list of users from an API. Each user should have an ID, a name, an email, and an optional phone number. Here's how you'd define that with Zod:

typescript
import { z } from 'zod';

// Define the schema for a single user
const userSchema = z.object({
  id: z.string().uuid(), // ID must be a string and a valid UUID
  name: z.string().min(3), // Name must be a string with at least 3 characters
  email: z.string().email(), // Email must be a string and a valid email format
  phone: z.string().optional().nullable(), // Phone is an optional string, can be null
  // Using .transform() for custom logic, e.g., sanitizing input
  isAdmin: z.boolean().default(false).transform(val => !!val) // Ensure boolean, default false, and transform to actual boolean
});

// Infer the TypeScript type from the schema
type User = z.infer<typeof userSchema>;

// Define a schema for a list of users
const userListSchema = z.array(userSchema);

// Infer the TypeScript type for the list
type UserList = z.infer<typeof userListSchema>;

// Example of usage
const userData: unknown = {
  id: 'b819f3f0-4d7a-4c2f-8b9a-1c2e3f4a5b6d',
  name: 'Sunil Band',
  email: 'sunil@example.com',
  // phone: null, // Optional field can be missing or null
  isAdmin: 1 // Example of a non-boolean value that .transform handles
};

try {
  const parsedUser: User = userSchema.parse(userData);
  console.log('Parsed User:', parsedUser);
  // At this point, parsedUser is guaranteed to conform to the User type
  // and all validations have passed. isAdmin is now a boolean.
  console.log('Is Admin:', parsedUser.isAdmin);

  // Example of invalid data
  const invalidUserData = { id: 'invalid-uuid', name: 'SB' };
  userSchema.parse(invalidUserData); // This will throw an error
} catch (error) {
  if (error instanceof z.ZodError) {
    console.error('Validation failed:', error.errors);
  } else {
    console.error('An unexpected error occurred:', error);
  }
}

// Example with an array of users
const rawUsers: unknown[] = [
  {
    id: 'a1b2c3d4-e5f6-7890-1234-567890abcdef',
    name: 'Alice',
    email: 'alice@example.com'
  },
  {
    id: 'f1e2d3c4-b5a6-9876-5432-10fedcba9876',
    name: 'Bob',
    email: 'bob@example.com',
    phone: '555-1234'
  }
];

try {
  const parsedUserList: UserList = userListSchema.parse(rawUsers);
  console.log('Parsed User List:', parsedUserList);
} catch (error) {
  if (error instanceof z.ZodError) {
    console.error('List validation failed:', error.errors);
  } else {
    console.error('An unexpected error occurred:', error);
  }
}

Notice how we define userSchema once, and then use z.infer<typeof userSchema> to get the static TypeScript type User. This means if you change your validation rules in userSchema, your User type automatically updates. No more manually syncing interfaces with runtime checks.

Zod offers a wide array of built-in validators for strings (email, url, uuid, datetime, CUID2), numbers (min, max, positive, negative, int), dates, and more. You can also define custom validation logic using .refine() for complex cross-field validation or .superRefine() for more granular error reporting.

Composing Schemas and Error Handling

One of Zod's strengths is its composability. You can build complex schemas from simpler ones, just like you build software components. This promotes reuse and keeps your schema definitions DRY (Don't Repeat Yourself).

Let's extend our userSchema to include an address, which itself is an object:

typescript
import { z } from 'zod';

const addressSchema = z.object({
  street: z.string().min(5),
  city: z.string(),
  zipCode: z.string().regex(/^\d{5}(-\d{4})?$/)
});

const userWithAddressSchema = userSchema.extend({
  address: addressSchema.optional()
});

type UserWithAddress = z.infer<typeof userWithAddressSchema>;

const userWithAddressData: unknown = {
  id: 'b819f3f0-4d7a-4c2f-8b9a-1c2e3f4a5b6d',
  name: 'Sunil Band',
  email: 'sunil@example.com',
  isAdmin: true,
  address: {
    street: '123 Main St',
    city: 'Anytown',
    zipCode: '12345'
  }
};

try {
  const parsedUser: UserWithAddress = userWithAddressSchema.parse(userWithAddressData);
  console.log('Parsed User with Address:', parsedUser);
} catch (error) {
  if (error instanceof z.ZodError) {
    console.error('Validation failed:', error.errors);
  } else {
    console.error('An unexpected error occurred:', error);
  }
}

When parse() fails, Zod throws a ZodError which contains a structured array of ZodIssue objects. Each issue tells you exactly what went wrong, including the path to the invalid field, the code of the validation rule that failed, and a message. This is incredibly useful for providing detailed feedback to users or logging specific validation failures.

For frontend forms, you can map these ZodIssues directly to form errors, highlighting fields and displaying messages. On the backend, you can return these structured errors in your API responses, giving clients actionable feedback.

Common Use Cases

Zod isn't just for API responses. I find myself reaching for it in a variety of situations:

  1. Validating API Request Bodies and Query Parameters: This is a big one for backend APIs (e.g., with Express, NestJS, or Next.js API Routes). Instead of manually checking req.body or req.query, you just schema.parse(req.body). If it passes, you know your data is shaped correctly and typed safely.
  2. Form Validation: In React applications, Zod integrates beautifully with form libraries like React Hook Form or Formik. You pass your Zod schema, and the library handles the validation logic, often integrating with useForm's resolver option to provide real-time feedback and submission validation.
  3. Environment Variables: Your application relies on process.env (or similar), but these are just strings at runtime. Using Zod, you can define a schema for your environment variables, ensure required variables are present, and even transform them (e.g., z.string().transform(Number) for a port number). This catches misconfigurations early.
  4. Configuration Files: If your application uses JSON or YAML configuration files, Zod can validate their structure and content against a defined schema, preventing runtime errors due to malformed configs.
  5. External Data Sources (CSV, JSON files, Databases): Whenever you ingest data that isn't strictly controlled by your application's type system, Zod is your safety net.

Consider this simplified example of environment variable validation:

typescript
import { z } from 'zod';

const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
  DATABASE_URL: z.string().url(),
  PORT: z.string().transform(Number).pipe(z.number().min(80).max(65535)), // Transform to number, then validate range
  API_KEY: z.string().min(10)
});

type Env = z.infer<typeof envSchema>;

try {
  // In a real app, this would be process.env
  const rawEnv = {
    NODE_ENV: 'production',
    DATABASE_URL: 'postgres://user:password@host:5432/db',
    PORT: '3000',
    API_KEY: 'supersecretkey123'
  };

  const env: Env = envSchema.parse(rawEnv);
  console.log('Environment variables loaded:', env);
  console.log('API Key length:', env.API_KEY.length); // TS knows this is a string
  console.log('Port number:', env.PORT + 1); // TS knows this is a number

  // Example of invalid environment
  const invalidRawEnv = {
    DATABASE_URL: 'not-a-url',
    PORT: '66000' // Out of range
  };
  // This would typically be called early in your app's startup phase
  envSchema.parse(invalidRawEnv);
} catch (error) {
  if (error instanceof z.ZodError) {
    console.error('Invalid environment variables:', error.errors);
  } else {
    console.error('An unexpected error occurred:', error);
  }
}

This pattern provides immediate feedback during development if a critical environment variable is missing or malformed, and offers robust protection in production.

Trade-offs and Considerations

Zod is fantastic, but like any tool, it has its trade-offs. The primary one is bundle size for client-side applications. While Zod is generally efficient, it's still a library you're adding. If you're building a highly optimized, tiny frontend bundle where every byte counts, you might feel the impact. However, for most modern applications, the benefits of type safety and robust validation far outweigh the minimal bundle increase.

Another point to consider is performance for extremely high-throughput validation. Zod is fast, but it's still performing JavaScript operations at runtime. For the vast majority of applications, this isn't an issue. If you're validating millions of objects per second in a hot path, you might need to benchmark or consider a lower-level, more performant solution, but this is a rare edge case.

Finally, the syntax, while fluent, can take a moment to get used to if you're coming from purely interface-driven TypeScript development or other validation libraries. However, its consistency and comprehensive features quickly make it a joy to use.

Wrapping up

Zod is more than just a validation library; it's a schema-first approach to data that dramatically improves type safety and robustness in JavaScript and TypeScript applications. By defining your data shapes once, you get both runtime validation and compile-time type inference, eliminating a whole class of errors and making your codebase much more reliable.

If you're not already using a runtime validation library in your TypeScript projects, or if you're tired of manually syncing interfaces with validation logic, I strongly recommend giving Zod a shot. Head over to the Zod documentation and try integrating it into your next API route, form, or environment variable setup. You'll wonder how you ever lived without it.

More from the blog
Available for projectsReady to make something fun 🎈

Ready to build the next system?Wanna build something awesome together?

Currently accepting high-impact opportunities in frontend engineering and scalable web applications.Got a cool idea rattling around? Let's grab a virtual coffee and turn it into something people love. ☕