| | |

Zod 14 🛡️ Transformations — .transform() for input-to-output conversion

⏱️ Estimated read: 10 min | |

.transform() changes the data after validation. It receives the parsed value and returns a new value, which becomes the schema’s output type. The input type remains whatever the base schema accepts. This split—z.input for what parse accepts, z.output for what it returns—is the defining consequence of using .transform(): a schema with a transform has two types, and code on each side of the parse call needs the correct one .

This chapter covers the transformation method, the input/output type divergence, the ordering rules that determine when transforms and refinements execute, and the v4 behavioral change that makes transforms run even when a preceding refinement fails. The sections that follow walk through basic transforms, the type split, and the composition patterns that keep validation and conversion separate.

Key point: .transform() runs after validation and changes the output type; z.infer returns the output, z.input returns the pre-transform input, and in v4 a transform runs even when a preceding .refine() fails unless the refinement aborts.


a. Basic transforms and the type split

.transform() takes a function that receives the validated value and returns the new value. The function can be synchronous or asynchronous; async transforms require parseAsync() or safeParseAsync() .

import { z } from "zod";

// String to Date
const DateSchema = z.string().transform((s) => new Date(s));

type DateInput = z.input<typeof DateSchema>;   // string
type DateOutput = z.output<typeof DateSchema>; // Date

// Parse returns the output type
const date: Date = DateSchema.parse("2026-05-05");

The z.input and z.output utilities are the correct way to type the two ends. A function that receives raw data—an API response, a form submission—types its parameter as z.input<typeof Schema>. A function that receives parsed data—business logic, a database writer—types its parameter as z.infer<typeof Schema> (which equals z.output) .

// Raw API handler: input type
function handleApiResponse(raw: z.input<typeof DateSchema>) {
  const date = DateSchema.parse(raw); // date is Date
  return date.toISOString();
}

// Business logic: output type
function formatDate(date: z.infer<typeof DateSchema>) {
  return date.toISOString(); // Date method is available
}

Using z.infer for the input parameter produces a type error at the call site: the function expects Date, but the caller passes string. The compiler catches the mismatch, which is the point of the split .


b. Ordering: transform, refine, and pipe

Transforms and refinements can be interleaved, and the order matters. A .refine() before a .transform() receives the pre-transform value. A .refine() after a .transform() receives the post-transform value.

const Schema = z.string()
  .refine((s) => s.length > 0, "Cannot be empty")  // string
  .transform((s) => s.toUpperCase())                // string
  .refine((s) => s !== "ADMIN", "Reserved");       // uppercase string

The rule is that .transform() should convert, not validate. A transform that throws on invalid input bypasses Zod’s error handling and produces a runtime exception instead of a ZodError. The correct pattern is to validate first, then transform .

// BAD: validation inside transform
z.string().transform((s) => {
  const n = parseInt(s, 10);
  if (isNaN(n)) throw new Error("Not a number"); // bypasses Zod
  return n;
});

// GOOD: validate, then transform
z.string()
  .refine((s) => !isNaN(parseInt(s, 10)), "Must be numeric")
  .transform((s) => parseInt(s, 10));

// BETTER: use pipe for staged parsing
z.string().pipe(z.coerce.number()).pipe(z.int());

.pipe() is the composition mechanism for chaining schemas where the output of one becomes the input of the next. It is stricter in v4 than in v3, and a transform whose output type does not match the next schema’s input type produces a compile error . The workaround is to annotate the transform’s return type as unknown or to use z.any() at the boundary, but the cleaner fix is to restructure the pipeline so the types align.


c. The v4 change: transform runs after a failed refine

In Zod v3, a .transform() after a failed .refine() did not run. In v4, the transform runs regardless of the refinement’s result. This is a consequence of the new effect-layering architecture, and it is not documented in the migration guide .

// v4: transform runs even when refine fails
const Schema = z.string()
  .refine((s) => {
    try { JSON.parse(s); return true; }
    catch { return false; }
  }, "Must be valid JSON")
  .transform((s) => JSON.parse(s)); // runs even on invalid input

Schema.safeParse("not-json");
// v3: { success: false, error: ZodError }
// v4: throws SyntaxError from JSON.parse

The failure mode is a runtime exception instead of a structured ZodError, which means the error loses its path and message. The issue was reported and acknowledged as a bug by the maintainer, but the behavior stems from the v4 architecture: transforms are applied unless an aborting issue is explicitly pushed .

The mitigation is to use .superRefine() (or .check() in v4) and push an aborting issue, or to restructure the schema so the transform’s input is guaranteed valid by a preceding schema rather than a refinement.

// Safer: use a schema that guarantees the shape
const JsonSchema = z.string()
  .pipe(z.string().refine((s) => {
    try { JSON.parse(s); return true; }
    catch { return false; }
  }, "Must be valid JSON"))
  .transform((s) => JSON.parse(s));

The .pipe() form isolates the refinement from the transform, so the transform only receives a value that passed the refinement’s check.


Complete Example Session

// ============================================
// PART 1: STRING TO DATE
// ============================================
import { z } from "zod";

const DateSchema = z.string().transform((s) => new Date(s));

type DateInput = z.input<typeof DateSchema>;   // string
type DateOutput = z.output<typeof DateSchema>; // Date

// ============================================
// PART 2: TYPING THE TWO ENDS
// ============================================
function handleRaw(raw: DateInput): DateOutput {
  return DateSchema.parse(raw); // parse returns Date
}

function useDate(date: z.infer<typeof DateSchema>) {
  return date.toISOString(); // Date method
}

// ============================================
// PART 3: VALIDATE, THEN TRANSFORM
// ============================================
const NumericString = z.string()
  .refine((s) => !isNaN(parseInt(s, 10)), "Must be numeric")
  .transform((s) => parseInt(s, 10));

// ============================================
// PART 4: ASYNC TRANSFORM
// ============================================
const UserSchema = z.object({ id: z.number() })
  .transform(async (data) => {
    const profile = await fetchProfile(data.id);
    return { ...data, profile };
  });

// Must use parseAsync
// const user = await UserSchema.parseAsync({ id: 1 });

// ============================================
// PART 5: THE v4 REFINE-TRANSFORM BEHAVIOR
// ============================================
// Transform runs even when refine fails
// Use pipe to isolate the transform
const SafeJson = z.string()
  .pipe(z.string().refine((s) => {
    try { JSON.parse(s); return true; } catch { return false; }
  }, "Must be valid JSON"))
  .transform((s) => JSON.parse(s));

The parts covered the basic transform, the input/output type split, the validate-then-transform pattern, the async transform requirement, and the pipe-isolated transform.


Quick Reference

Transform Basics

ItemDetail
Method.transform(fn)
Function receivesValidated value
Function returnsNew output value
Input typez.input<typeof S>
Output typez.infer<typeof S> / z.output<typeof S>

Ordering Rules

OrderReceives
.refine() before .transform()Pre-transform value
.refine() after .transform()Post-transform value
.transform() after failed .refine() (v3)Skipped
.transform() after failed .refine() (v4)Runs

Async Transforms

RequirementDetail
parseAsync()Required for async transform
safeParseAsync()Required for async transform
Sync parse()Throws error

Best Practices

✅ Do This:

// Use transform for conversion
z.string().transform((s) => new Date(s))                     // ✅

// Validate before transforming
z.string().refine(isNumeric).transform(toNumber)             // ✅

// Use z.input for raw data parameters
function handle(raw: z.input<typeof Schema>) { ... }         // ✅

// Use parseAsync for async transforms
await Schema.parseAsync(data)                                // ✅

❌ Don’t Do This:

// Throw inside transform
z.string().transform((s) => { if (bad) throw new Error() })  // ❌

// Use z.infer for raw input parameter
function handle(raw: z.infer<typeof TransformSchema>) { ... } // ❌

// Sync parse with async transform
Schema.parse(data) // throws                                   // ❌

// Assume v3 ordering in v4
// transform runs even after failed refine                    // ❌

Common Pitfalls

PitfallWhy It HappensFix
Type error at parse boundaryz.infer used for inputUse z.input
Runtime exception instead of ZodErrorThrow inside transformValidate first
Async transform ignoredSync parse() usedUse parseAsync()
Transform runs on invalid inputv4 behavior after failed refineUse .pipe() to isolate

Real-World Examples

1. String to Date

z.string().transform((s) => new Date(s));

2. String to number

z.string().transform((s) => parseInt(s, 10));

3. Comma-separated to array

z.string().transform((s) => s.split(","));

4. Boolean coercion

z.union([z.boolean(), z.literal(1), z.literal(0)])
  .transform((v) => Boolean(v));

5. Input type for raw data

type Raw = z.input<typeof Schema>;

6. Output type for business logic

type Parsed = z.infer<typeof Schema>;

7. Async transform

await Schema.parseAsync(data);

8. Pipe-isolated transform

z.string().pipe(ValidSchema).transform(convert);

9. Transform with refine after

z.string().transform((s) => s.length).refine((n) => n > 3);

10. Form amount parsing

amount: z.string().transform((s) => parseFloat(s));

Visual

The Transform Pipeline

┌─────────────────────────────────────────────────────────┐
│  z.string().transform((s) => new Date(s))               │
│                                                         │
│  input ──► validate string ──► transform ──► output     │
│    │                            │                       │
│    ▼                            ▼                       │
│  string                     Date                        │
│  (z.input)                  (z.output)                  │
│                                                         │
│  Two types, one schema                                  │
└─────────────────────────────────────────────────────────┘

Type Split

┌─────────────────────────────────────────────────────────┐
│  z.input<typeof Schema>    z.output<typeof Schema>      │
│         │                          │                    │
│         ▼                          ▼                    │
│  What parse accepts         What parse returns          │
│  Raw API data               Parsed business data        │
│  Form values                Validated model             │
└─────────────────────────────────────────────────────────┘

v4 Refine-Transform Behavior

┌─────────────────────────────────────────────────────────┐
│  z.string()                                             │
│    .refine((s) => isJson(s))                            │
│    .transform((s) => JSON.parse(s))                     │
│                                                         │
│  Input: "not-json"                                      │
│         │                                               │
│         ▼                                               │
│  v3: refine fails → transform skipped → ZodError        │
│  v4: refine fails → transform still runs → SyntaxError  │
│                                                         │
│  Fix: isolate with pipe                                 │
└─────────────────────────────────────────────────────────┘

Async Transform Requirement

┌─────────────────────────────────────────────────────────┐
│  .transform(async (data) => {                           │
│    const result = await fetch(data.id);                 │
│    return { ...data, result };                          │
│  })                                                     │
│                                                         │
│  Schema.parse(data)       → throws                      │
│  await Schema.parseAsync(data) → works                  │
│                                                         │
│  Async transforms require async parse methods           │
└─────────────────────────────────────────────────────────┘

Summary

ItemValue
Method.transform(fn)
RunsAfter validation
Input typez.input<typeof S>
Output typez.infer<typeof S> / z.output<typeof S>
Async requirementparseAsync() / safeParseAsync()
v4 changeTransform runs after failed refine
Safe patternValidate first, then transform

Key takeaways:

  • .transform() changes the output type, not the input type. The schema accepts whatever the base schema accepts, and returns whatever the transform returns. z.infer gives the output; z.input gives the input .
  • Use z.input for raw data parameters. A function that receives an API response or form submission types its parameter as z.input<typeof Schema>. Using z.infer produces a type error at the call site .
  • Validate before transforming. A transform that throws on invalid input bypasses Zod’s error handling. Use .refine() to validate, .transform() to convert, and .pipe() to stage the pipeline .
  • Async transforms require async parsing. parse() throws when the schema contains an async transform; parseAsync() or safeParseAsync() is required .
  • v4 changed the refine-transform ordering. A transform after a failed refinement now runs, which can produce runtime exceptions instead of ZodError. Isolate the transform with .pipe() to ensure it only receives validated input .

Remember: A transform is a conversion, not a validation. It runs after the base schema passes, changes the value’s type, and splits the schema into an input type and an output type. Code on the raw side uses z.input; code on the parsed side uses z.infer. Validate before transforming, use .pipe() when the transform depends on a refinement, and use parseAsync() when the transform awaits anything. The transform is the bridge between the wire format and the application’s model; the type split is the toll.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!