Files
gruperly/.agents/skills/zod/references/compose-pipe.md
2026-09-04 16:49:24 -03:00

3.3 KiB

title, impact, impactDescription, tags
title impact impactDescription tags
Use pipe() for Multi-Stage Validation MEDIUM Chaining transforms loses intermediate type info; pipe() explicitly shows data flow through validation stages compose, pipe, pipeline, transform

Use pipe() for Multi-Stage Validation

When data needs to pass through multiple validation stages (coerce string to number, then validate range, then transform to currency), use .pipe() to chain schemas. This makes the data transformation pipeline explicit and each stage's type clear.

Incorrect (unclear transformation chain):

import { z } from 'zod'

// All transforms in one long chain - hard to understand stages
const priceSchema = z
  .string()
  .transform((s) => parseFloat(s.replace(/[$,]/g, '')))
  .refine((n) => !isNaN(n), 'Invalid number')
  .refine((n) => n >= 0, 'Must be positive')
  .refine((n) => n <= 1000000, 'Too large')
  .transform((n) => Math.round(n * 100))

// What type is n at each stage? Hard to tell

Correct (using pipe for clear stages):

import { z } from 'zod'

// Stage 1: Coerce string to number
const parsePrice = z.string().transform((s) => {
  const cleaned = s.replace(/[$,]/g, '')
  const parsed = parseFloat(cleaned)
  if (isNaN(parsed)) throw new Error('Invalid number')
  return parsed
})

// Stage 2: Validate number constraints
const validPrice = z.number().min(0, 'Must be positive').max(1000000, 'Too large')

// Stage 3: Transform to cents
const centsPrice = z.number().transform((n) => Math.round(n * 100))

// Pipe them together - clear data flow
const priceSchema = parsePrice.pipe(validPrice).pipe(centsPrice)

// Type at each stage is clear:
// string -> number (parsePrice)
// number -> number (validPrice)
// number -> number (centsPrice, but semantically cents)

Coercion with validation:

// Without pipe - validation runs on raw input
const schema1 = z.coerce.number().min(1)
schema1.parse('')  // Passes! Empty string coerces to 0, but then... wait, 0 < 1

// With pipe - validation runs on coerced value
const schema2 = z.coerce.number().pipe(z.number().min(1))
schema2.parse('')  // Fails correctly: 0 is less than 1

Complex data transformation:

// Input: CSV string of emails
// Output: Array of normalized, validated email objects

const emailArraySchema = z
  .string()
  // Stage 1: Split CSV
  .transform((s) => s.split(',').map((e) => e.trim()))
  // Stage 2: Validate as email array
  .pipe(z.array(z.string().email()))
  // Stage 3: Transform to objects
  .pipe(
    z.array(z.string()).transform((emails) =>
      emails.map((email) => ({
        address: email.toLowerCase(),
        domain: email.split('@')[1],
      }))
    )
  )

emailArraySchema.parse('John@Example.com, jane@test.com')
// [
//   { address: 'john@example.com', domain: 'Example.com' },
//   { address: 'jane@test.com', domain: 'test.com' }
// ]

Type inference with pipe:

const schema = z.string().pipe(z.coerce.number()).pipe(z.number().positive())

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

// Each pipe stage has clear input/output types

When NOT to use this pattern:

  • Simple single-stage validation (adds unnecessary complexity)
  • When .refine() chain is sufficient and readable

Reference: Zod API - pipe