How to Use Zod's preprocess for Pre-Parsing Data Transformation
Zod's preprocess() function creates a ZodPipe that applies a transformation to raw input before validation, enabling type coercion, sanitization, and async lookups while supporting early error handling via ctx.addIssue().
Zod's preprocess utility in the colinhacks/zod repository provides a powerful mechanism for transforming raw data before schema validation occurs. Unlike standard transforms that run after type checking, preprocessing operates on the initial unknown input, making it ideal for sanitizing user input or coercing primitive types. This guide examines the implementation details in packages/zod/src/v4/classic/schemas.ts and demonstrates practical patterns for leveraging preprocess in your validation pipelines.
What Is Zod preprocess?
z.preprocess() is a schema composition helper that constructs a pipeline (specifically a ZodPipe) connecting a transformation function to a target schema. According to the source code in packages/zod/src/v4/classic/schemas.ts at lines 2404-2409, the function signature is:
export function preprocess<A, U extends core.SomeType, B = unknown>(
fn: (arg: B, ctx: core.$RefinementCtx) => A,
schema: U
): ZodPipe<ZodTransform<A, B>, U> {
return pipe(transform(fn as any), schema as any) as any;
}
This implementation creates a ZodEffects instance that first applies your transformation function to the raw input, then validates the result against the provided schema. The preprocessing step receives the unrefined input as type unknown and runs before any schema constraints are checked.
How Zod preprocess Works Internally
When you invoke z.preprocess(fn, schema), the resulting schema executes a four-stage pipeline:
- Receives raw input of type
unknownfrom the parser. - Executes the transformation function
fnwith the input and a refinement context (ctx).- The function may be synchronous or asynchronous.
- Use
ctx.addIssue()insidefnto report validation errors and halt processing without invoking the downstream schema.
- Returns the transformed value to the next stage, or returns
z.NEVERto abort the pipeline immediately. - Validates against the downstream schema, which receives the transformed value instead of the original input.
Because the transformation runs prior to validation, preprocess is distinct from z.transform(), which validates first and transforms second. This ordering makes preprocess essential for scenarios where the raw input shape differs fundamentally from the target type.
Practical Zod preprocess Examples
Basic Type Coercion
Use preprocess to coerce primitive values before they reach the schema. This pattern handles form inputs that arrive as strings but must function as numbers or booleans:
import { z } from "zod/v4";
const castToString = z.preprocess((val) => String(val), z.string());
castToString.parse(123); // → "123"
castToString.parse(null); // → "null"
Source reference: packages/zod/src/v4/classic/schemas.ts
Async Data Fetching
The transformation function can return a Promise, enabling async operations like database lookups or API calls before validation:
import { z } from "zod/v4";
const fetchUser = async (id: unknown) => {
const response = await fetch(`https://api.example.com/users/${id}`);
if (!response.ok) throw new Error("User not found");
return await response.json(); // returns { name: string, age: number }
};
const userSchema = z.object({
name: z.string(),
age: z.number(),
});
const userPreprocess = z.preprocess(fetchUser, userSchema);
// Resolves the fetch, then validates the response shape
await userPreprocess.parseAsync("42");
Early Exit Validation Using ctx.addIssue
For conditional validation that must reject invalid input before expensive transformations, use the ctx parameter to add issues and return z.NEVER:
import { z } from "zod/v4";
const nonEmptyNumber = z.preprocess((val, ctx) => {
if (val === "" || val == null) {
ctx.addIssue({
code: "custom",
message: "value required"
});
return z.NEVER; // Aborts pipeline; downstream schema never runs
}
return Number(val);
}, z.number());
nonEmptyNumber.safeParse(""); // → { success: false, error: ... }
Test reference: packages/zod/src/v4/classic/tests/preprocess.test.ts
Chaining with pipe
Combine preprocess with .pipe() to apply additional transformations after the initial schema validation:
import { z } from "zod/v4";
const toUpperCase = z.preprocess(
(val) => String(val).toUpperCase(),
z.string()
).pipe(z.string().min(3));
toUpperCase.parse("ab"); // Transforms to "AB", then fails min(3) validation
preprocess vs coerce: When to Use Each
While Zod provides dedicated z.coerce.* helpers for primitive type casting, preprocess remains necessary for complex logic:
| Scenario | Recommended Approach |
|---|---|
| String → Number (simple form inputs) | z.coerce.number() |
| Async external lookups (API/database calls) | z.preprocess() |
| Conditional early termination (empty checks) | z.preprocess() with ctx.addIssue() |
| Complex sanitization (trim, regex, JSON parsing) | z.preprocess() |
| Multi-step transformations | z.preprocess() combined with .pipe() |
Summary
z.preprocess()constructs aZodPipethat transforms raw input before validation, implemented inpackages/zod/src/v4/classic/schemas.ts.- The transformation function receives
unknowninput and actxobject for reporting issues without executing the downstream schema. - Return
z.NEVERfrom the preprocess function to abort validation early and prevent the target schema from running. preprocesssupports async functions, making it suitable for fetching or resolving data before type checking.- For simple primitive coercion, prefer
z.coerce.*; reservepreprocessfor complex logic, async operations, or conditional validation.
Frequently Asked Questions
Can I use async functions with z.preprocess?
Yes. The transformation function passed to z.preprocess() can return a Promise. When using async preprocessing, call .parseAsync() or .safeParseAsync() on the schema rather than the synchronous methods. The parser awaits the preprocessing result before passing it to the downstream schema.
How do I stop validation early inside a preprocess function?
Use the second argument to your preprocess function—the refinement context (ctx)—to add validation issues, then return z.NEVER. This pattern prevents the downstream schema from executing and immediately returns the custom error. Reference the test suite in packages/zod/src/v4/classic/tests/preprocess.test.ts for implementation examples.
What is the difference between preprocess and transform in Zod?
z.preprocess() runs transformations on the raw unknown input before validation, while z.transform() validates the input against the base schema first, then applies the transformation to the parsed result. Use preprocess when you need to modify the input to match the schema's expected type; use transform when you want to convert an already-validated type into a different output format.
Can I chain multiple preprocess steps together?
Yes. You can nest z.preprocess() calls or use the .pipe() method to chain multiple transformations. Each step in the pipe receives the output of the previous step. For complex pipelines, piping multiple preprocess schemas together allows you to compose reusable transformation logic while maintaining type safety throughout the chain.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →