@thomasfosterau/effect-forms
v0.1.0
Published
Framework-neutral, Effect-native form validation and state — schema-driven validation, nested errors, posted-value retention, and native constraints, on Effect Schema.
Maintainers
Readme
@thomasfosterau/effect-forms
Framework-neutral, Effect-native form validation and state — schema-driven validation, nested errors, posted-value retention, status messages, and native input constraints, built on Effect Schema with no framework dependency. The Svelte 5 bindings live in @thomasfosterau/effect-forms-svelte.
Why
The app validates everything with Effect Schema (Zod is gone). The progressive form libraries in the ecosystem are excellent, but they speak Standard Schema / Zod and bake in a framework. This package keeps the ergonomics — one schema drives validation, errors nest by field, posted values survive a failed submit, native input constraints come for free — while staying on Effect Schema and free of any framework, so the core is testable in isolation and reusable from a Worker, the CLI, or a Svelte route.
The shape
import * as Schema from "effect/Schema";
import * as Effect from "effect/Effect";
import { Form } from "@thomasfosterau/effect-forms";
const Signup = Schema.Struct({
name: Schema.String.check(Schema.isMinLength(2)),
age: Schema.optional(Schema.FiniteFromString),
});
const signupForm = Form.make(Signup, {
id: "signup",
defaults: { name: "", age: undefined },
});
// In a server action / load:
const state = await Effect.runPromise(signupForm.validate(request));
// state: { id, valid, posted, data, errors, constraints, message? }Building a form incrementally
Form.make binds a hand-written Schema.Struct. When you'd rather compose a form field by field — reusing field groups, merging them, and attaching whole-form (cross-field) rules — reach for FormBuilder:
import * as Schema from "effect/Schema";
import { Field, Form, FormBuilder } from "@thomasfosterau/effect-forms";
const signup = FormBuilder.empty
.addField("email", Schema.String.check(Schema.isMinLength(3)))
.addField("password", Schema.String.check(Schema.isMinLength(8)))
.addField("confirm", Schema.String)
.addField(Field.makeArrayField("tags", Schema.String)) // an array field
.refine((v) =>
v.password === v.confirm ? undefined : { path: ["confirm"], issue: "Passwords must match" },
);
// Bind it exactly like a schema (defaults are derived when omitted):
const form = Form.fromBuilder(signup, { id: "signup" });
const state = await Effect.runPromise(form.validate(request));FormBuilder.buildSchema collapses the builder into an ordinary Schema.Struct (array fields become Schema.Array(item)) with each synchronous refine applied as a struct-level check — so it decodes, derives constraints, and validates like any schema. refineEffect attaches an asynchronous whole-form rule (a uniqueness check against a service, say); Effect Schema has no effectful filter, so Form.fromBuilder runs those on the Effect validate path and folds their failures into the same error tree.
Modules are exposed as Effect-style namespaces (Form, State, Result, Errors, …). Form.make(schema, { defaults }) is the server-side validate-and-seed entry point:
validate(input)accepts aFormData,URLSearchParams,Request, or a plain object. It parses the body (nestedauthor.name/tags[0]/tags[]notation), decodes it through the schema collecting every field error (errors: "all"), and returns a serialisableFormState— valid with the decoded data, or invalid with the nestedFormErrorstree and the posted values preserved so the form re-renders what the user typed. It is anEffect, so a schema that requires decoding services composes naturally; it never fails on the error channel (validation failures live in the returned state).empty()seeds a blank, unposted form from the defaults — what aloadreturns to render a create form.
What's in the box
| Module | Purpose |
| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Form | make — bind a schema to a form (validate + empty); fromBuilder — bind a FormBuilder. |
| FormBuilder | Compose a form field by field: addField / merge / refine / refineEffect / buildSchema. |
| Field | makeField / makeArrayField field defs + guards, and blank-default derivation from a schema — defaultValue / defaultEncodedValues for a builder field, schemaAt / defaultItemAt for any nested path. |
| State | FormState, makeFormState, setMessage, withError, FormStateSchema (transport). |
| Errors | The nested FormErrors tree + issuesToErrors / flattenErrors / getError / setError / mergeErrors / remapIndices; FieldValidators per-source error tracking via replaceAt / setSourceAt / clearSourceAt / mergeSourced. |
| Issues | FormIssue + the Effect Schema issue-tree walker. |
| FormData | formDataToObject / parseFieldPath — structured parsing of an HTML body. |
| Path | getAt / setAt (immutable) along a FormPath, toString / fromString notation conversion, the type-level FieldPath<T> / PathValue<T, P> deep-key projections a typed field handle is built on, and the IndexMap constructors (removeAt / insertAt / swapAt / moveAt / replaceAt / clearAll) + remapIndex / remapKeyedRecord for keeping per-index state in sync with an array mutation. |
| Coerce | Tri-state (value / null / undefined) coercers for the form boundary. |
| Constraints | constraintsFromSchema — native input attributes (required, minlength/maxlength, min/max, pattern, step) derived from the schema AST, including into array items; constraintAt reads one field's leaf out of the derived tree. |
| Mode | FormMode + parse — when a form validates (onSubmit / onBlur / onChange), whether it auto-submits, and when a posted form revalidates (revalidate). |
| Result | FormActionResult — the framework-neutral submission outcome (success / failure / redirect / error). |
| Validate | validateValue / validateValueSync — ad-hoc value/field validation; FieldValidators + runSyncFieldValidator / runAsyncFieldValidator — per-field sync/async validators (debounced, fiber-cancellable). |
Notes
- Coercion is the schema's job. An HTML body is all strings; build the schema from coercing leaves (
Schema.FiniteFromString,Schema.BooleanFromString, theCoercehelpers) so the parsed object decodes cleanly. The parser never guesses types. - Errors nest by field. Leaf fields are
string[]; nested objects / array indices nest; a branch's own (cross-field) message lives under_errors. The tree is plain JSON — it crosses the SSR boundary unchanged. - Update semantics. The
Coercehelpers honour the value /null/undefinedcontract (set / clear / carry-forward) the app uses for partial updates. - Constraint derivation reads schema checks, not just optionality.
constraintsFromSchemawalks each property's.check()s for themetaannotation Effect Schema's built-in checks carry (isMinLength,isLengthBetween,isPattern,isGreaterThanOrEqualTo,isBetween,isMultipleOf,isInt, …), unwrapsoptional/NullOrunions, and descends into struct and array-item shapes. A check with no recognisedmetatag (a bareSchema.filterpredicate) derives nothing — there's no attribute to guess at. Anything the walk can't (or shouldn't) infer goes through theoverridesargument, which always wins on conflict. - Errors have a source.
Errors.SourcedErrorskeeps aclienttree (browser-side validation) parallel to aservertree (the last submission), merged for display bymergeSourced.Errors.replaceAt/setSourceAt/clearSourceAtupdate one path in one source without disturbing the rest — what a reactive store needs so a server-reported failure survives a client revalidation of some other field, but clears once its own field changes.
File naming
Source modules use PascalCase file names, matching Effect's own convention (effect/Schema, effect/SchemaParser). The package has a single entry point: every module is re-exported from the . barrel, so there are no subpaths to import from.
Development
From the repository root:
vp run -r build # build every package with tsdown
vp check # format (Oxfmt), lint (Oxlint), type-check (tsgolint)
vp run -r test # unit testsOr from this directory: vp test run / vp test run --coverage.
