Overview
Transformations allow you to modify data after validation. The .transform() method runs after all validations pass, converting the data to a different shape or type.
Use .transform() to modify validated data:
import { z } from 'zod';
const schema = z.string().transform((val) => val.length);
const result = schema.parse('hello'); // 5 (number)
From packages/zod/src/v4/classic/tests/transform.test.ts:86-91:
const r1 = z.string()
.transform((data) => data.length)
.parse('asdf');
expect(r1).toEqual(4);
From packages/zod/src/v4/classic/schemas.ts:114-116, the .transform() method signature:
transform<NewOut>(
transform: (arg: Output, ctx: RefinementCtx) => NewOut | Promise<NewOut>
): ZodPipe<this, ZodTransform<Awaited<NewOut>, Output>>
Transformations receive two parameters:
arg - The validated output value
ctx - A context object for adding issues
Common Use Cases
Type Coercion
Convert strings to numbers:
const NumberFromString = z.string().transform((val) => Number.parseFloat(val));
const result = NumberFromString.parse('42.5'); // 42.5 (number)
From packages/zod/src/v4/classic/tests/transform.test.ts:94-102:
const numToString = z.number().transform((n) => String(n));
const data = z.object({
id: numToString,
}).parse({ id: 5 });
expect(data).toEqual({ id: '5' });
String Manipulation
const Uppercase = z.string().transform((val) => val.toUpperCase());
const result = Uppercase.parse('hello'); // 'HELLO'
const TrimmedLowercase = z.string()
.transform((val) => val.trim())
.transform((val) => val.toLowerCase());
const clean = TrimmedLowercase.parse(' HELLO '); // 'hello'
Data Normalization
const PhoneNumber = z.string().transform((val) => {
// Remove all non-digit characters
return val.replace(/\D/g, '');
});
const phone = PhoneNumber.parse('(555) 123-4567'); // '5551234567'
Object Reshaping
const ApiResponse = z.object({
user_id: z.number(),
user_name: z.string(),
user_email: z.string(),
}).transform((data) => ({
id: data.user_id,
name: data.user_name,
email: data.user_email,
}));
const result = ApiResponse.parse({
user_id: 1,
user_name: 'Alice',
user_email: '[email protected]',
});
// { id: 1, name: 'Alice', email: '[email protected]' }
Transformations can be asynchronous:
const schema = z.number().transform(async (n) => {
const response = await fetch(`/api/multiply?n=${n}`);
return response.json();
});
const result = await schema.parseAsync(5);
From packages/zod/src/v4/classic/tests/transform.test.ts:104-113:
const numToString = z.number().transform(async (n) => String(n));
const data = await z.object({
id: numToString,
}).parseAsync({ id: 5 });
expect(data).toEqual({ id: '5' });
Async transformations require using parseAsync() or safeParseAsync(). Calling parse() on a schema with async transformations will throw an error.
Multiple transformations can be chained:
const stringToNumber = z.string().transform((arg) => Number.parseFloat(arg));
const doubler = stringToNumber.transform((val) => val * 2);
const result = doubler.parse('5'); // 10
From packages/zod/src/v4/classic/tests/transform.test.ts:186-192:
const stringToNumber = z.string().transform((arg) => Number.parseFloat(arg));
const doubler = stringToNumber.transform((val) => {
return val * 2;
});
expect(doubler.parse('5')).toEqual(10);
Use the context object to add validation errors:
const schema = z.string().transform((data, ctx) => {
const parsed = Number.parseInt(data);
if (Number.isNaN(parsed)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Not a valid number',
});
return z.NEVER; // Signal validation failure
}
return parsed;
});
const result = schema.safeParse('not-a-number');
// result.success === false
// result.error.issues[0].message === 'Not a valid number'
From packages/zod/src/v4/classic/tests/transform.test.ts:4-28:
const strs = ['foo', 'bar'];
const schema = z.string().transform((data, ctx) => {
const i = strs.indexOf(data);
if (i === -1) {
ctx.addIssue({
input: data,
code: 'custom',
message: `${data} is not one of our allowed strings`,
});
}
return data.length;
});
const result = schema.safeParse('asdf');
expect(result.success).toEqual(false);
Using z.NEVER
From packages/zod/src/v4/classic/tests/transform.test.ts:62-83:
const foo = z.number()
.optional()
.transform((val, ctx) => {
if (!val) {
ctx.addIssue({
input: val,
code: z.ZodIssueCode.custom,
message: 'bad',
});
return z.NEVER;
}
return val;
});
type foo = z.infer<typeof foo>; // number (not number | undefined)
const arg = foo.safeParse(undefined);
if (!arg.success) {
expect(arg.error.issues[0].message).toEqual('bad');
}
Returning z.NEVER from a transformation signals that validation should fail, and it also narrows the output type appropriately.
Transformations change the output type while keeping the input type:
const schema = z.string().transform((val) => val.length);
type Input = z.input<typeof schema>; // string
type Output = z.output<typeof schema>; // number
// The parser accepts strings
const result = schema.parse('hello'); // result is number
Practical Examples
Date Parsing
const DateFromString = z.string().transform((str) => new Date(str));
const schema = z.object({
createdAt: DateFromString,
updatedAt: DateFromString,
});
const data = schema.parse({
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
});
// data.createdAt is Date
// data.updatedAt is Date
JSON Parsing
const JsonString = z.string().transform((str, ctx) => {
try {
return JSON.parse(str);
} catch (error) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid JSON',
});
return z.NEVER;
}
});
const result = JsonString.parse('{"name": "Alice"}');
// { name: 'Alice' }
URL Slug Generation
const Slug = z.string().transform((val) => {
return val
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
});
const slug = Slug.parse('Hello World! 123'); // 'hello-world-123'
const FormSchema = z.object({
name: z.string().transform((val) => val.trim()),
email: z.string().email().transform((val) => val.toLowerCase()),
age: z.string().transform((val) => Number.parseInt(val, 10)),
newsletter: z.string().transform((val) => val === 'true'),
});
type FormInput = z.input<typeof FormSchema>;
// {
// name: string;
// email: string;
// age: string;
// newsletter: string;
// }
type FormOutput = z.output<typeof FormSchema>;
// {
// name: string;
// email: string;
// age: number;
// newsletter: boolean;
// }
const data = FormSchema.parse({
name: ' Alice ',
email: '[email protected]',
age: '25',
newsletter: 'true',
});
// {
// name: 'Alice',
// email: '[email protected]',
// age: 25,
// newsletter: true
// }
const UserApiResponse = z.object({
id: z.number(),
first_name: z.string(),
last_name: z.string(),
email: z.string(),
created_at: z.string(),
}).transform((data) => ({
id: data.id,
name: `${data.first_name} ${data.last_name}`,
email: data.email,
createdAt: new Date(data.created_at),
}));
const user = UserApiResponse.parse({
id: 1,
first_name: 'Alice',
last_name: 'Smith',
email: '[email protected]',
created_at: '2024-01-01T00:00:00Z',
});
// {
// id: 1,
// name: 'Alice Smith',
// email: '[email protected]',
// createdAt: Date
// }
Transformations run AFTER all validations:
const schema = z.string()
.min(5) // 1. Validation
.email() // 2. Validation
.transform((val) => // 3. Transformation (runs last)
val.toLowerCase()
);
From packages/zod/src/v4/classic/tests/transform.test.ts:194-200:
const schema = z.string()
.refine(() => false) // This fails
.transform((val) => val.toUpperCase()); // This never runs
const result = schema.safeParse('asdf');
expect(result.success).toEqual(false);
If validation fails, transformations are never executed. This ensures you only transform valid data.
Understand the difference:
// Default: provides value when undefined
const withDefault = z.string().default('hello');
withDefault.parse(undefined); // 'hello'
// Transform: modifies provided value
const withTransform = z.string().transform((val) => val.toUpperCase());
withTransform.parse('hello'); // 'HELLO'
withTransform.parse(undefined); // Error: expected string
- Refinements (
.refine()) - Add validation, don’t change data
- Transformations (
.transform()) - Modify data after validation
// Refinement: validates but doesn't change the value
const refined = z.string().refine((val) => val.length > 5);
type RefinedOutput = z.output<typeof refined>; // string
// Transformation: changes the value
const transformed = z.string().transform((val) => val.length);
type TransformedOutput = z.output<typeof transformed>; // number
Transformations add overhead to parsing. For high-performance scenarios, consider whether you really need to transform during validation or if it’s better to transform separately.
// Slower: transformation during parse
const schema = z.array(z.string().transform((s) => s.toUpperCase()));
schema.parse(largeArray);
// Faster: transform after parse
const schema = z.array(z.string());
const data = schema.parse(largeArray);
const transformed = data.map((s) => s.toUpperCase());
Combining with Pipes
Transformations can be combined with pipes for complex data flows:
const transform1 = z.transform((val: string) => val.toUpperCase());
const transform2 = z.transform((val: string) => val.length);
const schema = z.string().transform(transform1).pipe(transform2);
const result = schema.parse('hello'); // 5
Best Practices
- Keep transformations simple - Complex logic is hard to debug
- Use transformations for data coercion - Converting types, normalizing formats
- Handle errors explicitly - Use
ctx.addIssue() for validation failures
- Consider performance - Avoid expensive operations in transforms
- Type safety - TypeScript will infer the output type correctly
// Good: Simple, clear transformation
const Uppercase = z.string().transform((val) => val.toUpperCase());
// Bad: Complex business logic in transformation
const Bad = z.string().transform(async (val, ctx) => {
const user = await db.users.find(val);
if (!user) {
ctx.addIssue({ code: 'custom', message: 'User not found' });
return z.NEVER;
}
const permissions = await db.permissions.get(user.id);
// ... more complex logic
return { user, permissions };
});
Common Gotchas
Async in Sync Context
const bad = z.string().transform(async (val) => val.toUpperCase());
// This will throw!
bad.parse('hello');
// Must use async parse
await bad.parseAsync('hello'); // OK
Returning Undefined
// Be careful with undefined returns
const schema = z.string().transform((val) => {
if (val === 'skip') {
return undefined; // Output type becomes string | undefined
}
return val.toUpperCase();
});
type Output = z.output<typeof schema>; // string | undefined
Next Steps