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:
…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?
Motivation
Validation sometimes needs to perform I/O, and therefore can’t always be synchronous. Common examples:
If Pema allows arbitrary async validators inside normal schemas, async becomes infectious: every
schema.parse(...)would have to becomeawait 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:
But if async validators were allowed “anywhere”, the caller would be forced into:
…even though the schema itself doesn’t need it.
Proposed solution
Add
p.async(...)to opt into async validationIntroduce a parallel entrypoint:
p(...)returns a Schema with synchronous.parse(...)p.async(...)returns an AsyncSchema with asynchronous.parse(...)(returns aPromise)p.asyncmirrors the API surface ofpas closely as possible, but the resulting schema permits async-only features and async validator functions.This creates a clean, explicit boundary:
Construction rules
Sync schema (
p(...)).reachable(),.exists()if it hits I/O).Examples (sync):
Async schema (
p.async(...))parse(...)returns a Promise.Examples (async):
Security / correctness considerations
p(...), the error should explicitly say: “Async validation requiresp.async(...).”Summary of changes
p.async(...)AsyncSchematype (mirrors Schema, but asyncparse)ErrorAsyncValidation) for misusereachable,exists) only underp.asyncOpen questions
p.string.async.reachable())? (Schema-wide is simpler and avoids accidental async in sync schemas.)p.asyncallow mixing sync and async validators seamlessly (sync validators run normally, async ones awaited), or require validators to be uniformly async for predictability?