DevToolBoxGRATIS
Blog

Guia Zod: Esquemas, Transformaciones, Refinements e Integracion tRPC

12 minpor DevToolBox

Zod is a TypeScript-first schema validation library that lets you define schemas for your data and validate it at runtime while providing full TypeScript type inference. Unlike other validation libraries, Zod eliminates the gap between your runtime validation and your TypeScript types. Define it once, and get both runtime validation and compile-time type safety. This guide covers everything from basic schemas to advanced patterns used in production applications.

Why Zod?

Traditional TypeScript types only exist at compile time and are erased during compilation. This means data from external sources (API responses, form inputs, environment variables, database queries) is never validated at runtime. Zod bridges this gap by providing runtime validation that automatically generates TypeScript types.

Key Benefits

  • Zero dependencies: tiny library with no external packages
  • TypeScript-first: full type inference, no separate type definitions needed
  • Composable: schemas can be combined, extended, and transformed
  • Immutable: all methods return new schema instances
  • Detailed errors: rich error messages with paths and custom messages
  • Works everywhere: Node.js, Deno, Bun, browsers, React Native

Basic Schemas

Zod provides primitives for all JavaScript types and methods to compose them into complex schemas.

import { z } from "zod";

// Primitive types
const stringSchema = z.string();
const numberSchema = z.number();
const booleanSchema = z.boolean();
const dateSchema = z.date();
const undefinedSchema = z.undefined();
const nullSchema = z.null();
const bigintSchema = z.bigint();

// String validations
const emailSchema = z.string().email("Invalid email address");
const urlSchema = z.string().url();
const uuidSchema = z.string().uuid();
const minSchema = z.string().min(3, "Too short");
const maxSchema = z.string().max(100, "Too long");
const regexSchema = z.string().regex(/^[A-Z]{2}\d{4}$/, "Invalid format");
const trimSchema = z.string().trim();  // trims whitespace

// Number validations
const positiveSchema = z.number().positive();
const intSchema = z.number().int();
const rangeSchema = z.number().min(0).max(100);
const finiteSchema = z.number().finite();

// Enums
const roleSchema = z.enum(["admin", "user", "guest"]);
type Role = z.infer<typeof roleSchema>;  // "admin" | "user" | "guest"

// Literals
const statusSchema = z.literal("active");

// Unions
const idSchema = z.union([z.string(), z.number()]);
// Shorthand: z.string().or(z.number())

// Parsing
const result = emailSchema.safeParse("test@example.com");
if (result.success) {
  console.log(result.data);  // "test@example.com"
} else {
  console.log(result.error.issues);
}

Object Schemas

Object schemas are the most common type. They validate the shape of JavaScript objects and provide type inference for each property.

// Object schemas with full type inference
const UserSchema = z.object({
  id: z.number().int().positive(),
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().int().min(0).max(150).optional(),
  role: z.enum(["admin", "user", "guest"]).default("user"),
  tags: z.array(z.string()).default([]),
  address: z.object({
    street: z.string(),
    city: z.string(),
    zip: z.string().regex(/^\d{5}(-\d{4})?$/),
    country: z.string().length(2),
  }).optional(),
});

// Infer the TypeScript type automatically
type User = z.infer<typeof UserSchema>;
// {
//   id: number;
//   name: string;
//   email: string;
//   age?: number | undefined;
//   role: "admin" | "user" | "guest";
//   tags: string[];
//   address?: { street: string; city: string; zip: string; country: string } | undefined;
// }

// Schema composition
const CreateUserSchema = UserSchema.omit({ id: true });
const UpdateUserSchema = UserSchema.partial().required({ id: true });
const PublicUserSchema = UserSchema.pick({ id: true, name: true, role: true });

// Extending schemas
const AdminSchema = UserSchema.extend({
  permissions: z.array(z.string()),
  department: z.string(),
});

// Merging schemas
const WithTimestamps = z.object({
  createdAt: z.date(),
  updatedAt: z.date(),
});
const UserWithTimestamps = UserSchema.merge(WithTimestamps);

Transforms and Refinements

Transforms modify the output type, while refinements add custom validation logic. Both are essential for real-world validation.

Custom Refinements

The refine method adds custom validation rules that go beyond type checking.

// Custom refinements
const PasswordSchema = z.string()
  .min(8, "Password must be at least 8 characters")
  .refine(val => /[A-Z]/.test(val), "Must contain uppercase letter")
  .refine(val => /[a-z]/.test(val), "Must contain lowercase letter")
  .refine(val => /[0-9]/.test(val), "Must contain number")
  .refine(val => /[^A-Za-z0-9]/.test(val), "Must contain special character");

// Super refinement for cross-field validation
const SignupSchema = z.object({
  password: z.string().min(8),
  confirmPassword: z.string(),
}).superRefine((data, ctx) => {
  if (data.password !== data.confirmPassword) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "Passwords do not match",
      path: ["confirmPassword"],
    });
  }
});

// Date range validation
const DateRangeSchema = z.object({
  startDate: z.date(),
  endDate: z.date(),
}).refine(
  data => data.endDate > data.startDate,
  { message: "End date must be after start date", path: ["endDate"] }
);

Data Transformations

The transform method changes the output type of a schema. This is useful for parsing strings to numbers, normalizing data, or computing derived values.

// Transform: string to number
const NumericStringSchema = z.string().transform(Number);
// Input: "42" -> Output: 42

// Transform: normalize data
const NormalizedEmailSchema = z.string()
  .email()
  .transform(email => email.toLowerCase().trim());

// Transform with validation
const PositiveIntString = z.string()
  .transform(Number)
  .pipe(z.number().int().positive());
// Input: "42" -> validates as string -> transforms to 42 -> validates as positive int

// Transform: parse JSON strings
const JsonSchema = z.string().transform((str, ctx) => {
  try {
    return JSON.parse(str);
  } catch {
    ctx.addIssue({ code: "custom", message: "Invalid JSON" });
    return z.NEVER;
  }
});

// Coercion (built-in transforms)
const coercedNumber = z.coerce.number();  // "42" -> 42
const coercedBoolean = z.coerce.boolean(); // "true" -> true
const coercedDate = z.coerce.date();       // "2026-01-01" -> Date

Real-World Patterns

These patterns are commonly used in production TypeScript applications.

API Response Validation

Validate API responses to catch breaking changes and ensure your application handles data correctly.

// Validate API responses
const ApiResponseSchema = z.object({
  data: z.array(UserSchema),
  pagination: z.object({
    page: z.number(),
    perPage: z.number(),
    total: z.number(),
    totalPages: z.number(),
  }),
});

type ApiResponse = z.infer<typeof ApiResponseSchema>;

async function fetchUsers(page: number): Promise<ApiResponse> {
  const res = await fetch(`/api/users?page=${page}`);
  const json = await res.json();

  // Validate response - throws if invalid
  return ApiResponseSchema.parse(json);
}

// Safe version that returns errors
async function fetchUsersSafe(page: number) {
  const res = await fetch(`/api/users?page=${page}`);
  const json = await res.json();
  return ApiResponseSchema.safeParse(json);
}

Environment Variable Validation

Validate environment variables at application startup to fail fast if configuration is missing or invalid.

// Validate environment variables at startup
const EnvSchema = z.object({
  NODE_ENV: z.enum(["development", "production", "test"]),
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  API_KEY: z.string().min(1),
  REDIS_URL: z.string().url().optional(),
  LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
  CORS_ORIGINS: z.string().transform(s => s.split(",")),
});

// Parse and export typed env
const env = EnvSchema.parse(process.env);

export default env;
// env.PORT is number (not string!)
// env.CORS_ORIGINS is string[]
// env.NODE_ENV is "development" | "production" | "test"

Advanced Features

Discriminated Unions

Discriminated unions validate objects based on a shared discriminator field. They are more efficient than regular unions because Zod can determine the correct schema without trying each option.

// Discriminated union - efficient, checks "type" field first
const EventSchema = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("click"),
    x: z.number(),
    y: z.number(),
    target: z.string(),
  }),
  z.object({
    type: z.literal("keypress"),
    key: z.string(),
    modifiers: z.array(z.enum(["ctrl", "shift", "alt", "meta"])),
  }),
  z.object({
    type: z.literal("scroll"),
    deltaX: z.number(),
    deltaY: z.number(),
  }),
]);

type Event = z.infer<typeof EventSchema>;

function handleEvent(event: Event) {
  switch (event.type) {
    case "click":
      console.log(`Click at (${event.x}, ${event.y})`);  // TS knows x, y exist
      break;
    case "keypress":
      console.log(`Key: ${event.key}`);  // TS knows key exists
      break;
    case "scroll":
      console.log(`Scroll: ${event.deltaY}`);
      break;
  }
}

Branded Types

Branded types create nominally-typed values that prevent mixing structurally identical types. This is useful for IDs, validated strings, and domain-specific types.

// Branded types prevent mixing structurally identical types
const UserId = z.string().uuid().brand<"UserId">();
const PostId = z.string().uuid().brand<"PostId">();

type UserId = z.infer<typeof UserId>;  // string & { __brand: "UserId" }
type PostId = z.infer<typeof PostId>;  // string & { __brand: "PostId" }

function getUser(id: UserId) { /* ... */ }
function getPost(id: PostId) { /* ... */ }

const userId = UserId.parse("550e8400-e29b-41d4-a716-446655440000");
const postId = PostId.parse("6ba7b810-9dad-11d1-80b4-00c04fd430c8");

getUser(userId);  // OK
// getUser(postId); // TypeScript ERROR! PostId is not UserId

Error Handling

Zod provides detailed error information that you can format for user-facing messages.

// Handling validation errors
const result = UserSchema.safeParse(invalidData);

if (!result.success) {
  // Zod error has structured issues
  const errors = result.error.issues;
  // [
  //   { code: "too_small", path: ["name"], message: "Too short" },
  //   { code: "invalid_string", path: ["email"], message: "Invalid email" }
  // ]

  // Format errors by field
  const fieldErrors = result.error.flatten().fieldErrors;
  // { name: ["Too short"], email: ["Invalid email"] }

  // Format as a single message
  const formatted = result.error.format();
  // { name: { _errors: ["Too short"] }, email: { _errors: ["Invalid email"] } }
}

// Custom error map
const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
  if (issue.code === z.ZodIssueCode.too_small) {
    return { message: `Must be at least ${issue.minimum} characters` };
  }
  return { message: ctx.defaultError };
};

z.setErrorMap(customErrorMap);

Framework Integration

tRPC

tRPC uses Zod schemas for input validation, providing end-to-end type safety between your client and server.

// tRPC with Zod: end-to-end type safety
import { initTRPC } from "@trpc/server";

const t = initTRPC.create();

const appRouter = t.router({
  getUser: t.procedure
    .input(z.object({ id: z.string().uuid() }))
    .query(async ({ input }) => {
      // input is typed as { id: string }
      return await db.user.findUnique({ where: { id: input.id } });
    }),

  createUser: t.procedure
    .input(z.object({
      name: z.string().min(1),
      email: z.string().email(),
    }))
    .mutation(async ({ input }) => {
      // input is typed as { name: string; email: string }
      return await db.user.create({ data: input });
    }),
});

// Client gets full type inference from the schemas!
// const user = await trpc.getUser.query({ id: "..." });

Frequently Asked Questions

How does Zod compare to Yup?

Zod is TypeScript-first with better type inference, while Yup was originally designed for JavaScript. Zod schemas automatically generate TypeScript types (z.infer<typeof schema>), while Yup requires separate type definitions. Zod is also smaller, faster, and has no dependencies. For new TypeScript projects, Zod is the better choice.

Does Zod impact runtime performance?

Zod is designed to be fast. Simple schema validation (objects with primitive fields) takes microseconds. For most applications, the performance impact is negligible. If you are validating millions of objects per second, consider validating only at system boundaries (API inputs/outputs) rather than internally.

Can I use Zod without TypeScript?

Yes, Zod works with plain JavaScript. However, you lose the main benefit of automatic type inference. If you are using JavaScript, Joi or Yup might be better alternatives since they were designed for JavaScript-first usage.

How do I validate nested objects?

Nest z.object() schemas inside each other. Zod handles deep nesting naturally. Use z.lazy() for recursive structures. Error paths automatically include the full nested path (e.g., "address.city") for easy debugging.

Can Zod replace TypeScript interfaces?

For data that crosses system boundaries (API inputs, form data, env vars, database results), yes. Define a Zod schema and use z.infer<typeof schema> for the type. For internal application types (component props, function signatures), regular TypeScript interfaces are simpler and have no runtime cost.

𝕏 Twitterin LinkedIn
¿Fue útil?

Mantente actualizado

Recibe consejos de desarrollo y nuevas herramientas.

Sin spam. Cancela cuando quieras.

Prueba estas herramientas relacionadas

ZDJSON to Zod Schema🔷JSON to TypeScript Online{ }JSON FormatterY→YAML to JSON Converter

Artículos relacionados

TypeScript 5 Nuevas Funciones: Decoradores, Parametros de Tipo Const y Satisfies

Guia completa de novedades TypeScript 5: decoradores, const type params y satisfies.

TypeScript Type Guards: Guía Completa de Verificación de Tipos

Domina type guards en TypeScript: typeof, instanceof, in y guards personalizados.

GraphQL vs REST API: Cual usar en 2026?

Comparacion profunda de GraphQL y REST API con ejemplos de codigo. Diferencias de arquitectura, patrones de obtencion de datos, cache y criterios de seleccion.

JSON a Zod Schema: Validacion en Tiempo de Ejecucion con Tipos Seguros en TypeScript

Aprende a convertir JSON a schemas Zod para validacion en tiempo de ejecucion con tipos seguros en TypeScript.