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

114 lines
3.3 KiB
Markdown

---
title: Use pipe() for Multi-Stage Validation
impact: MEDIUM
impactDescription: Chaining transforms loses intermediate type info; pipe() explicitly shows data flow through validation stages
tags: 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):**
```typescript
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):**
```typescript
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:**
```typescript
// 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:**
```typescript
// 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:**
```typescript
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](https://zod.dev/api#pipe)