Capítulo 28 de 61

Chapter 28: Transforms & Preprocess

Core Idea

Transforms perform a one-way reshaping of a value (unlike codecs, which are bidirectional) — they accept anything and return a new value, optionally reporting validation issues via a ctx parameter.

Key Concepts

  • z.transform(fn): accepts any input, returns fn(val) as the parsed output; the inferred output type comes from what fn returns.
  • .transform(fn): convenience method for the common schema.pipe(z.transform(fn)) pattern (Zod only, no Zod Mini equivalent — use z.pipe() there).
  • ctx.issues / z.NEVER: inside a transform, push a validation issue onto ctx.issues to report failure, then return z.NEVER to exit without corrupting the inferred return type.
  • Async transforms: .transform(async (val) => ...) is supported, but parsing must then use .parseAsync()/.safeParseAsync() — using the sync .parse() throws.
  • z.preprocess(fn, schema): the inverse composition — pipe a raw-value transform into a schema. Input type defaults to unknown; annotate the preprocessor's parameter type to narrow z.input<> (useful for libraries like react-hook-form that derive form value types from the input type).

Code Examples

const castToString = z.transform((val) => String(val));
castToString.parse(123); // => "123"

const coercedInt = z.transform((val, ctx) => {
  const parsed = Number.parseInt(String(val));
  if (Number.isNaN(parsed)) {
    ctx.issues.push({ code: "custom", message: "Not a number", input: val });
    return z.NEVER;
  }
  return parsed;
});

const stringToLength = z.string().transform(val => val.length);

const idToUser = z.string().transform(async (id) => db.getUserById(id));
const user = await idToUser.parseAsync("abc123");

const trimmed = z.preprocess(
  (val: string | null | undefined) => val?.trim() ?? "",
  z.string()
);
  • What it demonstrates: pushing a custom issue and bailing out with z.NEVER, async transforms requiring parseAsync, and narrowing a preprocessor's accepted input type.

Anti-patterns

  • Throwing inside a transform function: not caught by Zod — push to ctx.issues and return z.NEVER instead.
  • Using .parse() (sync) on a schema with an async transform: throws — must use .parseAsync()/.safeParseAsync().

Key Takeaways

  1. Transforms are one-way; reach for a codec instead when you need to convert back to the original shape later.
  2. ctx.issues.push(...) + return z.NEVER is the correct way to fail validation from inside a transform — never throw.
  3. Any async transform anywhere in a schema forces every downstream .parse() call on that schema to be the async variant.

Connects To

  • Codecs — Basics: the bidirectional alternative to a one-way transform.
  • Refinements: for pass/fail validation without reshaping the value; transforms are for reshaping.