Files
gruperly/.agents/skills/zod/references/refine-transform-coerce.md
2026-09-04 16:49:24 -03:00

3.4 KiB

title, impact, impactDescription, tags
title impact impactDescription tags
Distinguish transform() from refine() and coerce() MEDIUM Using wrong method causes validation to pass with wrong data; each method has distinct purpose refine, transform, coerce, conversion

Distinguish transform() from refine() and coerce()

.refine() validates and returns boolean, .transform() converts data to new format, and .coerce converts input before validation. Using the wrong one causes bugs where validation passes but data is wrong.

Purpose of each method:

import { z } from 'zod'

// coerce: Convert type BEFORE validation
// Input: unknown -> Output: validated type
z.coerce.number().parse('42')  // Converts "42" to 42, then validates as number

// refine: Validate with custom logic, return boolean
// Input: T -> Output: T (unchanged, but validated)
z.number().refine((n) => n > 0)  // Validates n > 0, returns n unchanged

// transform: Convert to different type AFTER validation
// Input: T -> Output: U (different type)
z.string().transform((s) => s.length)  // Validates string, returns length

Incorrect (using transform for validation):

// Wrong: transform should convert, not validate
const schema = z.number().transform((n) => {
  if (n < 0) throw new Error('Must be positive')  // Don't throw in transform
  return n
})

Correct (using appropriate method):

import { z } from 'zod'

// VALIDATION: Use refine - returns boolean, data unchanged
const positiveNumber = z.number().refine(
  (n) => n > 0,
  { message: 'Must be positive' }
)

positiveNumber.parse(5)  // 5
positiveNumber.parse(-1)  // ZodError: Must be positive

// CONVERSION: Use transform - returns new value
const stringLength = z.string().transform((s) => s.length)

type StringLength = z.infer<typeof stringLength>  // number
stringLength.parse('hello')  // 5

// COERCION: Use coerce - converts input type
const coercedNumber = z.coerce.number()

coercedNumber.parse('42')  // 42 (from string)
coercedNumber.parse(42)  // 42 (already number)

Combining methods correctly:

// Input: string -> Coerce to number -> Validate positive -> Transform to dollars
const priceSchema = z.coerce
  .number()
  .refine((n) => n >= 0, 'Price cannot be negative')
  .transform((cents) => `$${(cents / 100).toFixed(2)}`)

priceSchema.parse('1999')  // "$19.99"
priceSchema.parse('-100')  // ZodError: Price cannot be negative

Order of operations:

const schema = z
  .preprocess(val => val, z.string())  // 1. preprocess (before type check)
  .transform(s => s.trim())             // 2. transform (after type check)
  .refine(s => s.length > 0)            // 3. refine (custom validation)
  .transform(s => s.toUpperCase())      // 4. another transform

// Input flows: preprocess -> type check -> transforms/refines in order

Use case comparison:

Need Method Example
Convert string to number z.coerce.number() Form input
Validate number is positive .refine(n => n > 0) Business rule
Convert cents to dollars .transform(n => n/100) Display format
Trim whitespace before check z.preprocess Input cleanup

When NOT to use this pattern:

  • Simple type coercion: use z.coerce.*
  • Simple validation: use built-in methods like .min(), .email()

Reference: Zod API - transform