| | |

Zod 17 🛡️ Async Validation — .parseAsync() and async refinements

⏱️ Estimated read: 9 min | |

Zod’s synchronous parsing methods assume validation completes immediately. An async refinement—a database uniqueness check, an external API call, a file read—breaks that assumption. The schema returns a Promise instead of a value, and .parse() throws an error the moment it encounters one. Async validation is the mechanism that lets a schema await those checks, and .parseAsync() is the method that makes it work.

This chapter covers the async parsing methods, the async refinement and transform patterns that require them, and the error-handling strategies that keep async validation resilient. The sections that follow walk through the method selection, the async check patterns, and the timeout and concurrency considerations that apply when validation waits on external systems.

Key point: Any schema containing an async refinement or async transform must be parsed with .parseAsync() or .safeParseAsync(); synchronous parsing throws an Async refinement encountered during synchronous parse operation error, and the async methods return Promises that must be awaited.


a. The async parsing methods

Zod provides two async parsing methods that mirror the synchronous pair. .parseAsync() returns a Promise of the typed output and rejects with a ZodError on failure. .safeParseAsync() returns a Promise of the discriminated union and never rejects for validation failures .

import { z } from "zod";

const UserSchema = z.object({
  email: z.email(),
}).refine(async (data) => {
  const exists = await db.users.findByEmail(data.email);
  return !exists;
}, "Email already registered");

// This throws: Async refinement encountered during synchronous parse
// UserSchema.parse({ email: "a@b.com" });

// Correct: await the async parse
const user = await UserSchema.parseAsync({ email: "a@b.com" });

// Safe variant: returns a union, never rejects for validation
const result = await UserSchema.safeParseAsync({ email: "a@b.com" });
if (!result.success) {
  console.log(result.error.issues);
}

The synchronous .parse() does not silently skip the async check; it throws immediately with a message naming the method to use instead . The error is a $ZodAsyncError, and its purpose is to prevent a schema with an async refinement from being used in a synchronous context where the check would never complete.

The async transform has the same requirement. A .transform(async (data) => { ... }) that awaits a database lookup must be parsed with .parseAsync(). The transformation pipeline includes a step that returns a Promise, and the synchronous parser cannot await it .


b. Async refinements and their error patterns

An async refinement is a .refine() or .superRefine() whose function returns a Promise. The common case is a uniqueness check: the refinement queries a database and returns false if the value already exists.

const RegisterSchema = z.object({
  email: z.email(),
  username: z.string().min(3),
}).superRefine(async (data, ctx) => {
  const emailExists = await db.users.findByEmail(data.email);
  if (emailExists) {
    ctx.addIssue({
      code: "custom",
      path: ["email"],
      message: "Email already registered",
    });
  }

  const usernameExists = await db.users.findByUsername(data.username);
  if (usernameExists) {
    ctx.addIssue({
      code: "custom",
      path: ["username"],
      message: "Username already taken",
    });
  }
});

The .superRefine() form is preferred for async checks that may produce multiple issues, because it receives the context and can add issues at different paths. A .refine() returns a single boolean and produces a single issue; it is sufficient when the check is a simple predicate with one failure message .

The async refinement should not throw for expected validation failures. A database query that returns “not found” is not an exception; it is a false result. A thrown error inside an async refinement surfaces as an unhandled rejection in some Zod versions, which is why the recommended pattern is to catch errors inside the refinement and return false or add an issue explicitly .

.refine(async (value) => {
  try {
    const result = await externalApiCall(value);
    return result.valid;
  } catch {
    return false; // treat errors as validation failure
  }
}, "Validation failed");

c. Timeouts, concurrency, and the API boundary

Async validation introduces failure modes that synchronous validation does not have: the external system can be slow, unavailable, or rate-limited. A refinement that awaits a database query without a timeout can hang the request indefinitely.

The timeout pattern wraps the external call in a Promise race against a timer. The refinement returns false when the timeout fires, which surfaces as a validation error rather than a hung request .

async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
  const timeout = new Promise<never>((_, reject) =>
    setTimeout(() => reject(new Error("Timeout")), ms)
  );
  return Promise.race([promise, timeout]);
}

const Schema = z.string().refine(async (value) => {
  try {
    await withTimeout(validateWithApi(value), 3000);
    return true;
  } catch {
    return false;
  }
}, "Validation timeout or failed");

The concurrency pattern applies when multiple async checks are independent. A Promise.all() inside a single .superRefine() runs the checks in parallel rather than sequentially, reducing the total latency to the slowest check rather than the sum of all checks .

.superRefine(async (data, ctx) => {
  const [emailExists, usernameExists] = await Promise.all([
    db.users.findByEmail(data.email),
    db.users.findByUsername(data.username),
  ]);
  if (emailExists) ctx.addIssue({ code: "custom", path: ["email"], message: "Taken" });
  if (usernameExists) ctx.addIssue({ code: "custom", path: ["username"], message: "Taken" });
});

At the API boundary, the route handler must await the async parse and return a 400 when the result is a failure. The pattern is to use .safeParseAsync() and check the success discriminant, because it never rejects for validation failures and the handler can return the issues as JSON .

export async function POST(req: NextRequest) {
  const body = await req.json();
  const result = await RegisterSchema.safeParseAsync(body);
  if (!result.success) {
    return NextResponse.json({ errors: result.error.issues }, { status: 400 });
  }
  // proceed
}

Complete Example Session

// ============================================
// PART 1: THE SCHEMA WITH ASYNC REFINEMENT
// ============================================
import { z } from "zod";

const UserSchema = z.object({
  email: z.email(),
  username: z.string().min(3),
}).superRefine(async (data, ctx) => {
  const [emailExists, usernameExists] = await Promise.all([
    db.users.findByEmail(data.email),
    db.users.findByUsername(data.username),
  ]);
  if (emailExists) {
    ctx.addIssue({ code: "custom", path: ["email"], message: "Email taken" });
  }
  if (usernameExists) {
    ctx.addIssue({ code: "custom", path: ["username"], message: "Username taken" });
  }
});

// ============================================
// PART 2: THE SYNC PARSE FAILS
// ============================================
// UserSchema.parse({ email: "a@b.com", username: "ana" });
// throws: Async refinement encountered during synchronous parse

// ============================================
// PART 3: THE ASYNC PARSE
// ============================================
const user = await UserSchema.parseAsync({
  email: "a@b.com",
  username: "ana",
});
// throws ZodError if either check fails

// ============================================
// PART 4: THE SAFE ASYNC PARSE
// ============================================
const result = await UserSchema.safeParseAsync({
  email: "a@b.com",
  username: "ana",
});
if (!result.success) {
  console.log(result.error.issues); // array of issues
}

// ============================================
// PART 5: THE API ROUTE
// ============================================
// async function POST(req) {
//   const body = await req.json();
//   const result = await UserSchema.safeParseAsync(body);
//   if (!result.success) return json(result.error.issues, 400);
//   // proceed
// }

The parts covered the async refinement, the synchronous failure, the awaited parse, the safe async parse, and the API route pattern.


Quick Reference

Parsing Methods

MethodReturnRejects on FailureAsync Schema
.parse()Typed outputYes (ZodError)No
.safeParse()UnionNoNo
.parseAsync()PromiseYes (ZodError)Yes
.safeParseAsync()Promise of unionNoYes

Async Triggers

Schema FeatureRequires Async Parse
.refine(async fn)Yes
.superRefine(async fn)Yes
.transform(async fn)Yes

Error Handling Patterns

PatternUse
try/catch in refinementReturn false on external error
Promise.race with timerTimeout long-running checks
Promise.all in superRefineParallel independent checks

Best Practices

✅ Do This:

// Await the async parse
const user = await UserSchema.parseAsync(data);               // ✅

// Use safeParseAsync at the API boundary
const result = await Schema.safeParseAsync(body);             // ✅

// Catch errors inside async refinements
try { ... } catch { return false; }                           // ✅

// Parallelize independent checks
await Promise.all([checkEmail(), checkUsername()]);           // ✅

❌ Don’t Do This:

// Sync parse with async schema
Schema.parse(data); // throws                                  // ❌

// Throw from async refinement for expected failure
if (bad) throw new Error();                                   // ❌

// Await checks sequentially when independent
await checkEmail(); await checkUsername();                    // ❌ (use Promise.all)

// Forget the timeout on external calls
await externalApi(value); // may hang                          // ❌

Common Pitfalls

PitfallWhy It HappensFix
“Async refinement encountered”Sync parse usedUse .parseAsync()
Unhandled rejectionThrew inside async refinementCatch and return false
Request hangsNo timeout on external callUse Promise.race
Slow validationSequential independent checksUse Promise.all
result.data inaccessibleAccessed outside success checkNarrow with if (result.success)

Real-World Examples

1. Email uniqueness check

.refine(async (email) => !(await db.users.findByEmail(email)), "Taken");

2. Multiple checks in parallel

await Promise.all([checkEmail(), checkUsername()]);

3. Timeout wrapper

await Promise.race([apiCall(), timeout(3000)]);

4. Safe async parse in route

const result = await Schema.safeParseAsync(body);

5. Async transform

.transform(async (id) => await fetchUser(id));

6. Catch inside refinement

try { return await api(value); } catch { return false; }

7. Error with path

ctx.addIssue({ code: "custom", path: ["email"], message: "Taken" });

8. Await in handler

const user = await UserSchema.parseAsync(data);

9. Result union

if (!result.success) return json(result.error.issues, 400);

10. Async superRefine

.superRefine(async (data, ctx) => { ... });

Visual

Sync vs Async Parse

┌─────────────────────────────────────────────────────────┐
│  .parse(data)                                           │
│  ┌──────────────────────────────────────────┐           │
│  │ Sync schema → typed value                 │           │
│  │ Async schema → throws $ZodAsyncError      │           │
│  └──────────────────────────────────────────┘           │
│                                                         │
│  .parseAsync(data)                                      │
│  ┌──────────────────────────────────────────┐           │
│  │ Any schema → Promise                     │           │
│  │ Await → typed value or ZodError          │           │
│  └──────────────────────────────────────────┘           │
└─────────────────────────────────────────────────────────┘

Async Refinement Flow

┌─────────────────────────────────────────────────────────┐
│  base schema passes                                     │
│         │                                               │
│         ▼                                               │
│  async refinement runs                                  │
│         │                                               │
│         ▼                                               │
│  await db query                                         │
│         │                                               │
│    ┌────┴────┐                                          │
│    ▼         ▼                                          │
│  true       false                                       │
│    │         │                                          │
│    ▼         ▼                                          │
│  pass      addIssue                                     │
│            or ZodError                                  │
└─────────────────────────────────────────────────────────┘

Parallel Checks

┌─────────────────────────────────────────────────────────┐
│  SEQUENTIAL                    PARALLEL                 │
│                                                         │
│  checkEmail()  ──► 2ms         checkEmail()   ──┐       │
│  checkUsername() ──► 3ms       checkUsername() ──┤       │
│  total: 5ms                    Promise.all     ──┘       │
│                                total: 3ms               │
└─────────────────────────────────────────────────────────┘

Timeout Pattern

┌─────────────────────────────────────────────────────────┐
│  Promise.race([                                         │
│    externalApiCall(value),                              │
│    timeout(3000)                                        │
│  ])                                                     │
│         │                                               │
│    ┌────┴────┐                                          │
│    ▼         ▼                                          │
│  API       Timeout                                      │
│  returns   fires                                        │
│    │         │                                          │
│    ▼         ▼                                          │
│  validate  return false                                 │
│            (validation error)                           │
└─────────────────────────────────────────────────────────┘

Summary

ItemValue
Async parse.parseAsync() / .safeParseAsync()
Sync parse on async schemaThrows $ZodAsyncError
Async triggersrefine(async), superRefine(async), transform(async)
Error handlingCatch inside refinement, return false
TimeoutPromise.race with timer
ConcurrencyPromise.all for independent checks
API boundaryawait safeParseAsync, return 400 on failure

Key takeaways:

  • Async schemas require async parsing. .parse() throws the moment it encounters an async refinement or transform. The schema must be parsed with .parseAsync() or .safeParseAsync() .
  • Async refinements should catch their own errors. A thrown error inside an async check can become an unhandled rejection. Catch the error, return false, and let Zod produce the validation issue .
  • Timeouts prevent hung requests. An external check without a timeout can block the request indefinitely. Promise.race against a timer converts the hang into a validation failure.
  • Parallelize independent checks. Promise.all inside .superRefine() runs the checks concurrently, reducing latency to the slowest check rather than the sum of all checks .
  • The API boundary uses .safeParseAsync(). It never rejects for validation failures, so the handler can return the issues as a 400 response without a try/catch .

Remember: Async validation is validation that waits. The schema awaits a database, an API, or a file, and the parser must await the schema. Use .parseAsync() when the result matters, .safeParseAsync() when the caller handles failure, catch errors inside the refinement so they become validation issues rather than crashes, and bound the wait with a timeout. The synchronous methods exist for schemas that complete immediately; the async methods exist for everything else.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!