Skip to content

Proposal: Support async validation in Pema #252

@terrablue

Description

@terrablue

Motivation

Validation sometimes needs to perform I/O, and therefore can’t always be synchronous. Common examples:

  • Database checks (e.g. uniqueness of a username, existence of a referenced row)
  • Filesystem checks (e.g. “does this file exist?”)
  • Network checks (e.g. “is this host reachable?”)

If Pema allows arbitrary async validators inside normal schemas, async becomes infectious: every schema.parse(...) would have to become await schema.parse(...), even for schemas that are purely synchronous. That’s a big ergonomics regression.

Example: this schema is fully synchronous and should stay synchronous:

import p from "pema";

const Schema = p({
  DB_URL: p.string.url({ protocol: "jdbc" }),
  DB_USERNAME: p.string,
  DB_PASSWORD: p.string,
  CONSOLE_ENABLED: p.boolean.default(false),
  IRC_ENABLED: p.boolean.default(false),
  DISCORD_ENABLED: p.boolean.default(false),
  JWT_SECRET: p.string.jwt(),
  CORS_ORIGINS: p.string,
});

But if async validators were allowed “anywhere”, the caller would be forced into:

await Schema.parse(...)

…even though the schema itself doesn’t need it.

Proposed solution

Add p.async(...) to opt into async validation

Introduce a parallel entrypoint:

  • p(...) returns a Schema with synchronous .parse(...)
  • p.async(...) returns an AsyncSchema with asynchronous .parse(...) (returns a Promise)

p.async mirrors the API surface of p as closely as possible, but the resulting schema permits async-only features and async validator functions.

This creates a clean, explicit boundary:

  • Sync schemas stay sync
  • Async capabilities require an explicit opt-in

Construction rules

Sync schema (p(...))

  • Reject any async function validator/default/refiner.
  • Reject inherently async validators (e.g. .reachable(), .exists() if it hits I/O).
  • Prefer failing at construction time where detectable; otherwise fail at parse-time with a dedicated error.

Examples (sync):

import p from "pema";

p({ created: p.date.default(async () => new Date()) });
// ❌ should fail (ideally during construction, otherwise at parse-time)
// ErrorAsyncValidation (or similar)

p({ host: p.string.reachable() });
// ❌ reachable is inherently async → not available on sync schemas

Async schema (p.async(...))

  • Accept async validators/defaults/refiners.
  • Expose async-only validators.
  • parse(...) returns a Promise.

Examples (async):

import p from "pema";

const S1 = p.async({
  created: p.date.default(async () => new Date()),
}); // ✅ AsyncSchema

const S2 = p.async({
  host: p.string.reachable(),
}); // ✅ AsyncSchema

const S3 = p.async({
  file: p.fileref.exists(), // checks that an rcompat FileRef exists
}); // ✅ AsyncSchema

Security / correctness considerations

  • No silent async in sync mode. Sync parsing must never “accidentally” ignore async validators or treat them as truthy.
  • Clear error messaging. If a user uses an async validator in p(...), the error should explicitly say: “Async validation requires p.async(...).”
  • Deterministic semantics. Async-only validators should be unavailable on sync schemas to prevent confusion and accidental I/O in hot paths.

Summary of changes

Area Change
Core API Add p.async(...)
Types Introduce AsyncSchema type (mirrors Schema, but async parse)
Validation Disallow async functions + async-only validators in sync schemas
Errors Add a dedicated error (e.g. ErrorAsyncValidation) for misuse
Validators Allow inherently async validators (e.g. reachable, exists) only under p.async

Open questions

  • Granularity: should async be schema-wide only, or also allow “local opt-in” (e.g. p.string.async.reachable())? (Schema-wide is simpler and avoids accidental async in sync schemas.)
  • Failure timing: how hard should we try to detect async usage at construction time vs parse-time only?
  • Compatibility: should p.async allow mixing sync and async validators seamlessly (sync validators run normally, async ones awaited), or require validators to be uniformly async for predictability?

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions