How to Create Custom Refinements in Zod Using refine and superRefine

Use refine for simple true/false checks that return a single error message, and superRefine when you need to report multiple validation issues, customize error paths, or control whether validation continues after a failure.

Zod is a TypeScript-first schema validation library that allows you to extend built-in validations with custom logic. In the colinhacks/zod repository, the refine and superRefine methods provide two distinct approaches for creating custom refinements that execute after standard type checks pass. Understanding the architectural differences between these APIs helps you choose the right tool for simple boolean validations versus complex multi-issue reporting.

refine vs superRefine: Key Differences

Both methods attach custom validation logic to a schema, but they differ in flexibility and control:

  • refine creates a single check that returns a boolean (or Promise). When the check fails, Zod generates one issue with a custom message or the default "custom" error code. This is ideal for "pass/fail" validations.

  • superRefine provides a refinement context (ctx) that allows you to push multiple issues, customize error paths, and decide whether validation should continue after a failure. Use this when you need granular error reporting or complex validation logic that produces several distinct error messages.

Core Implementation Architecture

The heavy lifting for both methods happens in packages/zod/src/v4/core/api.ts, while public wrappers exist for both the Mini and Classic API flavors.

Internal Core Functions

The _refine function (lines 16-24) creates a ZodCheck that runs a user-provided predicate:

export function _refine<O = unknown, I = O>(
  Class: util.SchemaClass<schemas.$ZodCustom>,
  fn: (data: O) => unknown,
  _params: string | $ZodCustomParams | undefined
): schemas.$ZodCustom<O, I> {
  const schema = new Class({
    type: "custom",
    check: "custom",
    fn: fn as any,
    ...util.normalizeParams(_params),
  });
  return schema as any;
}

The _superRefine function (lines 36-56) builds a check that supplies a refinement context:

export function _superRefine<T>(fn: (arg: T, payload: $RefinementCtx<T>) => void | Promise<void>) {
  const ch = _check<T>((payload) => {
    (payload as $RefinementCtx).addIssue = (issue) => {
      if (typeof issue === "string") {
        payload.issues.push(util.issue(issue, payload.value, ch._zod.def));
      } else {
        payload.issues.push(util.issue(_issue));
      }
    };
    return fn(payload.value, payload as $RefinementCtx<T>);
  });
  return ch;
}

Public API Wrappers

Both Mini and Classic builds expose refine and superRefine as chainable methods:

  • Mini version (packages/zod/src/v4/mini/schemas.ts, lines 61-75):

    export function refine<T>(fn, _params = {}) {
      return core._refine(ZodMiniCustom, fn, _params);
    }
    export function superRefine<T>(fn) {
      return core._superRefine(fn);
    }
  • Classic version (packages/zod/src/v4/classic/schemas.ts, lines 15-27):

    export function refine<T>(fn, _params = {}) {
      return core._refine(ZodCustom, fn, _params);
    }
    export function superRefine<T>(fn) {
      return core._superRefine(fn);
    }

Practical Code Examples

Simple Synchronous refine

Use refine for boolean checks that need a single error message:

import { z } from "zod";

const passwordSchema = z
  .string()
  .min(8)
  .refine((val) => /[A-Z]/.test(val), { 
    message: "Must contain an uppercase letter" 
  });

passwordSchema.parse("short");         // ❌ throws "String must contain at least 8 characters"
passwordSchema.parse("lowercase123");  // ❌ throws "Must contain an uppercase letter"
passwordSchema.parse("ValidPass123");  // ✅ passes

Asynchronous refine

refine automatically awaits promises returned by the predicate:

import { z } from "zod";

const usernameSchema = z
  .string()
  .refine(
    async (val) => {
      const taken = await db.users.findUnique({ where: { username: val } });
      return !taken;
    },
    { message: "Username already taken" }
  );

await usernameSchema.parseAsync("alice"); // ✅ if not taken
await usernameSchema.parseAsync("bob");   // ❌ "Username already taken"

Custom Error Paths with refine

Target specific fields in object schemas:

import { z } from "zod";

const pointSchema = z.object({
  x: z.number(),
  y: z.number(),
}).refine(
  (pt) => pt.x >= 0 && pt.y >= 0,
  {
    message: "Coordinates must be non‑negative",
    path: ["x", "y"],
  }
);

Multiple Issues with superRefine

Use superRefine when you need to report several distinct errors:

import { z } from "zod";

const registrationSchema = z.object({
  password: z.string().min(8),
  confirm: z.string(),
}).superRefine((data, ctx) => {
  if (data.password !== data.confirm) {
    ctx.addIssue({
      code: "custom",
      message: "Passwords do not match",
      path: ["confirm"],
    });
  }
  if (/\s/.test(data.password)) {
    ctx.addIssue({
      code: "custom",
      message: "Password cannot contain spaces",
      continue: false, // abort further validation
    });
  }
});

Chaining Both Methods

Combine refine and superRefine for layered validation:

import { z } from "zod";

const userSchema = z.object({
  email: z.string().email(),
  age: z.number(),
})
  .refine((obj) => obj.age >= 18, { message: "Must be adult" })
  .superRefine((obj, ctx) => {
    if (obj.email.endsWith("@example.com")) {
      ctx.addIssue({
        code: "custom",
        message: "Corporate emails not allowed",
        path: ["email"],
      });
    }
  });

Abort and Continue Behavior

Understanding when validation stops is crucial for error collection:

  • refine: By default, abort: true stops validation after the first failing refinement. You can override this by passing { abort: false } in the params.

  • superRefine: Each issue added via ctx.addIssue can set continue: true to keep validating, or continue: false to abort. The default behavior is continue = !schema._zod.def.abort, meaning it respects the schema's abort setting unless explicitly overridden.

This logic is implemented in packages/zod/src/v4/core/api.ts around lines 49-50 and in the utility functions within packages/zod/src/v4/core/util.ts.

Summary

  • refine is the lightweight choice for single boolean checks that need one error message; it wraps your predicate in a ZodCheck via _refine in packages/zod/src/v4/core/api.ts.
  • superRefine provides full control via a refinement context (ctx) that supports addIssue, custom paths, and continue/abort logic; implemented in _superRefine in the same core file.
  • Both methods are exposed through public wrappers in packages/zod/src/v4/mini/schemas.ts (Mini API) and packages/zod/src/v4/classic/schemas.ts (Classic API).
  • Use refine for simple validations like password strength or uniqueness checks, and superRefine when you need to report multiple distinct errors or manipulate error paths dynamically.

Frequently Asked Questions

What is the difference between refine and superRefine in Zod?

refine is designed for simple validations that return a boolean or Promise, producing a single error message when the check fails. superRefine provides a context object (ctx) that allows you to add multiple issues with custom paths, error codes, and control over whether validation continues after each error. Choose refine for "pass/fail" logic and superRefine when you need granular error reporting.

Can I use async functions with Zod refinements?

Yes, both refine and superRefine support asynchronous predicates. When you return a Promise from your validation function, Zod automatically awaits it during parsing. Use parseAsync or safeParseAsync on your schema to handle the asynchronous validation properly, particularly when checking database uniqueness or external API availability.

How do I add multiple error messages to a single field?

Use superRefine with the context's addIssue method to push multiple validation errors for the same or different fields. Unlike refine, which stops at the first failure (by default), superRefine lets you collect all validation issues by setting continue: true on each issue, giving users a complete list of what needs correction rather than one error at a time.

Where are the refine and superRefine methods implemented in the Zod source code?

The core logic resides in packages/zod/src/v4/core/api.ts, where the internal _refine and _superRefine functions create ZodCheck instances. The public-facing methods you import from zod are thin wrappers located in packages/zod/src/v4/mini/schemas.ts (for the Mini build) and packages/zod/src/v4/classic/schemas.ts (for the Classic build), which delegate to the core implementations while attaching the appropriate schema class.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →