Skip to main content

Overview

The .pipe() method allows you to chain two schemas together, where the output of the first schema becomes the input of the second. This is useful for multi-stage validation and transformation pipelines.

Signature

schema.pipe(targetSchema)
targetSchema
ZodType
required
The schema to pipe into. Its input type must match the output type of the source schema.
Returns: ZodPipe<A, B> where A is the source schema and B is the target schema

Basic Usage

String to Number Pipeline

The most common use case is converting and validating data in stages:
const schema = z.string()
  .transform(Number)
  .pipe(z.number().positive());

schema.parse("42");    // 42
schema.parse("-5");    // Error: Number must be positive
schema.parse("hello"); // Error: Invalid number

Async Transformations

Pipes work with async transformations:
const schema = z.string()
  .transform(async (val) => Number(val))
  .pipe(z.number().int());

await schema.parseAsync("42"); // 42

Advanced Patterns

Multi-Stage Validation

Use pipes to validate at different stages of transformation:
const schema = z.string()
  .min(1, "Input cannot be empty")
  .transform((val) => val.trim())
  .pipe(
    z.string()
      .email("Must be a valid email")
      .transform((email) => email.toLowerCase())
  );

schema.parse("  [email protected]  "); // "[email protected]"

Conditional Pipelines

Combine pipes with refinements for conditional logic:
const schema = z
  .pipe(
    z.transform((v) => v === "none" ? undefined : v),
    z.string()
  )
  .catch("default");

schema.parse("ok");    // "ok"
schema.parse("none");  // "default" (transformed to undefined, caught)
schema.parse(15);      // "default" (invalid type, caught)

Refinement Before Transformation

Validate before transforming to prevent invalid transformations:
const schema = z.string()
  .refine((c) => c === "1234", "Must be 1234")
  .transform((val) => Number(val))
  .refine((c) => c === 1234, "Result must be 1234");

schema.parse("1234"); // 1234

const result = schema.safeParse("4321");
// Error at first refinement, transformation never runs

Error Handling

Non-Fatal Errors

By default, refinement errors are non-fatal and allow subsequent checks:
const schema = z.string()
  .refine((c) => c.length > 5, "Too short")
  .transform((val) => val.toUpperCase())
  .refine((c) => c.startsWith("A"), "Must start with A");

const result = schema.safeParse("abc");
// Both refinement errors are reported:
// - "Too short"
// - (transformation runs anyway)
// - "Must start with A"

Fatal Errors

Use abort: true to stop validation on error:
const schema = z.string()
  .refine((c) => c === "1234", { 
    message: "Must be 1234", 
    abort: true 
  })
  .transform((val) => Number(val))
  .refine((c) => c === 1234, "Result must be 1234");

const result = schema.safeParse("4321");
// Only first error reported, transformation and second refinement are skipped

Bidirectional Validation

Encoding and Decoding

Pipes support both forward (decode) and reverse (encode) operations:
const schema = z.string().pipe(z.string());

// Forward: parse/decode
z.decode(schema, "hello");  // "hello"
z.parse(schema, "hello");   // "hello"

// Reverse: encode
z.encode(schema, "hello");  // "hello"

Transform Encoding Errors

Unidirectional transforms cannot be encoded:
const schema = z.string().transform((val) => val.length);

try {
  z.encode(schema, 1234);
} catch (error) {
  console.log(error.message);
  // "Encountered unidirectional transform during encode: ZodTransform"
}

Type Safety

Pipes maintain type safety throughout the chain:
const schema = z.string()
  .transform((s) => s.length)
  .pipe(z.number().positive());

type Input = z.input<typeof schema>;   // string
type Output = z.output<typeof schema>; // number

// TypeScript enforces correct types
const result: number = schema.parse("hello"); // 5

Type Mismatch Errors

TypeScript will catch type mismatches between piped schemas:
// ❌ Type error: number is not assignable to string
const invalid = z.number()
  .pipe(z.string()); // Error!

// ✅ Correct: transform to match next schema's input
const valid = z.number()
  .transform(String)
  .pipe(z.string());

Comparison with Transform

Feature.pipe().transform()
PurposeChain complete schemasTransform within schema
ValidationValidates at each stageValidates before transform
ReversibleCan be reversible (with codec)One-way only
Type safetyStrict input/output matchingFlexible
Use caseMulti-stage pipelinesSingle transformation
// Using pipe (multi-stage)
const piped = z.string()
  .transform(Number)
  .pipe(
    z.number()
      .int()
      .positive()
  );

// Using transform only (single stage)
const transformed = z.string()
  .transform((s) => {
    const num = Number(s);
    if (!Number.isInteger(num) || num <= 0) {
      throw new Error("Invalid");
    }
    return num;
  });

Common Patterns

String Preprocessing

Clean and validate string input:
const emailSchema = z.string()
  .transform((s) => s.trim().toLowerCase())
  .pipe(z.string().email());

emailSchema.parse("  [email protected]  "); // "[email protected]"

JSON Parsing

Parse and validate JSON strings:
const jsonSchema = z.string()
  .transform((str) => JSON.parse(str))
  .pipe(
    z.object({
      id: z.number(),
      name: z.string(),
    })
  );

const data = jsonSchema.parse('{"id": 1, "name": "Alice"}');
// { id: 1, name: "Alice" }

Date Normalization

const dateSchema = z.string()
  .transform((s) => new Date(s))
  .pipe(
    z.date()
      .min(new Date("2020-01-01"))
      .max(new Date())
  );

dateSchema.parse("2023-06-15"); // Date object

Form Value Processing

const formValueSchema = z.string()
  .transform((val) => val === "" ? undefined : val)
  .pipe(z.string().min(3).optional());

formValueSchema.parse("");      // undefined
formValueSchema.parse("hello"); // "hello"

Best Practices

Validate early

Place validation as early as possible in the pipeline to catch errors before expensive transformations.

Use meaningful error messages

Add custom error messages at each stage to help identify where validation failed.

Consider performance

Each pipe stage adds overhead. For simple cases, a single transform might be more efficient.

Type safety first

Let TypeScript guide you - if types don’t match, add explicit transforms to bridge the gap.

Error Examples

Type Mismatch

const schema = z.string()
  .transform(Number)
  .pipe(z.number());

const result = schema.safeParse("not a number");

if (!result.success) {
  console.log(result.error.issues);
  // [
  //   {
  //     code: "invalid_type",
  //     expected: "number",
  //     received: "nan",
  //     path: [],
  //     message: "Invalid input: expected number, received nan"
  //   }
  // ]
}

Validation Failure

const schema = z.string()
  .transform(Number)
  .pipe(z.number().int().positive());

const result = schema.safeParse("-5");

if (!result.success) {
  console.log(result.error.issues);
  // [
  //   {
  //     code: "too_small",
  //     minimum: 0,
  //     inclusive: false,
  //     path: [],
  //     message: "Number must be greater than 0"
  //   }
  // ]
}

See Also

Build docs developers (and LLMs) love