react-formbridge
Browse documentation
Core conceptsv1.0.2

createSchema() API

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 by useFormBridge, 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 through SchemaValues<typeof mySchema> and the generated fields.* components.
  • Type inference is preserved: const inference on the shape keeps every field's builder type, so refinements receive a fully typed values argument and errors are routed back to the right field.
  • Import surface stays flexible: use the main @runilib/react-formbridge entry in client-only files; if the schema module is shared with strict server runtimes, author it from @runilib/react-formbridge/schema and 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', then managerApproval is 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 under state.formLevelError.
  • You want to parse the submitted values to a fully-typed object via safeParse / validate ideal 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 errorMap instead 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.

TripBookingForm.tsxtsx
1import { createSchema, field, useFormBridge } from '@runilib/react-formbridge'
2
3const 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
23export 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}

Shared client/server schema modules

If a schema file is imported by both React code and server-side code, define that file with the server-safe @runilib/react-formbridge/schema subpath. That keeps schema authoring and parsing available without pulling hooks or UI helpers into the server module graph.

Keep React-only APIs such as useFormBridge imported from the main @runilib/react-formbridge entry inside your client component files.

bookingSchema.ts + BookingForm.tsx + actions.tsts
1// bookingSchema.ts
2import { createSchema, field, type SchemaValues } from '@runilib/react-formbridge/schema'
3
4export const bookingSchema = createSchema({
5 email: field.email('Email').trim().lowercase(),
6 phone: field.phone('Phone').defaultCountry('FR'),
7}).atLeastOne(
8 ['email', 'phone'],
9 'Provide at least an email or a phone number.',
10)
11
12export type BookingValues = SchemaValues<typeof bookingSchema>
13
14// BookingForm.tsx
15import { useFormBridge } from '@runilib/react-formbridge'
16import { bookingSchema } from './bookingSchema'
17
18export function BookingForm() {
19 const { Form, fields } = useFormBridge(bookingSchema)
20
21 return (
22 <Form onSubmit={(values) => console.log(values)}>
23 <fields.email />
24 <fields.phone />
25 <Form.Submit>Continue</Form.Submit>
26 </Form>
27 )
28}
29
30// actions.ts
31'use server'
32
33import { bookingSchema, type BookingValues } from './bookingSchema'
34
35export async function submitBooking(raw: unknown) {
36 return bookingSchema.safeParseAsync(raw as Partial<BookingValues>)
37}

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

InlineExample-2.tsts
1safeParse(values: Partial<SchemaValues<T>>): ValidationResult<SchemaValues<T>>
2safeParseAsync(values: Partial<SchemaValues<T>>): Promise<ValidationResult<SchemaValues<T>>>

Returned shape

InlineExample-4.tsts
1type ValidationResult<T> =
2 | { success: true; data: T; issues: []; errorsByField: {}; formLevelErrors: [] }
3 | { success: false; data: null; issues: ValidationIssue[]; errorsByField: Record<string, string>; formLevelErrors: string[] }
FieldTypeDescription
errorsByFieldRecord<string, string>Map keyed by field name, first error per field. Drop-in compatible with state.errors
formLevelErrorsstring[]Messages that had no field path (form-level)
issuesValidationIssue[]Raw, ordered list of every issue (including duplicates) - useful for analytics or custom grouping

When to use which

  • safeParse is synchronous. Any async refinement in the chain will throw a loud error telling you to use safeParseAsync.
  • safeParseAsync runs 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 errorsByField without mounting React.
server-action.tsts
1'use server'
2
3import { tripSchema } from './tripSchema'
4
5export 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

InlineExample-2.tsts
1validate(values: Partial<SchemaValues<T>>): SchemaValues<T>
2validateAsync(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

InlineExample-2.tsts
1refine(
2 predicate: (values: SchemaValues<T>) => boolean,
3 message?: string | ValidationIssueInput,
4): FormBridgeSchema<T>
5
6refineAsync(
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 under state.formLevelError.
  • Pass an object to pin the error to a specific field: refine(p, { path: 'email', message: 'Taken' }).
  • refineAsync only runs inside safeParseAsync / 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.
refine-examples.tsts
1const 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

InlineExample-2.tsts
1superRefine(
2 refinement: (values: SchemaValues<T>, ctx: ValidationContext) => void | Promise<void>,
3): FormBridgeSchema<T>
4
5type ValidationContext = {
6 addIssue(issue: string | ValidationIssueInput): void
7}
8
9type 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 code and params for 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.

super-refine.tsts
1createSchema({
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

InlineExample-2.tsts
1errorMap(mapper: ValidationErrorMap): FormBridgeSchema<T>
2
3type 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.

i18n-error-map.tsts
1import { t } from './i18n'
2
3const 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

InlineExample-2.tsts
1atLeastOne(
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 schema
  • ref('profile.phone') nested or dynamic path via the ref() helper
at-least-one.tsts
1createSchema({
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

InlineExample-2.tsts
1exactlyOne(
2 fields: Array<keyof T | string | FieldReference>,
3 message?: string,
4): FormBridgeSchema<T>

Default message

"Exactly one of <field1>, <field2>, ... must be provided."

exactly-one.tsts
1createSchema({
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

InlineExample-2.tsts
1allOrNone(
2 fields: Array<keyof T | string | FieldReference>,
3 message?: string,
4): FormBridgeSchema<T>

Default message

"Provide either all or none of <field1>, <field2>, ..."

all-or-none.tsts
1createSchema({
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:

  1. Nested paths. Plain string keys only work for top-level fields. ref('profile.phone') walks into a nested object during cross-field checks.
  2. Self-documenting intent. ref('profile.phone') inside an atLeastOne or exactlyOne call 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:

  1. Normalised into a ValidationIssue with { path, code, message, params }.
  2. Optionally rewritten by your errorMap mapper, if one is registered.
  3. Bucketed into errorsByField (keyed by path) and formLevelErrors (no path).
  4. Merged into the React form state, where errorsByField lands in state.errors and the first formLevelErrors entry lands in state.formLevelError.

Practical consequences:

  • Field-level UI (<fields.email />) automatically displays path-keyed issues you do nothing.
  • To show form-level errors, read state.formLevelError and render it wherever makes sense (below the form, in a toast, etc.).
  • Duplicate issues on the same field are preserved in issues but errorsByField only 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:

InlineExample-1.tsxtsx
1import type { FormSchema } from '@runilib/react-formbridge'
2
3const 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:

InlineExample-3.tsxtsx
1const 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.