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?