Zod 14 🛡️ Transformations — .transform() for input-to-output conversion
.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
| Item | Detail |
|---|---|
| Method | .transform(fn) |
| Function receives | Validated value |
| Function returns | New output value |
| Input type | z.input<typeof S> |
| Output type | z.infer<typeof S> / z.output<typeof S> |
Ordering Rules
| Order | Receives |
|---|---|
.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
| Requirement | Detail |
|---|---|
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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Type error at parse boundary | z.infer used for input | Use z.input |
| Runtime exception instead of ZodError | Throw inside transform | Validate first |
| Async transform ignored | Sync parse() used | Use parseAsync() |
| Transform runs on invalid input | v4 behavior after failed refine | Use .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
| Item | Value |
|---|---|
| Method | .transform(fn) |
| Runs | After validation |
| Input type | z.input<typeof S> |
| Output type | z.infer<typeof S> / z.output<typeof S> |
| Async requirement | parseAsync() / safeParseAsync() |
| v4 change | Transform runs after failed refine |
| Safe pattern | Validate 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.infergives the output;z.inputgives the input .- Use
z.inputfor raw data parameters. A function that receives an API response or form submission types its parameter asz.input<typeof Schema>. Usingz.inferproduces 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()orsafeParseAsync()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.
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!