Skip to main content

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.

Basic Transformation

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);

Transformation Signature

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:
  1. arg - The validated output value
  2. 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]' }

Async Transformations

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.

Chaining Transformations

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);

Error Handling in Transformations

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.

Input vs Output Types

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'

Form Data Processing

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
// }

API Response Transformation

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
// }

Transformation Order

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.

Transformations vs Defaults

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

Transformations vs Refinements

  • 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

Performance Considerations

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

  1. Keep transformations simple - Complex logic is hard to debug
  2. Use transformations for data coercion - Converting types, normalizing formats
  3. Handle errors explicitly - Use ctx.addIssue() for validation failures
  4. Consider performance - Avoid expensive operations in transforms
  5. 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

Build docs developers (and LLMs) love