Skip to content

Synchronous and Asynchronous Execution

Valchecker does not turn an entire schema into an unconditional promise merely because one callback can be asynchronous. Execution stays synchronous until reached work actually returns a promise-like value, unless the schema is explicitly converted with toAsync().

AsyncExecutionstartexecute(input)syncrun synchronous stepsstart->syncforcedtoAsync()start->forcedalwayscallbackasync-capable step reached?sync->callbackdirectdirect ExecutionResultcallback->directno / direct resultpromisePromise chaincallback->promisePromiseLike returnedforced->promisealways
AsyncExecutionstartexecute(input)syncrun synchronous stepsstart->syncforcedtoAsync()start->forcedalwayscallbackasync-capable step reached?sync->callbackdirectdirect ExecutionResultcallback->directno / direct resultpromisePromise chaincallback->promisePromiseLike returnedforced->promisealways

Three useful execution contracts

Synchronous

A fully synchronous pipeline returns ExecutionResult directly.

Maybe-async

A callback-driven step such as check() or transform() can return either directly or through a promise-like value. If execution fails before that callback is reached, the call may still return synchronously.

Always async

toAsync() adds an async runtime step and changes the type state to async. Public execute() then normalizes every invocation to a native promise, including an input that would otherwise fail before any callback is reached.

The reached path decides maybe-async completion

The same schema can therefore have two observable completion shapes for different inputs:

ts
import { v } from 'valchecker'

export const maybeAsyncSchema = v.string()
	.check(async value => value.length > 0)

export const reachedCallbackResult = maybeAsyncSchema.execute('value')
export const earlyFailureResult = maybeAsyncSchema.execute(42)

export const alwaysAsyncSchema = maybeAsyncSchema.toAsync()
export const alwaysAsyncEarlyFailure = alwaysAsyncSchema.execute(42)

The checked fixture proves all three cases:

  • reachedCallbackResult is a Promise because execution reaches the async callback;
  • earlyFailureResult is a direct failure because string() rejects before that callback runs;
  • alwaysAsyncEarlyFailure is still a native Promise because the schema has been converted with toAsync().

The type state tracks sync, maybe-async, or async so execute() exposes the corresponding return type. The runtime also keeps an execution-mode summary, but callback steps are conservatively registered as maybe-async because callback asynchrony is not knowable from the function object at schema-construction time.

That distinction is intentional: the type system may infer a narrower callback result in some cases while the runtime still chooses the safe executor path.

Await when either completion shape is acceptable

JavaScript await accepts both plain values and promises, so this is the simplest application boundary when direct versus asynchronous completion does not matter:

ts
import { v } from 'valchecker'

const input: unknown = 'Alice'

const schema = v.string()
	.check(async value => value.length > 0)

const result = await schema.execute(input)

Use toAsync() when the API contract itself requires Promise<ExecutionResult<...>> on every call, not merely because await is convenient.

Exact callback return forms and issue behavior stay in the generated check() and transform() reference entries.