hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Validation for beginners with Zod

What is Zod

  • The problem with untyped data
  • Your first schema
  • Project setup

Primitive types

  • Strings
  • Numbers
  • Booleans, dates, and literals

Objects and arrays

  • Objects
  • Nested objects
  • Arrays
  • Unions and discriminated unions

Transforms and refinements

  • Transforms
  • Refinements
  • Preprocessing and coercion

Composing schemas

  • Pick, omit, and extend
  • Merge and intersection
  • Reusable schemas

Zod in practice

  • Validating API requests
  • Error formatting
  • Inferring TypeScript types
  • Cheatsheet and capstone

Transforms

Validation is not always enough

So far, every schema we have written validates data and returns it as-is. If the input is " Alice ", and the schema says it is a valid string, you get back " Alice " with the whitespace still there. If the input is the string "2024-01-15T10:30:00Z", you get back a string, not a Date object.

Sometimes you need to change the data as part of validation: trim whitespace, normalize an email to lowercase, convert a date string into a Date object, or parse a string into a number. That is what .transform() does.

How transforms work

.transform() takes a function that receives the validated data and returns something new:

import { z } from "zod/v4";

const schema = z.string().transform((val) => val.toUpperCase());

schema.parse("hello"); // returns "HELLO"
schema.parse(42); // fails string validation (transform never runs)

The order here is important. Zod validates first (z.string() checks that the input is a string). If validation passes, the transform runs. If validation fails, the transform is skipped entirely. There is no point transforming invalid data.

Common transforms

Normalize an email:

const EmailSchema = z
  .string()
  .email()
  .transform((email) => email.toLowerCase().trim());
EmailSchema.parse("  [email protected]  "); // returns "[email protected]"

[!NOTE] The Strings lesson showed .trim() and .toLowerCase() as built-in string methods. Those are actually implemented as transforms internally. .transform() lets you write custom ones for anything the built-in methods do not cover.

Parse a string to a number:

const StringToNumber = z.string().transform((val) => {
  const num = Number(val);
  if (isNaN(num)) throw new Error("Not a number");
  return num;
});

StringToNumber.parse("42"); // returns 42 (number)
StringToNumber.parse("abc"); // throws "Not a number"

Parse a date string to a Date:

const DateStringSchema = z
  .string()
  .datetime()
  .transform((val) => new Date(val));
DateStringSchema.parse("2024-01-15T10:30:00Z"); // returns Date object

Notice the pattern: validate the format first (.datetime() checks it is a valid ISO string), then transform it into the type you actually need.

Transforms change the output type

This is a subtle but important point. Before the transform, the type is string. After, it might be number, Date, or anything else. TypeScript tracks this:

const schema = z.string().transform((val) => val.length);

type Input = z.input<typeof schema>; // string
type Output = z.output<typeof schema>; // number

schema.parse("hello"); // returns 5 (number, not string)

z.input is the type before transforms (what the caller sends). z.output (which is the same as z.infer) is the type after transforms (what you get back). We will cover this more in the Inferring TypeScript Types lesson.

Chaining transforms with validation

You can mix validation and transforms in a chain:

const ProcessedName = z
  .string()
  .trim()
  .min(1)
  .transform((val) => val.charAt(0).toUpperCase() + val.slice(1).toLowerCase());

ProcessedName.parse("  aLiCe  "); // returns "Alice"

The validation steps (trim, min) run first. Then the transform capitalizes the first letter and lowercases the rest. The input " aLiCe " gets trimmed to "aLiCe", passes min(1), and then gets transformed to "Alice".

Transforms are a clean way to normalize data during the validation step rather than scattering normalization logic throughout your route handlers. Validate once, transform once, and the rest of your code works with clean data.

Next, we will look at refinements, which let you write custom validation rules for cases where the built-in methods are not enough.

Exercises

Exercise 1: Create a transform that converts a comma-separated string into an array: "a,b,c" returns ["a", "b", "c"].

Exercise 2: Create a transform that parses a date string into a Date object. Validate the string format first with .datetime().

Exercise 3: Chain trim, min(1), and a transform that capitalizes the first letter. Test with " hello ".

When does a .transform() run relative to validation?

← Unions and discriminated unions Refinements →

© 2026 hectoday. All rights reserved.