createSchema(shape) wraps a plain field-builder object and layers on a full, zero-dependency validation API cross-field rules, refinements, typed parsing. So you do not need Zod, Yup, Joi, or Valibot to get a production-grade form.
- The plain schema describes what fields exist and how they render, default, and self-validate.
- createSchema(shape) takes that same object and returns a wrapped value that additionally exposes a validation API (
safeParse,refine,atLeastOne, etc.). The wrapped value is still accepted byuseFormBridge, so you do not split rendering and validation across two objects. - Autocomplete stays clean: The wrapped value deliberately hides the field keys from direct autocomplete on the schema object typing
mySchema.suggests only the API methods. Field-level inference still flows throughSchemaValues<typeof mySchema>and the generatedfields.*components. - Type inference is preserved:
constinference on the shape keeps every field's builder type, so refinements receive a fully typedvaluesargument and errors are routed back to the right field. - Import surface stays flexible: use the main
@runilib/react-formbridgeentry in client-only files; if the schema module is shared with strict server runtimes, author it from@runilib/react-formbridge/schemaand import React APIs separately from the main package.
Why createSchema() exists
Field-level rules (required, min, email, matches, etc.) already run through the builder chain on each individual field. That covers most forms until you need a rule that depends on *multiple* fields at once:
- "Provide at least an email or a phone number."
- "Return date must be on or after departure date."
- "If you supply a billing address, all billing fields must be filled."
- "Password must match confirmation, and must not contain the email handle."
- "If
role === 'admin', thenmanagerApprovalis required." - etc.
Without createSchema() you would either scatter these checks across ad-hoc useEffect hooks, duplicate them in onSubmit handlers, or pull in an external bridge library (Zod, Yup, Joi, Valibot) just to express a handful of rules. createSchema() gives you a first-class, chainable place for those rules that runs through the same validation pipeline as every other field errors land in state.errors, touch/dirty tracking still works, and form-level errors surface under state.formLevelError.
The design goal is stated plainly: FormBridge should be self-sufficient. Built-in validation is the complete path, not a stepping stone to an external bridge.
When to reach for createSchema()
Use createSchema() whenever any of the following apply:
- You need cross-field validation (one field's validity depends on another's value).
- You want form-level errors that are not attached to a specific field -
createSchema()surfaces these understate.formLevelError. - You want to parse the submitted values to a fully-typed object via
safeParse/validateideal inside server actions, tRPC procedures, or standalone utilities where you do not have a mounted form. - You want async refinements (e.g. username availability, server-side uniqueness checks) wired into the same validation pass as synchronous rules.
- You want to customise error messages globally via
errorMapinstead of overriding each field.
If your form has only independent field rules, the plain object form with satisfies FormSchema is enough. Wrap it in createSchema() the moment you need any of the behaviours above the two forms are interchangeable at the useFormBridge call site.
Quick start
The wrapped schema is used exactly like a plain shape. Hand it to useFormBridge, render via fields.*, and chain cross-field rules on the result of createSchema(). Declare the schema at module scope (outside the component) so its identity stays stable across renders. If that schema module is reused outside React, move the schema authoring imports to @runilib/react-formbridge/schema.
| 1 | import { createSchema, field, useFormBridge } from '@runilib/react-formbridge' |
| 2 | |
| 3 | const tripSchema = createSchema({ |
| 4 | email: field.email().label('Email'), |
| 5 | phone: field.phone('FR').label('Phone'), |
| 6 | password: field.password().required().min(8), |
| 7 | confirmPassword: field.password().required(), |
| 8 | }) |
| 9 | .atLeastOne( |
| 10 | ['email', 'phone'], |
| 11 | 'Provide at least an email or a phone number.', |
| 12 | ) |
| 13 | .superRefine((values, ctx) => { |
| 14 | if (values.password !== values.confirmPassword) { |
| 15 | ctx.addIssue({ |
| 16 | path: 'confirmPassword', |
| 17 | code: 'password_mismatch', |
| 18 | message: 'Passwords do not match.', |
| 19 | }) |
| 20 | } |
| 21 | }) |
| 22 | |
| 23 | export function TripBookingForm() { |
| 24 | const { Form, fields, state } = useFormBridge(tripSchema, { |
| 25 | validateOn: 'onTouched', |
| 26 | revalidateOn: 'onChange', |
| 27 | }) |
| 28 | |
| 29 | // Form-level errors (no specific field) land under state.formLevelError |
| 30 | const formLevelError = state.formLevelError |
| 31 | |
| 32 | return ( |
| 33 | <Form onSubmit={(values) => console.log(values)}> |
| 34 | <fields.email /> |
| 35 | <fields.phone /> |
| 36 | <fields.password /> |
| 37 | <fields.confirmPassword /> |
| 38 | {formLevelError ? <p className="error">{formLevelError}</p> : null} |
| 39 | <Form.Submit>Book the trip</Form.Submit> |
| 40 | </Form> |
| 41 | ) |
| 42 | } |
safeParse / safeParseAsync
The parse surface runs every field-level validator plus every refinement and returns a structured result. It never throws - the caller inspects result.success to decide what to do.
Signature
| 1 | safeParse(values: Partial<SchemaValues<T>>): ValidationResult<SchemaValues<T>> |
| 2 | safeParseAsync(values: Partial<SchemaValues<T>>): Promise<ValidationResult<SchemaValues<T>>> |
Returned shape
| 1 | type ValidationResult<T> = |
| 2 | | { success: true; data: T; issues: []; errorsByField: {}; formLevelErrors: [] } |
| 3 | | { success: false; data: null; issues: ValidationIssue[]; errorsByField: Record<string, string>; formLevelErrors: string[] } |
| Field | Type | Description |
|---|---|---|
errorsByField | Record<string, string> | Map keyed by field name, first error per field. Drop-in compatible with state.errors |
formLevelErrors | string[] | Messages that had no field path (form-level) |
issues | ValidationIssue[] | Raw, ordered list of every issue (including duplicates) - useful for analytics or custom grouping |
When to use which
safeParseis synchronous. Any async refinement in the chain will throw a loud error telling you to usesafeParseAsync.safeParseAsyncruns both sync and async refinements in order. Always reach for it on the server side, where you typically have async checks.
Typical uses
- Server actions / tRPC handlers that want to reuse the same schema they render with.
- Standalone utility functions that validate persisted drafts before writing to the database.
- Tests you can assert directly on
errorsByFieldwithout mounting React.
| 1 | 'use server' |
| 2 | |
| 3 | import { tripSchema } from './tripSchema' |
| 4 | |
| 5 | export async function bookTrip(raw: unknown) { |
| 6 | const result = await tripSchema.safeParseAsync(raw as Partial<SchemaValues<typeof tripSchema>>) |
| 7 | |
| 8 | if (!result.success) { |
| 9 | return { ok: false as const, errors: result.errorsByField, formLevelErrors: result.formLevelErrors } |
| 10 | } |
| 11 | |
| 12 | // result.data is fully typed from the schema shape |
| 13 | await db.bookings.insert(result.data) |
| 14 | return { ok: true as const } |
| 15 | } |
validate / validateAsync
The strict variants of safeParse / safeParseAsync. They return the typed, parsed data on success and throw FormBridgeSchemaValidationError on failure. The thrown error carries the full ValidationResult on its .result property.
Signature
| 1 | validate(values: Partial<SchemaValues<T>>): SchemaValues<T> |
| 2 | validateAsync(values: Partial<SchemaValues<T>>): Promise<SchemaValues<T>> |
Use these when you want to bail early on invalid input for example, inside a try / catch at a request boundary and do not want to hand-unwrap the result.success discriminated union on every call site. safeParse is usually the safer default inside React components.
refine / refineAsync
Attach a boolean predicate as a cross-field rule. Returns true if the values are valid, false to raise an issue. The message argument becomes the issue message; omit it to use the default "Invalid form.".
Signature
| 1 | refine( |
| 2 | predicate: (values: SchemaValues<T>) => boolean, |
| 3 | message?: string | ValidationIssueInput, |
| 4 | ): FormBridgeSchema<T> |
| 5 | |
| 6 | refineAsync( |
| 7 | predicate: (values: SchemaValues<T>) => Promise<boolean>, |
| 8 | message?: string | ValidationIssueInput, |
| 9 | ): FormBridgeSchema<T> |
Notes
- A bare string message creates a form-level error (no
path), so it surfaces understate.formLevelError. - Pass an object to pin the error to a specific field:
refine(p, { path: 'email', message: 'Taken' }). refineAsynconly runs insidesafeParseAsync/validateAsync. Running it through the synchronous path throws.- Chain as many
.refine()calls as you like each returns the same wrapped schema so the chain is fluent.
| 1 | const accountSchema = createSchema({ |
| 2 | password: field.password().required().min(8), |
| 3 | confirmPassword: field.password().required(), |
| 4 | username: field.text().required(), |
| 5 | }) |
| 6 | .refine( |
| 7 | (values) => values.password === values.confirmPassword, |
| 8 | { path: 'confirmPassword', code: 'mismatch', message: 'Passwords do not match.' }, |
| 9 | ) |
| 10 | .refineAsync( |
| 11 | async (values) => !(await isUsernameTaken(values.username)), |
| 12 | { path: 'username', code: 'taken', message: 'That username is already taken.' }, |
| 13 | ) |
superRefine
The most flexible refinement primitive. Instead of returning a boolean, you receive a ctx object with an addIssue method and can raise multiple issues each routed to its own field in a single pass.
Signature
| 1 | superRefine( |
| 2 | refinement: (values: SchemaValues<T>, ctx: ValidationContext) => void | Promise<void>, |
| 3 | ): FormBridgeSchema<T> |
| 4 | |
| 5 | type ValidationContext = { |
| 6 | addIssue(issue: string | ValidationIssueInput): void |
| 7 | } |
| 8 | |
| 9 | type ValidationIssueInput = { |
| 10 | path?: string // omit for form-level error |
| 11 | code?: string // identifier for grouping / analytics |
| 12 | message: string |
| 13 | params?: Record<string, unknown> |
| 14 | } |
When to pick superRefine over refine
- You need to raise errors on several fields from one cross-check.
- You want to set a custom
codeandparamsfor downstream logging or i18n. - You want to short-circuit further work inside the refinement based on shape (e.g. skip the check if a value is empty).
atLeastOne, exactlyOne, and allOrNone are all implemented internally as superRefine calls, so anything they can do, you can write by hand if you need custom behaviour.
| 1 | createSchema({ |
| 2 | password: field.password().required().min(8), |
| 3 | confirmPassword: field.password().required(), |
| 4 | email: field.email().required(), |
| 5 | }).superRefine((values, ctx) => { |
| 6 | if (values.password !== values.confirmPassword) { |
| 7 | ctx.addIssue({ |
| 8 | path: 'confirmPassword', |
| 9 | code: 'password_mismatch', |
| 10 | message: 'Passwords do not match.', |
| 11 | }) |
| 12 | } |
| 13 | |
| 14 | const handle = values.email.split('@')[0]?.toLowerCase() |
| 15 | if (handle && values.password.toLowerCase().includes(handle)) { |
| 16 | ctx.addIssue({ |
| 17 | path: 'password', |
| 18 | code: 'password_contains_email', |
| 19 | message: "Don't reuse your email handle inside your password.", |
| 20 | }) |
| 21 | } |
| 22 | }) |
errorMap
Register a global transformer for validation messages. The mapper receives the raw issue and can return a new string (or null to use the default). This is the FormBridge-native equivalent of Zod's errorMap ideal for i18n or for standardising error copy across a large form.
Signature
| 1 | errorMap(mapper: ValidationErrorMap): FormBridgeSchema<T> |
| 2 | |
| 3 | type ValidationErrorMap = ( |
| 4 | issue: ValidationIssue, |
| 5 | defaultMessage: string, |
| 6 | ) => string | null | undefined |
The mapper runs against every issue produced by field validators and refinements, so you can centralise message rewriting in one place.
| 1 | import { t } from './i18n' |
| 2 | |
| 3 | const schemaFR = createSchema({ |
| 4 | email: field.email().required(), |
| 5 | password: field.password().required().min(8), |
| 6 | }).errorMap((issue, defaultMessage) => { |
| 7 | switch (issue.code) { |
| 8 | case 'required': return t('errors.required') |
| 9 | case 'min': return t('errors.min', { n: issue.params?.min }) |
| 10 | case 'invalid_email': return t('errors.invalidEmail') |
| 11 | default: return defaultMessage |
| 12 | } |
| 13 | }) |
atLeastOne
Built-in helper: require that at least one of the listed fields has a "provided" value. A value is considered provided when it is a non-empty string, a true boolean, a non-empty array, or any other non-nullish value.
Signature
| 1 | atLeastOne( |
| 2 | fields: Array<keyof T | string | FieldReference>, |
| 3 | message?: string, |
| 4 | ): FormBridgeSchema<T> |
Default message
"At least one of <field1>, <field2>, ... is required."? You can override it by passing your own string.
Error routing
The error has no path, so it surfaces as a form-level error under state.formLevelError. Render it below the group of fields it governs.
Accepted field identifiers
'email'string key on the root schemaref('profile.phone')nested or dynamic path via theref()helper
| 1 | createSchema({ |
| 2 | email: field.email().label('Email'), |
| 3 | phone: field.phone('FR').label('Phone'), |
| 4 | }).atLeastOne( |
| 5 | ['email', 'phone'], |
| 6 | 'Provide at least an email or a phone number so we can reach you.', |
| 7 | ) |
exactlyOne
Built-in helper: require that exactly one of the listed fields is provided. Raises an issue if zero or more than one are filled in. Useful for "pick your delivery method" or "choose a contact channel" style forms where the rest of the pipeline assumes a single winner.
Signature
| 1 | exactlyOne( |
| 2 | fields: Array<keyof T | string | FieldReference>, |
| 3 | message?: string, |
| 4 | ): FormBridgeSchema<T> |
Default message
"Exactly one of <field1>, <field2>, ... must be provided."
| 1 | createSchema({ |
| 2 | pickupAddress: field.text().label('Pick up in store'), |
| 3 | homeDelivery: field.text().label('Home delivery address'), |
| 4 | lockerCode: field.text().label('Parcel locker code'), |
| 5 | }).exactlyOne( |
| 6 | ['pickupAddress', 'homeDelivery', 'lockerCode'], |
| 7 | 'Pick exactly one delivery method.', |
| 8 | ) |
allOrNone
Built-in helper: either all of the listed fields are provided, or none of them are. Triggers when the user has filled in some but not all of the group. Perfect for optional-but-atomic sections like "billing address" or "emergency contact".
Signature
| 1 | allOrNone( |
| 2 | fields: Array<keyof T | string | FieldReference>, |
| 3 | message?: string, |
| 4 | ): FormBridgeSchema<T> |
Default message
"Provide either all or none of <field1>, <field2>, ..."
| 1 | createSchema({ |
| 2 | billingStreet: field.text().label('Street'), |
| 3 | billingCity: field.text().label('City'), |
| 4 | billingZip: field.text().label('ZIP'), |
| 5 | }).allOrNone( |
| 6 | ['billingStreet', 'billingCity', 'billingZip'], |
| 7 | 'Fill in the full billing address or leave it blank.', |
| 8 | ) |
ref() - field references
ref(path) produces a typed pointer to a field. It exists for two reasons:
- Nested paths. Plain string keys only work for top-level fields.
ref('profile.phone')walks into a nested object during cross-field checks. - Self-documenting intent.
ref('profile.phone')inside anatLeastOneorexactlyOnecall reads unambiguously as "this field path", while a bare string can look like ordinary text.
You can mix ref() and plain string keys freely inside atLeastOne, exactlyOne, allOrNone, and other custom validation logic. Under the hood the bridge uses getValueAtPath with dot-notation, so deeply nested schemas work transparently.
How errors reach the UI
Every issue produced by createSchema() whether it comes from a field builder, a manual refine, a superRefine, or one of the built-in helpers flows through the same pipeline:
- Normalised into a
ValidationIssuewith{ path, code, message, params }. - Optionally rewritten by your
errorMapmapper, if one is registered. - Bucketed into
errorsByField(keyed by path) andformLevelErrors(no path). - Merged into the React form state, where
errorsByFieldlands instate.errorsand the firstformLevelErrorsentry lands instate.formLevelError.
Practical consequences:
- Field-level UI (
<fields.email />) automatically displays path-keyed issues you do nothing. - To show form-level errors, read
state.formLevelErrorand render it wherever makes sense (below the form, in a toast, etc.). - Duplicate issues on the same field are preserved in
issuesbuterrorsByFieldonly keeps the first one, mirroring the usual "one message per field" convention.
Typing tip - satisfies vs createSchema()
If your form needs only field-level rules, keep the object form and annotate it with satisfies FormSchema so TypeScript preserves each field's precise type:
| 1 | import type { FormSchema } from '@runilib/react-formbridge' |
| 2 | |
| 3 | const profileSchema = { |
| 4 | bio: field.textarea('Bio'), |
| 5 | country: field.select('Country').options(['FR', 'US']), |
| 6 | } satisfies FormSchema |
The moment you need cross-field rules, a parse surface, or form-level errors, wrap it in createSchema() nothing else in the call site has to change:
| 1 | const profileSchema = createSchema({ |
| 2 | bio: field.textarea('Bio'), |
| 3 | country: field.select('Country').options(['FR', 'US']), |
| 4 | altCountry: field.select('Alt country').options(['FR', 'US']), |
| 5 | }).refine( |
| 6 | (values) => values.country !== values.altCountry, |
| 7 | { path: 'altCountry', message: 'Alt country must differ from primary.' }, |
| 8 | ) |
useFormBridge(profileSchema) accepts either form and SchemaValues<typeof profileSchema> resolves to the same typed object in both cases.