Sofware Development
Zod + TypeScript: One Source of Truth for Your Data
November 28, 2025
by
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:```bashnpm create vite@latest zod-demo -- --template react-ts```We are also going to use TanStack Form so we need to install it```bashnpm 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.```bashnpm install zod```Once we have all installed we can start.---## Defining SchemasIf 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:```tsximport { 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 numberIn 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:```tsxconst 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 dataOnce 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.### .parseYou 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.```tsxconst 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:```json{ "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.```tsxconst 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](#custom-errors).### .safeParseThe 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:```tsxtype SafeParseResult = | { success: true; data: T } // if success | { success: false; error: z.ZodError }; // if fails```Failure response example:```json{ "success": false, "error": "ZodError { issues: [ [Object], [Object], [Object] ] }"}```Success response example:```json{ "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 typesType 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.```tsxconst 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 typetype User = z.infer;// Now we can use the `User` type, and TypeScript will enforce the schema-defined structureconst 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 errorsWe'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:```tsxconst 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:```tsxconst 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:```tsxconsole.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 formOnce we have all the app configured we can start by creating a simple form in our `App` component:```tsximport { useForm } from "@tanstack/react-form";import "./App.css";const App = () => { const form = useForm(); return (
Zod Form Example
);};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```tsxconst 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, email: z.email("Invalid email"),});type User = z.infer;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:```tsx { e.preventDefault(); e.stopPropagation(); form.handleSubmit(); }} className="app__form"> } /> } /> } /> } /> [state.canSubmit, state.isSubmitting]} children={([canSubmit, isSubmitting]) => ( {isSubmitting ? "..." : "Submit"} )} />```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:```tsximport type { AnyFieldApi } from "@tanstack/react-form";import "./Input.css";function FieldInfo({ field }: { field: AnyFieldApi }) { return ( <> {field.state.meta.isTouched && field.state.meta.errors.length ? ( {field.state.meta.errors.join(",")} ) : null} {field.state.meta.isValidating ? "Validating..." : null} );}const Input = ({ field, label, type = "text",}: { field: AnyFieldApi; label: string; type?: string;}) => { return (
{label}: { field.handleChange(e.target.value); }} onBlur={field.handleBlur} />
);};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!```tsxconst 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.---## ConclusionsAs 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- https://zod.dev/- https://vite.dev/guide/- https://tanstack.com/form/latest- [https://zod.dev/api#primitives](https://v4.zod.dev/api#primitives)- [https://zod.dev/api#objects](https://v4.zod.dev/api#objects)- [https://zod.dev/api](https://v4.zod.dev/api)- [TanStack Form example](https://tanstack.com/form/latest/docs/framework/react/examples/large-form?path=examples%2Freact%2Flarge-form%2Fpublic%2Femblem-light.svg)