Zod + TypeScript: One Source of Truth for Your Data

Zod + TypeScript: One Source of Truth for Your Data

If you’ve ever struggled to keep your TypeScript types, API contracts, and form validations in sync: meet Zod.

In this guide I’ll walk through how to define Zod schemas, validate and transform data at runtime, and then reuse those schemas to infer TypeScript types instead of hand-writing them. We’ll also look at .parse() vs .safeParse(), some practical error handling patterns, and how to plug Zod into React with TanStack Form for real-time validation that doesn’t feel like boilerplate.

The goal isn’t to learn every Zod feature, but to get to a point where your validation logic lives in one place and the rest of your code just benefits from it.

What is Zod?

Zod is a TypeScript-first library for schema validation and type inference. It allows you to define strict schemas for data validation, ensuring type safety in TypeScript projects. By creating a schema in Zod, you automatically get TypeScript types inferred from it, reducing the need to manually write types and ensuring consistency across your application. This means you can focus more on the data structure, and less on type definitions.

Additionally, Zod introduces a unique approach to validation, where you parse data instead of just validating it. This distinction ensures better handling of invalid data by throwing errors that can be caught, providing more control over data integrity.

In this document we are going to work using Typescript + React + Vite, so I highly recommend to create project using the next script:

npm create vite@latest zod-demo -- --template react-ts

We are also going to use TanStack Form so we need to install it

npm install @tanstack/react-form

Zod v4 is now the stable release. If you're using an older version (v3), it's recommended to upgrade to benefit from the latest features and improvements.

npm install zod

Once we have all installed we can start.


Defining Schemas

If you want to validate something, you need to create a Schema of it.

But, what is a schema?

Schema is a representation of a Type, it can be just a simple primitive type or a complex object with different types on it. Here's an example:

import { z } from "zod";
const userSchema = z.object({
firstName: z.string(),
lastName: z.string().optional(),
email: z.email(),
birthdate: z.date(),
age: z.number(),
})

As you can see in this example, we have:

  • First name as string
  • Last name as an optional string (optional fields in Zod are indicated by .optional())
  • Email as email
  • Birthdate as a date
  • Age as a number

In Zod v4, you can use z.email() directly for email validation, whereas in Zod v3, you would use z.string().email()—same with z.date() instead of z.string.date().

This user schema can be used to parse data, and check if the app is receiving the correct data type.

However, real-world data isn't always clean. For example, form inputs usually return strings—even when you expect numbers or dates. That's where z.preprocess becomes useful.

You can use z.preprocess to transform the input before validation. For instance, to convert a string into a number or a Date object:

const userSchema = z.object({
firstName: z.string(),
lastName: z.string().optional(),
email: z.email(),
birthdate: z.preprocess((val) => new Date(val as string), z.date()),
age: z.preprocess((val) => Number(val), z.number()),
});

In this example, even if the birthdate or age come as strings (e.g., from a form input), the schema will convert and validate them correctly.


Parsing data

Once you have defined your schema, you need to parse the data against it. Zod provides two main methods for parsing data: .parse() and .safeParse(). These methods allow you to validate and transform the data according to your schema, but they behave differently when handling validation errors.

.parse

You can use .parse(object you want to parse) for any Zod Schema and, if the parse is correct, it will return a strongly-typed deep clone of the input.

const user = {
firstName: "John",
email: "john.doe@example.com",
birthdate: new Date("1990-01-01"),
age: 33,
};
try {
const userSchemaParsed = userSchema.parse(user);
console.log(userSchemaParsed);
} catch (error) {
if (error instanceof z.ZodError) {
console.log(error); // Handle validation errors
} else {
console.error("Unexpected error", error);
}
}

For valid inputs it returns:

{
"firstName": "John",
"email": "john.doe@example.com",
"birthdate": "1990-01-01T00:00:00.000Z",
"age": 33
}

But, if the input is not valid it throws an instance of ZodError, it needs to be caught to prevent your app from breaking.

For example, in the following object the email is not valid, the date is wrong, and the age is a string.

const user = {
firstName: "John",
email: "john", // Invalid email
birthdate: "12-232-2300", // Invalid date
age: "33", // Invalid type (should be a number)
};

When parsing this data, we can see the following error:

ZodError {
issues: [
{
origin: 'string',
code: 'invalid_format',
format: 'email',
pattern: "/^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$/",
path: [Array],
message: 'Invalid email address'
},
{
expected: 'date',
code: 'invalid_type',
path: [Array],
message: 'Invalid input: expected date, received string'
},
{
expected: 'number',
code: 'invalid_type',
path: [Array],
message: 'Invalid input: expected number, received string'
}
]
}

The error messages can be customized, but we'll jump to it later here.

.safeParse

The key difference between parse and safeParse is the response when they fail. While the parse function throws a ZodError, safeParse always returns a JSON-like result, so you don't need to catch the response:

type SafeParseResult<T> =
| { success: true; data: T } // if success
| { success: false; error: z.ZodError }; // if fails

Failure response example:

{
"success": false,
"error": "ZodError { issues: [ [Object], [Object], [Object] ] }"
}

Success response example:

{
"success": true,
"data": {
"firstName": "John",
"email": "john.doe@example.com",
"birthdate": "1990-01-01T00:00:00.000Z",
"age": 33
}
}

When to use .parse() vs .safeParse()

  • Use .parse() when you want to handle validation errors explicitly using try-catch blocks. This is useful when you want to interrupt your code flow on validation failure and immediately handle it.
  • Use .safeParse() when you prefer to avoid exceptions and want a more structured response. This is ideal when you want to handle validation errors more gracefully, especially in situations like form validation or asynchronous validation flows.

Now we know about schemas and parsing but, what if we have a type "User" and want to be consistent with the schema? That's where inferred types come in.


Inferred types

Type inference is one of the most powerful features of Zod when working with TypeScript. It lets you define your types just once — in the schema — and automatically infers the corresponding TypeScript types from it.

const userSchema = z.object({
firstName: z.string().min(3, "First name must be at least 3 characters long"),
lastName: z.string().min(3, "Last name must be at least 3 characters long").optional(),
email: z.email("Invalid email"),
birthdate: z.date(),
age: z.number(),
reputation: z.preprocess((val) => Number(val), z.number())
});
// Use z.infer to extract the TypeScript type
type User = z.infer<typeof userSchema>;
// Now we can use the `User` type, and TypeScript will enforce the schema-defined structure
const user: User = {
firstName: "John",
lastName: "Doe",
email: "john.doe@example.com",
birthdate: new Date("1990-01-01"),
age: 30,
reputation: 100,
};
// If you try to assign incorrect data, TypeScript will catch the error:
const invalidUser: User = {
firstName: "John",
lastName: "Doe",
email: "invalid-email", // Typescript will not catch this error because it takes the `email` as a string, Zod will because it check if it's an email.
birthdate: new Date("1990-01-01"),
// @ts-expect-error
age: "25", // TypeScript will catch this because `age` should be a number
reputation: "100" // TypeScript will also catch this because `reputation` is only transformed to number when is parsed by Zod. When you parse it with zod this field will be preprocessed correctly and transfomed to a Number.
};

Advantages of Type Inference in Zod

  1. Consistency: You only define your schema once, and TypeScript guarantees that the inferred types are consistent across your application.
  2. Type Safety: Since Zod ensures the type is always derived from the schema, you get robust type safety without extra effort.
  3. Simplified Code: Avoid manually writing types for your data models, saving time and reducing the potential for errors.

Custom errors

We've covered a lot of useful features, but what if we want to customize error messages for different validations? Fortunately, it's easy! Most Zod functions and schema methods support custom error messages.

There are two ways to define them. The first is by passing the error message directly as a parameter to the field you want to validate. For example:

const userSchema = z.object({
firstName: z
.string("Please enter a valid first name")
.min(3, "First name must be at least 3 characters"),
lastName: z.string("Please enter a valid last name").optional(),
email: z.email("Please enter a valid email"),
birthdate: z.date("Please enter a valid birthdate"),
age: z.number("Please enter a valid age"),
});

You can also use an object instead of a string if you want to have more parameters than just the error message:

const userSchema = z.object({
firstName: z
.string({ error: "Please enter a valid first name" })
.min(3, { error: "First name must be at least 3 characters" }),
lastName: z.string({ error: "Please enter a valid last name" }).optional(),
email: z.email({ error: "Please enter a valid email" }),
birthdate: z.date({ error: "Please enter a valid birthdate" }),
age: z.number({ error: "Please enter a valid age" }),
});

Both types of declared errors will return the error messages this way:

ZodError {
issues: [
{
origin: 'string',
code: 'too_small',
minimum: 3,
path: [Array],
message: 'First name must be at least 3 characters'
},
{
origin: 'string',
code: 'invalid_format',
format: 'email',
pattern: "/^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$/",
path: [Array],
message: 'Please enter a valid email'
}
]
}

If you want to display a nicely formatted error message, Zod v4 provides a helper: z.prettifyError(error).

Here's how you can use it:

console.log(z.prettifyError(error));

Log:

✖ First name must be at least 3 characters
→ at firstName
✖ Please enter a valid email
→ at email

Now we are ready to use Zod in an actual application, let's go to our React app and let's apply all of this knowledge.


Using Zod in a Front-End form

Once we have all the app configured we can start by creating a simple form in our App component:

import { useForm } from "@tanstack/react-form";
import "./App.css";
const App = () => {
const form = useForm();
return (
<div className="app">
<h1>Zod Form Example</h1>
<form onSubmit={form.handleSubmit} className="app__form"></form>
</div>
);
};
export default App;

Once we have it, let's define the schema. The form has 4 inputs:

  • firstName with a minimum of 3 characters
  • lastName with a minimum of 3 characters
  • age with a minimum value of 18 and a maximum of 100
  • email that should be a valid one
const userSchema = z.object({
firstName: z.string().min(3, "This field must be at least 3 characters long"),
lastName: z.string().min(3, "This field must be at least 3 characters long"),
age: z.preprocess(
(val) => Number(val) || 0,
z
.number()
.min(18, "You must be at least 18 years old")
.max(100, "You must be less than 100 years old")
) as unknown as z.ZodType<number, number>,
email: z.email("Invalid email"),
});
type User = z.infer<typeof userSchema>;
const defaultValues: User = {
firstName: "",
lastName: "",
email: "",
age: 0,
};

As you can see, we are using custom error messages on each field, as well as the preprocess function we saw earlier.

Once we have the schema ready, we can move on and add the fields to the form:

<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
className="app__form"
>
<form.Field name="firstName" children={(field) => <Input label="First Name" field={field} />} />
<form.Field name="lastName" children={(field) => <Input label="Last Name" field={field} />} />
<form.Field name="age" children={(field) => <Input type="number" label="Age" field={field} />} />
<form.Field name="email" children={(field) => <Input label="Email" field={field} />} />
<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
<button className="app__submit" type="submit" disabled={!canSubmit}>
{isSubmitting ? "..." : "Submit"}
</button>
)}
/>
</form>

In the following example, the Input component receives a field object as a prop. For simplicity, we're typing it as AnyFieldApi, but for a more robust and type-safe implementation, we recommend reviewing the official docs.

This field object provides everything we need to wire the input: its name, value, onChange, and onBlur handlers. It also includes validation state, such as field.state.meta.errors, which we can use to display error messages.

Here's the example:

import type { AnyFieldApi } from "@tanstack/react-form";
import "./Input.css";
function FieldInfo({ field }: { field: AnyFieldApi }) {
return (
<>
{field.state.meta.isTouched && field.state.meta.errors.length ? (
<em>{field.state.meta.errors.join(",")}</em>
) : null}
{field.state.meta.isValidating ? "Validating..." : null}
</>
);
}
const Input = ({
field,
label,
type = "text",
}: {
field: AnyFieldApi;
label: string;
type?: string;
}) => {
return (
<div className="field">
<label htmlFor={field.name}>{label}:</label>
<input
className="field__input"
id={field.name}
name={field.name}
type={type}
value={field.state.value}
onChange={(e) => {
field.handleChange(e.target.value);
}}
onBlur={field.handleBlur}
/>
<FieldInfo field={field} />
</div>
);
};
export default Input;

Now, how can we validate the fields? Well, that's probably the easiest task of this guide. You just need to add 4 lines of code: add a validators field with your schema to the useForm parameter. As long as your field names match the schema, validation will happen automatically!

const form = useForm({
defaultValues,
onSubmit: async ({ value }) => {
console.log(value);
},
validators: {
onMount: userSchema, // This will validate the form when the component is Mounted
onChange: userSchema, // This will validate the form on every change
},
});

Why Zod over the alternatives?

There are many schema validation libraries available, like Yup, Joi, or class-validator, and each has its strengths. Joi is battle-tested and widely used in Node.js backends. Yup offers a similar API to Joi but is often used in the frontend with Formik. class-validator integrates well with class-based architectures like NestJS.

However, Zod stands out for being TypeScript-first by design. While libraries like Yup require you to maintain separate type definitions or rely on external tools like yup-infer, Zod automatically infers TypeScript types directly from your schema. This reduces duplication and keeps your code consistent and type-safe by default.

Zod also embraces a functional, composable API that feels more predictable and ergonomic, especially in modern React workflows. It supports powerful features like .preprocess, .transform, custom error messages, and safe parsing, making it not just a validator, but also a data transformation tool.

If your project is TypeScript-based and you value strict type safety, maintainability, and clean syntax, Zod is likely the best fit.


Conclusions

As you've seen in this article, Zod is a powerful library that simplifies schema validation and type inference. You can now be confident that you have a single source of truth, your types and validations stay in sync by defining just one schema.

This significantly reduces the risk of errors. The ability to define custom errors for each field makes form creation straightforward, with everything defined in one place. No more updating types, validations, and error messages across multiple files, with Zod it all lives on one side.


Links