API.md

@lazarv/react-server/function

Server-function validation: createFunction wraps a "use server" handler with a per-arg parse/validate spec, plus the wire-aware helpers (formData, file, blob, arrayBuffer, typedArray, map, set, stream, asyncIterable, iterable, promise) for the rest of the Flight protocol surface.

function arrayBuffer(options?: { maxBytes?: number; }): ArrayBufferSpec;

Declare an ArrayBuffer argument with an optional byte-length cap. The decoder rejects oversize payloads with DecodeValidationError(reason: "max_bytes_exceeded") before the handler observes the buffer.

function asyncIterable<const V extends ValidateSchema<any> | undefined = undefined>(options?: { maxYields?: number; value?: V; }): AsyncIterableSpec<V>;

Declare an AsyncIterable<T> argument. maxYields caps total values; value runs each yielded item through a Standard Schema as the handler pulls. Bound exceeded → throws inside the iteration; schema mismatch → throws inside the iteration.

function blob<const M extends readonly string[] = readonly string[], const O extends boolean = false>(options?: { maxBytes?: number; mime?: M; validate?: (value: File | Blob) => boolean; optional?: O; }): BlobSpec<M, O>;

Declare a Blob entry constraint. Same wire-shape semantics as file({...}). When mime is supplied, the inferred handler-side value is TypedBlob<"…">.

function createFunction<const TArgs extends ReadonlyArray<unknown>>(validate: TArgs): <Fn extends (...args: InferArgs<TArgs>) => unknown>(fn: Fn) => Fn; function createFunction<const TArgs extends ReadonlyArray<unknown>>(spec: CreateFunctionSpec<TArgs>): <Fn extends (...args: InferArgs<TArgs>) => unknown>(fn: Fn) => Fn; function createFunction(): <Fn extends (...args: any[]) => unknown>(fn: Fn) => Fn;

createFunction(spec)(fn) — wrap a "use server" action with a per-arg parse/validate spec.

The wrapper's runtime is a thin pass-through; the value is in the type-level inference. The validate slots flow through InferArgs to constrain the handler's parameter list, so each parameter gets the inferred runtime type from its schema or wire-aware spec.

Three call shapes:

import { createFunction, formData, file } from "@lazarv/react-server/function"; import { z } from "zod"; // Array shorthand — most common export const greet = createFunction([z.string(), z.number()])( async function greet(name, age) { // name: string, age: number — inferred from the schemas. "use server"; return `${name}, ${age}`; } ); // Object form — when parse is also needed export const setLimit = createFunction({ parse: [(v) => Number(v)], validate: [z.number().int().min(1).max(1000)], })(async function setLimit(limit) { "use server"; // limit: number (parsed from string, then validated) }); export const upload = createFunction([ formData({ title: z.string().min(1), photo: file({ maxBytes: 5e6, mime: ["image/png", "image/jpeg"] }), }), ])(async function upload(form) { "use server"; // form: TypedFormData<{ title: ZodString; photo: FileSpec<...> }> const title = form.get("title"); // string const photo = form.get("photo"); // TypedFile<"image/png" | "image/jpeg"> if (photo.type === "image/png") { … } // narrowed: autocomplete works }); Requires TypeScript 5.0+ for the `const` modifier on the generic parameters (the project pin is 5.6.3).
function file<const M extends readonly string[] = readonly string[], const O extends boolean = false>(options?: { maxBytes?: number; mime?: M; validate?: (value: File | Blob) => boolean; optional?: O; }): FileSpec<M, O>;

Declare a File entry constraint inside a formData(...). Size / MIME are checked synchronously against Blob.size / Blob.type.

When mime is supplied as a tuple of literals (e.g. ["image/png", "image/jpeg"]), the inferred handler-side value is TypedFile<"image/png" | "image/jpeg">f.type is narrowed to the declared union, so switch/equality checks get autocomplete and exhaustiveness.

function formData<const E extends Record<string, unknown>>(shape: E, options?: { unknown?: "reject" | "drop" | "allow"; }): FormDataSpec<E>;

Declare a server-function argument that receives a sub-FormData object. The handler receives a TypedFormData<E> whose get, getAll, and has methods are typed by entry name.

formData(shape, options?) — the first argument is the entry-shape map (the primary input); the second is an optional config bag. Splitting them keeps the call site clean even as future per-form constraints land in options (e.g. maxEntries, transform).

const upload = createFunction([ formData({ title: z.string(), photo: file({ mime: ["image/png"] }), }), ])(async function (form) { const title = form.get("title"); // string (from z.string()) const photo = form.get("photo"); // TypedFile<"image/png"> const wat = form.get("attacker"); // FormDataEntryValue | null }); // Drop unexpected fields (e.g. when the form has React-managed // hidden fields the schema doesn't enumerate): formData({ title: z.string() }, { unknown: "drop" });
function iterable<const V extends ValidateSchema<any> | undefined = undefined>(options?: { maxYields?: number; value?: V; }): IterableSpec<V>;

Declare a sync Iterable<T> argument. Same shape as asyncIterable({...}); bounds enforced on each next() pull.

function map<const K extends ValidateSchema<any> | undefined = undefined, const V extends ValidateSchema<any> | undefined = undefined>(options?: { maxSize?: number; key?: K; value?: V; }): MapSpec<K, V>;

Declare a Map<K, V> argument with an optional size cap and inner key / value schemas. Inner schemas use the same Standard-Schema dispatch as the rest of createFunction — Zod / Valibot / ArkType all work.

function promise<const V extends ValidateSchema<any>>(value: V): PromiseSpec<V>; function promise(): PromiseSpec;

Declare a Promise<T> argument. Pass the resolved-value schema directly — the decoder wraps the promise so the resolution flows through the schema before the handler observes it. A rejected promise propagates unchanged.

function set<const V extends ValidateSchema<any> | undefined = undefined>(options?: { maxSize?: number; value?: V; }): SetSpec<V>;

Declare a Set<T> argument. Same shape as map({...}) minus the key channel.

function stream(options?: { maxChunks?: number; maxBytes?: number; }): StreamSpec;

Declare a ReadableStream argument. maxChunks and maxBytes are enforced as the handler consumes the stream — once either ceiling is exceeded the wrapped stream errors instead of yielding more data. Covers both flavors of Flight stream (text $r and binary $b).

function typedArray<const C extends ArrayBufferViewCtor | ReadonlyArray<ArrayBufferViewCtor>>(options: { ctor: C; maxBytes?: number; }): TypedArraySpec<C>; function typedArray(options?: { maxBytes?: number; }): TypedArraySpec;

Declare a TypedArray argument. Pass the actual constructor (or array of constructors) — references narrow both the wire-shape check (via instanceof) and the inferred runtime type. ctor: Float32Array yields (samples: Float32Array) in the handler signature; an array yields the union (e.g. Uint8Array | Uint8ClampedArray).

const METADATA_SYMBOL: unique symbol;

Metadata symbol attached to wrapped actions.

const noop: (value: unknown) => unknown;

No-op slot marker for createFunction arrays — placeholder for slots that don't need validation or parse, with explicit intent at the call site (instead of sparse [, , schema] or undefined):

createFunction([noop, noop, z.number()])(handler); // ^ runtime arg slot 2 // handler signature: (a: unknown, b: unknown, c: number)

Typed as a plain function so it doesn't structurally match ValidateSchema<T>; InferArg<typeof noop> therefore resolves to unknown, and the corresponding handler parameter is unknown — exactly what an unvalidated slot should be.

interface ArrayBufferSpec { readonly _kind: "arrayBuffer"; readonly maxBytes?: number; }
interface AsyncIterableSpec<V = unknown> { readonly _kind: "asyncIterable"; readonly maxYields?: number; readonly value?: V; }
interface BlobSpec<M extends readonly string[] = readonly string[], O extends boolean = boolean> { readonly _kind: "blob"; readonly maxBytes?: number; readonly mime?: M; readonly validate?: (value: File | Blob) => boolean; readonly optional?: O; }

Wire-aware Blob entry constraint. Same wire-shape semantics as FileSpec; kept separate for naming clarity in declarations and so the inferred runtime value type is Blob rather than File.

interface CreateFunctionSpec<TArgs extends ReadonlyArray<unknown> = ReadonlyArray<unknown>> { validate?: TArgs; parse?: ReadonlyArray<((value: unknown) => unknown) | undefined>; }

Per-arg parse/validate spec — the object form passed to createFunction({ validate, parse }). Both fields are arrays of per-slot entries indexed by the runtime arg slot (what the client puts on the wire), NOT by handler signature param. Bound captures (closure values) are not subject to this validation.

interface FileSpec<M extends readonly string[] = readonly string[], O extends boolean = boolean> { readonly _kind: "file"; readonly maxBytes?: number; readonly mime?: M; readonly validate?: (value: File | Blob) => boolean; readonly optional?: O; }

Wire-aware File entry constraint inside a formData(...). Size and MIME are checked synchronously against Blob.size / Blob.type before the entry is added to the result.

M records the MIME allowlist as a tuple of string literals (e.g. readonly ["image/png", "image/jpeg"]), so the inferred runtime File value can have a narrowed type property — see TypedFile<M> below.

O records the optional flag so the inferred value type can include | undefined when the entry is declared optional.

interface FormDataSpec<E extends Record<string, unknown> = Record<string, unknown>> { readonly _kind: "formdata"; readonly entries: E; readonly unknown: "reject" | "drop" | "allow"; }

Wire-aware FormData argument constraint. Drives decodeFormDataSlot directly: declared entries are looked up by exact key, anything else is rejected / dropped / allowed per the unknown policy.

The E type parameter records the entries map at the type level, so InferArg<FormDataSpec<E>> can produce a TypedFormData<E> whose get/getAll/has methods are typed by entry name.

interface IterableSpec<V = unknown> { readonly _kind: "iterable"; readonly maxYields?: number; readonly value?: V; }
interface MapSpec<K = unknown, V = unknown> { readonly _kind: "map"; readonly maxSize?: number; readonly key?: K; readonly value?: V; }
interface PromiseSpec<V = unknown> { readonly _kind: "promise"; readonly value?: V; }
interface SetSpec<V = unknown> { readonly _kind: "set"; readonly maxSize?: number; readonly value?: V; }
interface StreamSpec { readonly _kind: "stream"; readonly maxChunks?: number; readonly maxBytes?: number; }
interface TypedArraySpec<C extends ArrayBufferViewCtor | ReadonlyArray<ArrayBufferViewCtor> = ReadonlyArray<ArrayBufferViewCtor>> { readonly _kind: "typedArray"; readonly ctor?: C; readonly maxBytes?: number; }
interface TypedFormData<E extends Record<string, unknown>> extends FormData { get<K extends keyof E & string>(name: K): InferEntryValue<E[K]>; get(name: string): FormDataEntryValue | null; getAll<K extends keyof E & string>(name: K): Array<InferEntryValue<E[K]>>; getAll(name: string): FormDataEntryValue[]; has<K extends keyof E & string>(name: K): boolean; has(name: string): boolean; }

FormData whose get / getAll / has methods are typed by entry name. The decoder produces a real FormData populated only with the declared entries (under unknown: "reject" | "drop"), so by the time the handler reads it, every declared entry is either present with the validated value or — for optional: true file/blob entries — legitimately absent. We therefore type get(<declared key>) as non-null: optional entries carry the | undefined in their value type itself, so the user never has to null-check a key the schema declared as required.

Methods called with arbitrary string keys fall through to the platform FormData overload, returning FormDataEntryValue | null as usual.

type ArrayBufferViewCtor = typeof Int8Array | typeof Uint8Array | typeof Uint8ClampedArray | typeof Int16Array | typeof Uint16Array | typeof Int32Array | typeof Uint32Array | typeof Float32Array | typeof Float64Array | typeof BigInt64Array | typeof BigUint64Array | typeof DataView;

Union of constructor references the Flight protocol can carry as $AT payloads. Users pass these by reference (e.g. Uint8Array), not by string name — the runtime check is instanceof Ctor, and TS can derive the instance type via InstanceType<C>.

type InferArg<S> = S extends FormDataSpec<infer E> ? TypedFormData<E> : S extends FileSpec<any, any> ? _MaybeOptional<_FileFromSpec<S>, S> : S extends BlobSpec<any, any> ? _MaybeOptional<_BlobFromSpec<S>, S> : S extends ArrayBufferSpec ? ArrayBuffer : S extends TypedArraySpec<infer C> ? C extends ReadonlyArray<ArrayBufferViewCtor> ? _CtorInstance<C[number]> : C extends ArrayBufferViewCtor ? _CtorInstance<C> : ArrayBufferView : S extends MapSpec<infer K, infer V> ? Map<_InferInner<K>, _InferInner<V>> : S extends SetSpec<infer V> ? Set<_InferInner<V>> : S extends StreamSpec ? ReadableStream : S extends AsyncIterableSpec<infer V> ? AsyncIterable<_InferInner<V>> : S extends IterableSpec<infer V> ? Iterable<_InferInner<V>> : S extends PromiseSpec<infer V> ? Promise<_InferInner<V>> : S extends ValidateSchema<any> ? InferSchema<S> : unknown;

Infer the runtime type for a single arg slot. Wire-aware specs map to platform types (TypedFormData<E>, TypedFile<M>, TypedBlob<M>, ReadableStream, Map, Set, …); Standard Schemas (Zod / Valibot / ArkType / …) flow through InferSchema. Anything else (e.g. null / undefined — declared "accept anything") becomes unknown, which keeps the call site honest about the missing validation.

type InferArgs<TArgs extends ReadonlyArray<unknown>> = { [K in keyof TArgs]: InferArg<TArgs[K]>; };

Map a tuple of arg specs to a tuple of inferred runtime types. Drives the handler's parameter-type inference in createFunction.

type InferEntryValue<E> = E extends FileSpec<any, any> ? _MaybeOptional<_FileFromSpec<E>, E> : E extends BlobSpec<any, any> ? _MaybeOptional<_BlobFromSpec<E>, E> : E extends ValidateSchema<any> ? InferSchema<E> : FormDataEntryValue;

Infer the runtime value type for one declared FormData entry. Wire- aware specs (file / blob) become typed File / Blob; Standard Schemas flow through InferSchema; anything else falls back to FormDataEntryValue (the platform default).

type InferSchema<T> = T extends SafeParseSchema<infer U> ? U : T extends AssertSchema<infer U> ? U : T extends ParseSchema<infer U> ? U : never;

Infer the output type of a ValidateSchema

type TypedBlob<M extends string = string> = Omit<Blob, "type"> & { readonly type: M; };

Blob whose type is narrowed to the declared MIME allowlist. Same shape as TypedFile<M>, just for Blob.

type TypedFile<M extends string = string> = Omit<File, "type"> & { readonly type: M; };

File whose type is narrowed to the declared MIME allowlist. Structurally a subtype of File, so it interops cleanly with any API that takes a File — but lets you switch (f.type) { case "image/png": … } with full autocomplete and exhaustiveness.

Any schema that exposes at least one recognized validation method.

Supported patterns (tried in this order at runtime):

  1. .safeParse() — Zod, Valibot
  2. .assert() — ArkType
  3. .parse() — generic fallback

You can use any library whose schema objects satisfy one of these shapes.