Skip to main content

Overview

Refinements allow you to add custom validation logic beyond Zod’s built-in validators. While transformations modify data, refinements validate data without changing it.

Basic Refinement with .refine()

Use .refine() to add custom validation:
import { z } from 'zod';

const schema = z.string().refine(
  (val) => val.length <= 10,
  'String must be 10 characters or less'
);

schema.parse('hello');     // OK
schema.parse('hello world'); // Error: String must be 10 characters or less
From packages/zod/src/v4/classic/schemas.ts:92-95, the .refine() signature:
refine<Ch extends (arg: Output) => unknown | Promise<unknown>>(
  check: Ch,
  params?: string | $ZodCustomParams
): Ch extends (arg: any) => arg is infer R ? this & ZodType<R, Input> : this

Custom Error Messages

Provide custom error messages:
// String message
const schema1 = z.string().refine(
  (val) => val.includes('@'),
  'Must contain @ symbol'
);

// Object with message
const schema2 = z.string().refine(
  (val) => val.includes('@'),
  { message: 'Must contain @ symbol' }
);

// Dynamic message
const schema3 = z.number().refine(
  (val) => val >= 0,
  (val) => ({ message: `Expected non-negative, got ${val}` })
);
From packages/zod/src/v4/classic/tests/refine.test.ts:54-75:
const validationSchema = z.object({
  email: z.string().email(),
  password: z.string(),
  confirmPassword: z.string(),
}).refine(
  (data) => data.password === data.confirmPassword,
  'Both password and confirmation must match'
);

const result = validationSchema.safeParse({
  email: '[email protected]',
  password: 'aaaaaaaa',
  confirmPassword: 'bbbbbbbb',
});

expect(result.success).toEqual(false);
if (!result.success) {
  expect(result.error.issues[0].message).toEqual(
    'Both password and confirmation must match'
  );
}

Refine on Objects

Refinements are powerful for cross-field validation:
const PasswordSchema = z.object({
  password: z.string().min(8),
  confirmPassword: z.string(),
}).refine(
  (data) => data.password === data.confirmPassword,
  {
    message: 'Passwords do not match',
    path: ['confirmPassword'], // Error will point to this field
  }
);
From packages/zod/src/v4/classic/tests/refine.test.ts:169-181:
const result = z.object({ password: z.string(), confirm: z.string() })
  .refine(
    (data) => data.confirm === data.password,
    { path: ['confirm'] }
  )
  .safeParse({ password: 'asdf', confirm: 'qewr' });

expect(result.success).toEqual(false);
if (!result.success) {
  expect(result.error.issues[0].path).toEqual(['confirm']);
}

Error Path

The path option specifies where the error should appear:
const schema = z.object({
  start: z.date(),
  end: z.date(),
}).refine(
  (data) => data.end > data.start,
  {
    message: 'End date must be after start date',
    path: ['end'], // Error appears on 'end' field
  }
);

Async Refinements

Refinements can be asynchronous for database checks, API calls, etc:
const schema = z.string().refine(
  async (email) => {
    // Check if email exists in database
    const exists = await checkEmailExists(email);
    return !exists;
  },
  { message: 'Email already exists' }
);

// Must use parseAsync for async refinements
const result = await schema.parseAsync('[email protected]');
From packages/zod/src/v4/classic/tests/refine.test.ts:77-108:
const validationSchema = z.object({
  email: z.string().email(),
  password: z.string(),
  confirmPassword: z.string(),
}).refine(
  (data) => Promise.resolve().then(() => data.password === data.confirmPassword),
  'Both password and confirmation must match'
);

// Should pass with matching passwords
const validData = {
  email: '[email protected]',
  password: 'password',
  confirmPassword: 'password',
};

await expect(validationSchema.parseAsync(validData)).resolves.toEqual(validData);

// Should fail with non-matching passwords
await expect(
  validationSchema.parseAsync({
    email: '[email protected]',
    password: 'password',
    confirmPassword: 'different',
  })
).rejects.toThrow();
Async refinements require parseAsync() or safeParseAsync(). Using parse() will throw an error.

superRefine() for Advanced Validation

For complex validation with multiple errors, use .superRefine():
const schema = z.array(z.string()).superRefine((val, ctx) => {
  // Check length
  if (val.length > 3) {
    ctx.addIssue({
      code: z.ZodIssueCode.too_big,
      maximum: 3,
      inclusive: true,
      type: 'array',
      message: 'Too many items',
    });
  }
  
  // Check for duplicates
  if (val.length !== new Set(val).size) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: 'Array contains duplicates',
    });
  }
});
From packages/zod/src/v4/classic/schemas.ts:96-98, the .superRefine() signature:
superRefine(
  refinement: (arg: Output, ctx: $RefinementCtx) => void | Promise<void>
): this
From packages/zod/src/v4/classic/tests/refine.test.ts:183-215:
const Strings = z.array(z.string()).superRefine((val, ctx) => {
  if (val.length > 3) {
    ctx.addIssue({
      input: val,
      code: 'too_big',
      origin: 'array',
      maximum: 3,
      inclusive: true,
      exact: true,
      message: 'Too many items 😡',
    });
  }

  if (val.length !== new Set(val).size) {
    ctx.addIssue({
      input: val,
      code: 'custom',
      message: `No duplicates allowed.`,
    });
  }
});

const result = Strings.safeParse(['asdf', 'asdf', 'asdf', 'asdf']);
expect(result.success).toBe(false);
if (!result.success) {
  expect(result.error.issues.length).toBe(2);
  expect(result.error.issues[0].message).toBe('Too many items 😡');
  expect(result.error.issues[1].message).toBe('No duplicates allowed.');
}

Adding Multiple Issues

.superRefine() allows reporting multiple validation errors:
const PasswordStrength = z.string().superRefine((val, ctx) => {
  if (val.length < 8) {
    ctx.addIssue({
      code: z.ZodIssueCode.too_small,
      minimum: 8,
      type: 'string',
      inclusive: true,
      message: 'Password must be at least 8 characters',
    });
  }
  
  if (!/[A-Z]/.test(val)) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: 'Password must contain uppercase letter',
    });
  }
  
  if (!/[0-9]/.test(val)) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: 'Password must contain a number',
    });
  }
});

Early Termination

Control validation flow with early termination options:

Using fatal: true

const schema = z.string().superRefine((val, ctx) => {
  if (val.length < 2) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      fatal: true,  // Stop validation immediately
      message: 'Too short',
    });
  }
}).refine(() => false); // This won't run if fatal issue occurs
From packages/zod/src/v4/classic/tests/refine.test.ts:133-154:
const schema = z.string()
  .superRefine((val, ctx) => {
    if (val.length < 2) {
      ctx.addIssue({
        code: 'custom',
        fatal: true,
        message: 'BAD',
      });
    }
  })
  .refine(() => false);

const result = schema.safeParse('');
expect(result.success).toEqual(false);
if (!result.success) {
  expect(result.error.issues.length).toEqual(1);
  expect(result.error.issues[0].message).toEqual('BAD');
}

Using continue: false

const schema = z.string().superRefine((val, ctx) => {
  if (val.length < 2) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      continue: false, // Stop this refinement, but continue others
      message: 'BAD',
    });
  }
});

Using abort in .refine()

const schema = z.string()
  .refine(() => false, { abort: true }) // Abort on failure
  .refine(() => false);                   // This won't run
From packages/zod/src/v4/classic/tests/refine.test.ts:155-167:
const schema = z.string()
  .refine(() => false, { abort: true })
  .refine(() => false);

const result = schema.safeParse('');
expect(result.success).toEqual(false);
if (!result.success) {
  expect(result.error.issues.length).toEqual(1);
}

Type Narrowing with Refinements

Refinements can narrow TypeScript types using type predicates:
const isValidEmail = (val: string): val is `${string}@${string}` => {
  return val.includes('@');
};

const schema = z.string().refine(isValidEmail);

type Input = z.input<typeof schema>;   // string
type Output = z.output<typeof schema>; // `${string}@${string}`
From packages/zod/src/v4/classic/tests/refine.test.ts:422-432:
// Type predicate narrows the type
const schema = z.string().refine((val): val is 'a' => val === 'a');

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

// Without type predicate, type doesn't narrow
const schema2 = z.string().refine((val) => val === 'a');

type Input2 = z.input<typeof schema2>;   // string
type Output2 = z.output<typeof schema2>; // string (not narrowed)

Practical Examples

Email Uniqueness Check

const UniqueEmail = z.string()
  .email()
  .refine(
    async (email) => {
      const user = await db.users.findOne({ email });
      return !user;
    },
    { message: 'Email already exists' }
  );

Password Confirmation

const RegistrationSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
  confirmPassword: z.string(),
}).refine(
  (data) => data.password === data.confirmPassword,
  {
    message: 'Passwords do not match',
    path: ['confirmPassword'],
  }
);

Date Range Validation

const EventSchema = z.object({
  title: z.string(),
  startDate: z.date(),
  endDate: z.date(),
}).refine(
  (data) => data.endDate > data.startDate,
  {
    message: 'End date must be after start date',
    path: ['endDate'],
  }
);

Complex Business Logic

const OrderSchema = z.object({
  items: z.array(z.object({
    id: z.string(),
    quantity: z.number().positive(),
    price: z.number().positive(),
  })),
  discount: z.number().min(0).max(1),
  shippingCost: z.number().nonnegative(),
}).superRefine((data, ctx) => {
  const subtotal = data.items.reduce(
    (sum, item) => sum + item.quantity * item.price,
    0
  );
  
  const total = subtotal * (1 - data.discount) + data.shippingCost;
  
  // Minimum order value
  if (total < 10) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: 'Order total must be at least $10',
      path: ['items'],
    });
  }
  
  // Free shipping threshold
  if (subtotal >= 100 && data.shippingCost > 0) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: 'Orders over $100 qualify for free shipping',
      path: ['shippingCost'],
    });
  }
});

Credit Card Validation

const CreditCardSchema = z.string()
  .regex(/^\d{16}$/, 'Must be 16 digits')
  .refine(
    (num) => {
      // Luhn algorithm
      let sum = 0;
      let isEven = false;
      
      for (let i = num.length - 1; i >= 0; i--) {
        let digit = parseInt(num[i], 10);
        
        if (isEven) {
          digit *= 2;
          if (digit > 9) {
            digit -= 9;
          }
        }
        
        sum += digit;
        isEven = !isEven;
      }
      
      return sum % 10 === 0;
    },
    { message: 'Invalid credit card number' }
  );

Refinements vs Transformations

Key Difference:
  • Refinements validate data without changing it
  • Transformations modify data after validation
// Refinement: validates, doesn't change value
const refined = z.string().refine((val) => val.length > 5);
type RefinedOutput = z.output<typeof refined>; // string
const result1 = refined.parse('hello world'); // 'hello world'

// Transformation: changes the value
const transformed = z.string().transform((val) => val.length);
type TransformedOutput = z.output<typeof transformed>; // number
const result2 = transformed.parse('hello world'); // 11

Chaining Refinements

Multiple refinements can be chained:
const schema = z.number()
  .refine((n) => n >= 0, 'Must be non-negative')
  .refine((n) => n <= 100, 'Must be at most 100')
  .refine((n) => n % 5 === 0, 'Must be multiple of 5');

Performance Considerations

Async refinements can be slow. Consider caching or batching database lookups for better performance.
// Slow: Database call for each value
const slow = z.string().refine(async (val) => {
  return await db.check(val);
});

// Better: Batch validation
const better = z.array(z.string()).refine(async (values) => {
  const results = await db.batchCheck(values);
  return results.every((r) => r.valid);
});

Best Practices

  1. Use built-in validators first - They’re optimized and well-tested
  2. Keep refinements focused - One validation per refinement
  3. Use .superRefine() for multiple errors - Better UX than stopping at first error
  4. Set appropriate error paths - Help users fix the right field
  5. Consider async performance - Cache or batch when possible
  6. Use type predicates for narrowing - Get better TypeScript types
// Good: Focused, clear refinements
const schema = z.object({
  email: z.string().email(),
  age: z.number().positive(),
}).refine(
  (data) => data.age >= 18,
  { message: 'Must be 18 or older', path: ['age'] }
);

// Bad: Everything in one refinement
const bad = z.object({
  email: z.string(),
  age: z.number(),
}).refine((data) => {
  return data.email.includes('@') && data.age >= 18;
}, 'Invalid data'); // Unclear what's wrong

Common Patterns

Conditional Validation

const schema = z.object({
  country: z.string(),
  zipCode: z.string(),
}).refine(
  (data) => {
    if (data.country === 'US') {
      return /^\d{5}(-\d{4})?$/.test(data.zipCode);
    }
    return true; // Skip validation for other countries
  },
  {
    message: 'Invalid US zip code',
    path: ['zipCode'],
  }
);

Dependent Fields

const schema = z.object({
  hasAccount: z.boolean(),
  accountId: z.string().optional(),
}).refine(
  (data) => {
    if (data.hasAccount) {
      return data.accountId !== undefined;
    }
    return true;
  },
  {
    message: 'Account ID required when "has account" is checked',
    path: ['accountId'],
  }
);

Next Steps

Build docs developers (and LLMs) love