SchemaForm Documentation

A schema-first form system for Svelte 5 powered by @effect/schema. Define your form once with validation, types, and UI metadata - the form renders itself.

Overview

SchemaForm provides a declarative approach to building forms where your Effect Schema serves as the single source of truth for:

  • Validation rules - Type constraints, patterns, min/max values
  • TypeScript types - Automatically inferred from schema
  • UI metadata - Labels, placeholders, input types, descriptions
  • Layout configuration - Sections, steps, column spans, ordering
// Define once, get everything
const LoginSchema = pipe(
  Schema.Struct({
    email: pipe(
      Schema.String,
      Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/),
      withField({
        label: "Email",
        inputType: "email",
        placeholder: "you@example.com"
      })
    ),
    password: pipe(
      Schema.String,
      Schema.minLength(8),
      withField({
        label: "Password",
        inputType: "password"
      })
    )
  })
);

// TypeScript type is automatically inferred
type Login = Schema.Schema.Type<typeof LoginSchema>;
// { email: string; password: string }

Quick Start

1. Define Your Schema

import { Schema, pipe } from "effect";
import { withField, withFormLayout } from "@kareyes/aether/forms";

const ContactSchema = pipe(
  Schema.Struct({
    name: pipe(
      Schema.String,
      Schema.minLength(1),
      withField({
        label: "Full Name",
        placeholder: "John Doe",
        inputType: "text"
      })
    ),
    email: pipe(
      Schema.String,
      Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/),
      Schema.annotations({
        message: () => "Please enter a valid email"
      }),
      withField({
        label: "Email Address",
        inputType: "email"
      })
    ),
    message: pipe(
      Schema.String,
      Schema.minLength(10),
      withField({
        label: "Message",
        inputType: "textarea",
        placeholder: "How can we help?"
      })
    )
  }),
  withFormLayout({
    columns: 1,
    sections: [{ id: "contact", title: "Contact Us" }]
  })
);

2. Create a Controller

import { FormController } from "@kareyes/aether/forms";

const controller = new FormController(ContactSchema, {
  validateOnBlur: true,
  validateOnChange: false,
  initialValues: {
    name: "",
    email: "",
    message: ""
  }
});

3. Render the Form

<script lang="ts">
  import { SchemaForm } from "@kareyes/aether/forms";

  async function handleSubmit(data) {
    console.log("Form submitted:", data);
    await api.submitContact(data);
  }
</script>

<SchemaForm
  {controller}
  onSubmit={handleSubmit}
  submitText="Send Message"
/>

Core Concepts

Schema-First Design

The schema defines everything about your form:

const UserSchema = pipe(
  Schema.Struct({
    // Validation: minLength(2), maxLength(50)
    // Type: string
    // UI: text input with label "Username"
    username: pipe(
      Schema.String,
      Schema.minLength(2),
      Schema.maxLength(50),
      withField({
        label: "Username",
        inputType: "text",
        description: "Choose a unique username"
      })
    )
  })
);

Annotations

Annotations attach metadata to schema fields:

  • withField() - Combined UI and layout metadata
  • withFieldUI() - UI-only metadata (label, placeholder, inputType)
  • withFieldLayout() - Layout-only metadata (section, step, colSpan)
  • withFormLayout() - Form-level configuration (sections, steps, columns)

FormController

The controller manages form state and provides methods for:

  • Value manipulation (setValue, setValues)
  • Validation (validate, validateField, validateStep)
  • Step navigation (nextStep, prevStep, goToStep)
  • Submission (submit)
  • Reset (reset)

API Reference

SchemaForm Component

The main component that renders your form.

Props

Prop Type Default Description
controller FormController required The form controller instance
onSubmit (data) => Promise<void> | void - Called with validated data on submit
onError (errors: FieldErrors) => void - Called when validation fails
onChange (values) => void - Called when any value changes
sectionVariant 'default' | 'card' | 'collapsible' 'default' Section styling variant
showStepIndicator boolean true Show/hide step indicator in multi-step forms
submitText string 'Submit' Submit button text
nextText string 'Next' Next button text (multi-step)
prevText string 'Back' Back button text (multi-step)
class string - Additional CSS classes

Snippets

<SchemaForm {controller} onSubmit={handleSubmit}>
  {#snippet header()}
    <h2>Custom Header</h2>
  {/snippet}

  {#snippet footer({ isSubmitting, isValid, isFirstStep, isLastStep, handleSubmit, handleNext, handlePrev })}
    <div class="flex justify-between">
      {#if !isFirstStep}
        <Button variant="outline" onclick={handlePrev}>Back</Button>
      {/if}
      <Button onclick={isLastStep ? handleSubmit : handleNext} disabled={isSubmitting}>
        {isLastStep ? "Save" : "Next"}
      </Button>
    </div>
  {/snippet}
</SchemaForm>
Footer snippet parameters
Parameter Type Description
isSubmitting boolean True while form submission is in progress
isValid boolean True when there are no validation errors
isFirstStep boolean True when on the first step (always true for single-step forms)
isLastStep boolean True when on the last step (always true for single-step forms)
handleSubmit () => void Trigger form submission
handleNext () => void Advance to next step (validates current step first)
handlePrev () => void Go back to previous step

Keyboard Shortcuts

Shortcut Action
Ctrl+Enter Submit form or advance to next step

FormController

class FormController<A, I, R> {
  constructor(
    schema: Schema.Schema<A, I, R>,
    config?: FormConfig<A>
  );

  // State
  readonly state: FormState<Partial<A>>;
  readonly form: ExtractedForm;

  // Subscriptions
  subscribe(listener: (state) => void): () => void;

  // Values
  setValue(field: string, value: unknown): void;
  setValues(values: Partial<A>): void;

  // Validation
  validate(): ValidationResult<A>;
  validateField(field: string): string | undefined;
  validateStep(): boolean;

  // Errors
  setFieldError(field: string, error: string): void;
  setErrors(errors: FieldErrors): void;

  // Steps (multi-step forms)
  nextStep(): boolean;  // Returns true if step was valid and navigation occurred
  prevStep(): void;
  goToStep(step: number): void;

  // Submission
  submit(handler: (data: A) => Promise<void>): Promise<boolean>;

  // Reset
  reset(values?: Partial<A>): void;

  // Field state
  getFieldState(field: string): FieldState;
  shouldShowError(field: string): boolean;
}

FormConfig

interface FormConfig<T> {
  initialValues?: Partial<T>;
  validateOnChange?: boolean;  // Default: true
  validateOnBlur?: boolean;    // Default: true
  validateOnMount?: boolean;   // Default: false
  revalidateOnChange?: boolean; // Default: true
}

FormState

interface FormState<T> {
  values: T;
  errors: Record<string, string | undefined>;
  touched: Record<string, boolean>;
  dirty: Record<string, boolean>;
  isSubmitting: boolean;
  isValidating: boolean;
  isValid: boolean;
  isDirty: boolean;
  submitCount: number;
  currentStep: number;
  validationVersion: number; // Increments on every error/touched change — useful for derived reactive contexts
}

Annotations

withField()

Combined UI and layout annotation:

withField({
  // UI properties
  label: string;              // Field label (required)
  placeholder?: string;       // Input placeholder
  description?: string;       // Help text below field
  inputType?: InputType;      // Input type (auto-detected if not set)
  options?: FieldOption[];    // For select/radio/combobox
  optionGroups?: FieldOptionGroup[]; // Grouped options for select/combobox
  mask?: string;              // Input mask (e.g., "phone")
  autocomplete?: string;      // HTML autocomplete attribute
  disabled?: boolean;         // Disable field
  readonly?: boolean;         // Read-only field

  // File input properties (inputType: "file" only)
  fileMode?: 'drag-drop' | 'regular' | 'button-only'; // Display mode (default: "drag-drop")
  multiple?: boolean;         // Allow multiple file selection
  accept?: string;            // HTML accept attribute (e.g. "image/*", ".pdf,.docx")

  // Layout properties
  section?: string;           // Section ID
  step?: number;              // Step number (1-based)
  order?: number;             // Sort order within section
  colSpan?: ColumnSpan;       // Column span (1-12 or "full")
  colSpanSm?: ColumnSpan;     // Column span at sm breakpoint
  colSpanMd?: ColumnSpan;     // Column span at md breakpoint
  colSpanLg?: ColumnSpan;     // Column span at lg breakpoint
})

withFormLayout()

Form-level layout configuration:

withFormLayout({
  columns?: number;           // Grid columns (default: 1)
  gap?: "none" | "sm" | "md" | "lg"; // Gap between fields (default: "md")
  sections?: SectionConfig[];  // Section definitions
  steps?: StepConfig[];       // Step definitions (for multi-step)
})

interface SectionConfig {
  id: string;                 // Unique section ID
  title?: string;             // Section title
  description?: string;       // Section description
  order?: number;             // Sort order
  collapsible?: boolean;      // Make section collapsible
  defaultCollapsed?: boolean; // Start collapsed (requires collapsible: true or sectionVariant="collapsible")
}

interface StepConfig {
  step: number;               // Step number (1-based)
  title: string;              // Step title
  description?: string;       // Step description
  icon?: string;              // Icon name
}

Input Types

SchemaForm automatically renders the appropriate input component based on the inputType annotation:

Input Type Component Schema Type
text Input Schema.String
email Input (type="email") Schema.String
password Input (type="password") Schema.String
tel Input (type="tel") Schema.String
url Input (type="url") Schema.String
number Input (type="number") Schema.Number
textarea Textarea Schema.String
select Select Schema.String
combobox Combobox Schema.String
checkbox Checkbox RequiredCheckbox | requiredCheckbox() | Schema.Boolean
switch Switch RequiredSwitch | requiredSwitch() | Schema.Boolean
radio RadioGroup Schema.String
date DatePicker Schema.String
datetime DateTimePicker Schema.String
file FileInput RequiredFile | requiredFile() | Schema.Any
hidden Hidden input Any

Auto-Detection

If inputType is not specified, it's inferred from the schema type:

  • Schema.Booleancheckbox
  • Schema.Numbernumber
  • Schema.Stringtext
  • Union of ≤5 string literals → radio
  • Union of >5 string literals → select

Form Layouts

Single Section

const FormSchema = pipe(
  Schema.Struct({
    field1: pipe(Schema.String, withField({ label: "Field 1" })),
    field2: pipe(Schema.String, withField({ label: "Field 2" }))
  }),
  withFormLayout({
    columns: 2,
    sections: [{ id: "main", title: "Form Title" }]
  })
);

Multiple Sections

const FormSchema = pipe(
  Schema.Struct({
    firstName: pipe(
      Schema.String,
      withField({ label: "First Name", section: "personal", colSpan: 6 })
    ),
    lastName: pipe(
      Schema.String,
      withField({ label: "Last Name", section: "personal", colSpan: 6 })
    ),
    email: pipe(
      Schema.String,
      withField({ label: "Email", section: "contact" })
    )
  }),
  withFormLayout({
    columns: 12,
    sections: [
      { id: "personal", title: "Personal Information", order: 1 },
      { id: "contact", title: "Contact Details", order: 2 }
    ]
  })
);

Collapsible Sections

Sections can be individually made collapsible via the SectionConfig, or all sections can be made collapsible via sectionVariant="collapsible" on SchemaForm.

withFormLayout({
  columns: 12,
  sections: [
    {
      id: "advanced",
      title: "Advanced Settings",
      collapsible: true,
      defaultCollapsed: true  // Starts collapsed
    },
    {
      id: "basic",
      title: "Basic Settings",
      collapsible: true,
      defaultCollapsed: false // Starts open
    }
  ]
})

Section Variants

<!-- Default: simple dividers -->
<SchemaForm {controller} sectionVariant="default" />

<!-- Card: each section in a card -->
<SchemaForm {controller} sectionVariant="card" />

<!-- Collapsible: accordion-style sections -->
<SchemaForm {controller} sectionVariant="collapsible" />

Responsive Column Spans

Use colSpanSm, colSpanMd, colSpanLg for responsive layouts. The grid must use enough columns (columns: 12 is common).

These use Tailwind CSS v4 container queries (@sm:, @md:, @lg:) scoped to the grid's own width — not the viewport. The grid div automatically receives @container so breakpoints respond to the form's rendered width regardless of where it is placed on the page.

withField({
  label: "Full Name",
  colSpan: "full",   // Full width (default / narrow container)
  colSpanMd: 6,      // Half width when container ≥ 768px
  colSpanLg: 4       // One-third when container ≥ 1024px
})

Breakpoint reference (container width, not viewport):

Annotation Class emitted Applies when container ≥
colSpanSm @sm:col-span-X 640px
colSpanMd @md:col-span-X 768px
colSpanLg @lg:col-span-X 1024px

Note: Column span classes are generated from static lookup maps, not dynamic string interpolation, so all classes are always included in the Tailwind CSS bundle.


Multi-Step Forms

Create wizard-style forms by defining steps:

const WizardSchema = pipe(
  Schema.Struct({
    // Step 1 fields
    email: pipe(
      Schema.String,
      withField({ label: "Email", step: 1, section: "account" })
    ),
    password: pipe(
      Schema.String,
      withField({ label: "Password", step: 1, section: "account" })
    ),

    // Step 2 fields
    firstName: pipe(
      Schema.String,
      withField({ label: "First Name", step: 2, section: "profile" })
    ),
    lastName: pipe(
      Schema.String,
      withField({ label: "Last Name", step: 2, section: "profile" })
    ),

    // Step 3 fields
    newsletter: pipe(
      Schema.Boolean,
      withField({ label: "Subscribe", step: 3, section: "preferences" })
    )
  }),
  withFormLayout({
    sections: [
      { id: "account", title: "Account" },
      { id: "profile", title: "Profile" },
      { id: "preferences", title: "Preferences" }
    ],
    steps: [
      { step: 1, title: "Account", description: "Create your login" },
      { step: 2, title: "Profile", description: "Personal details" },
      { step: 3, title: "Finish", description: "Preferences" }
    ]
  })
);

Step Navigation

<SchemaForm
  {controller}
  showStepIndicator={true}
  nextText="Continue"
  prevText="Go Back"
  submitText="Complete"
/>

Per-Step Validation

Each step is validated before proceeding to the next:

// Manually trigger step validation
const isValid = controller.validateStep();

// Manually navigate (nextStep validates current step first, returns false if invalid)
const advanced = controller.nextStep();
controller.prevStep();  // No validation needed
controller.goToStep(2); // Jump to specific step

Async Data Loading

Load options dynamically before creating the form:

// 1. Define a schema factory
function createFormSchema(options: FieldOption[]) {
  return pipe(
    Schema.Struct({
      category: pipe(
        Schema.String,
        withField({
          label: "Category",
          inputType: "select",
          options: options  // Dynamic options
        })
      )
    })
  );
}

// 2. Load data and create controller
const options = await fetchCategories();
const schema = createFormSchema(options);
const controller = new FormController(schema);

Complete Example

<script lang="ts">
  import { onMount } from "svelte";
  import { FormController, SchemaForm } from "@kareyes/aether/forms";

  let controller = $state(null);
  let loading = $state(true);

  onMount(async () => {
    const [categories, regions] = await Promise.all([
      fetchCategories(),
      fetchRegions()
    ]);

    const schema = createFormSchema(categories, regions);
    controller = new FormController(schema);
    loading = false;
  });
</script>

{#if loading}
  <Skeleton />
{:else if controller}
  <SchemaForm {controller} onSubmit={handleSubmit} />
{/if}

Validation

Schema-Level Validation

Use Effect Schema's built-in validators:

const schema = pipe(
  Schema.String,
  Schema.minLength(2),
  Schema.maxLength(100),
  Schema.pattern(/^[a-zA-Z]+$/),
  Schema.annotations({
    message: () => "Only letters allowed, 2-100 characters"
  })
);

Custom Error Messages

// Per-field message
const email = pipe(
  Schema.String,
  Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/),
  Schema.annotations({
    message: () => "Please enter a valid email address"
  })
);

// Refinement with message
const age = pipe(
  Schema.Number,
  Schema.filter(
    (n) => n >= 18,
    { message: () => "Must be 18 or older" }
  )
);

Required Checkboxes

For checkboxes that must be checked (e.g., "I agree to terms"):

import { RequiredCheckbox, requiredCheckbox } from "@kareyes/aether/forms";

// Default message: "This field must be checked"
acceptTerms: pipe(
  RequiredCheckbox,
  withField({ label: "I accept the terms", inputType: "checkbox" })
)

// Custom message
acceptPrivacy: pipe(
  requiredCheckbox("You must accept the privacy policy"),
  withField({ label: "I accept the privacy policy", inputType: "checkbox" })
)

Required Switches

For switches that must be enabled:

import { RequiredSwitch, requiredSwitch } from "@kareyes/aether/forms";

// Default message: "This field must be enabled"
notifications: pipe(
  RequiredSwitch,
  withField({ label: "Enable notifications", inputType: "switch" })
)

// Custom message
dataSharing: pipe(
  requiredSwitch("You must enable data sharing to continue"),
  withField({ label: "Allow data sharing", inputType: "switch" })
)

Required File Fields

File fields default to null when nothing is selected. Schema.Any accepts null, so it should only be used for optional file fields. Use RequiredFile or requiredFile() to enforce a selection:

import { RequiredFile, requiredFile } from "@kareyes/aether/forms";

// Required — default message: "Please select a file"
avatar: pipe(
  RequiredFile,
  withField({
    label: "Profile Photo",
    inputType: "file",
    accept: "image/png,image/jpeg",
    // fileMode defaults to "drag-drop"
  })
)

// Required — custom message
resume: pipe(
  requiredFile("Please upload your resume"),
  withField({
    label: "Resume",
    inputType: "file",
    fileMode: "regular",
    accept: ".pdf",
  })
)

// Optional — null (no file selected) is valid
attachments: pipe(
  Schema.Any,
  withField({
    label: "Attachments",
    inputType: "file",
    fileMode: "button-only",
    multiple: true,
    description: "Optional"
  })
)

RequiredFile checks value instanceof FileList && value.length > 0 with a typeof FileList !== "undefined" guard for SSR safety.

File fields store a FileList | null as their value. Upload the files inside onSubmit:

<SchemaForm
  {controller}
  onSubmit={async (data) => {
    const formData = new FormData();
    if (data.avatar) formData.append("avatar", data.avatar[0]);
    if (data.resume) formData.append("resume", data.resume[0]);
    await fetch("/api/upload", { method: "POST", body: formData });
  }}
/>

Server-Side Errors

Set errors from API responses:

async function handleSubmit(data) {
  try {
    await api.register(data);
  } catch (error) {
    if (error.code === "EMAIL_EXISTS") {
      controller.setFieldError("email", "This email is already registered");
    } else {
      controller.setErrors({
        email: error.errors?.email,
        password: error.errors?.password
      });
    }
    throw error; // Re-throw to prevent success state
  }
}

You can also throw a structured object from onSubmit to set a field error inline:

onSubmit={async (data) => {
  const res = await api.register(data);
  if (!res.ok) {
    throw { field: "email", message: "Email already taken" };
  }
}}

Advanced Usage

Custom Footer

<SchemaForm {controller} onSubmit={handleSubmit}>
  {#snippet footer({ isSubmitting, isValid, isFirstStep, isLastStep, handleSubmit, handleNext, handlePrev })}
    <div class="flex justify-between">
      <Button variant="outline" onclick={handlePrev} disabled={isFirstStep}>
        Back
      </Button>
      <div class="flex gap-2">
        <Button variant="outline" onclick={() => controller.reset()}>
          Reset
        </Button>
        <Button
          onclick={isLastStep ? handleSubmit : handleNext}
          disabled={isSubmitting || !isValid}
        >
          {isSubmitting ? "Saving..." : isLastStep ? "Save" : "Next"}
        </Button>
      </div>
    </div>
  {/snippet}
</SchemaForm>

Watching Values

<script>
  // React to value changes
  $effect(() => {
    const country = controller.state.values.country;
    if (country) {
      loadStatesForCountry(country);
    }
  });
</script>

<SchemaForm
  {controller}
  onChange={(values) => console.log("Values changed:", values)}
/>

Programmatic Control

// Set values programmatically
controller.setValue("email", "user@example.com");
controller.setValues({ firstName: "John", lastName: "Doe" });

// Validate
const result = controller.validate();
if (!result.valid) {
  console.log("Errors:", result.errors);
}

// Reset with new values
controller.reset({ email: "", password: "" });

// Set errors from external source
controller.setFieldError("email", "Already taken");
controller.setErrors({ email: "Already taken", username: "Already taken" });

Input Masks

Apply input masks using the mask annotation:

phone: pipe(
  Schema.String,
  withField({
    label: "Phone Number",
    inputType: "tel",
    mask: "phone",  // Built-in: (555) 123-4567
    placeholder: "(555) 123-4567"
  })
)

Low-Level Components

The form system exposes its internal rendering components for advanced customisation:

import {
  SchemaField,    // Renders a single field based on FieldRenderContext
  SchemaSection,  // Renders a section (grid of fields) based on SectionRenderContext
  SchemaStep      // Renders a step (collection of sections) based on StepRenderContext
} from "@kareyes/aether/forms";

You can also build render contexts manually to drive custom layouts:

import {
  createFieldContext,
  createSectionContext,
  createStepContext
} from "@kareyes/aether/forms";

const sectionCtx = createSectionContext(controller, section);
const fieldCtx   = createFieldContext(controller, field);

Standalone Validation Utilities

These can be used independently of the form components:

import {
  validateSync,       // Validate data against schema, returns ValidationResult
  validate,           // Same but returns an Effect
  validateField,      // Validate a single value against a schema
  validateFields,     // Validate multiple fields, returns combined FieldErrors
  createFieldValidator, // Create a reusable validator for a specific field
  validateAsync,      // Async wrapper (for future async validators)
  hasErrors,          // Check if a FieldErrors object has any errors
  getFirstError       // Get the first error message from FieldErrors
} from "@kareyes/aether/forms";

// Standalone sync validation
const result = validateSync(UserSchema, formData);
if (result.valid) {
  console.log(result.data);   // Typed, validated data
} else {
  console.log(result.errors); // { fieldName: "error message" }
}

// Effect-based validation (composable with other Effects)
import { pipe } from "effect";
import { Effect } from "effect";
const program = pipe(
  validate(UserSchema, formData),
  Effect.flatMap((validData) => registerUser(validData))
);

// Per-field validator factory
const validateEmail = createFieldValidator(UserSchema, "email");
const error = validateEmail(emailValue); // string | undefined

// Validate specific fields only
const errors = validateFields(UserSchema, { email: emailValue, password: pwValue });

Troubleshooting

Common Issues

1. Checkbox shows "Invalid value" instead of custom error

Use RequiredCheckbox or requiredCheckbox() instead of Schema.Boolean:

// ❌ Wrong - "false" is a valid boolean
acceptTerms: Schema.Boolean

// ✅ Correct - requires value to be true
acceptTerms: RequiredCheckbox

2. Switch shows "Invalid value" instead of custom error

Same pattern — use RequiredSwitch or requiredSwitch():

// ❌ Wrong
notifications: Schema.Boolean

// ✅ Correct
notifications: RequiredSwitch

3. File input passes validation even when empty

Schema.Any accepts null, so a file field using it will always pass — even when nothing is selected. Use RequiredFile or requiredFile() for required file fields:

// ❌ Wrong - null (no file) passes validation
avatar: pipe(Schema.Any, withField({ inputType: "file" }))

// ✅ Correct - requires a file to be selected
avatar: pipe(RequiredFile, withField({ inputType: "file" }))

// ✅ Correct - custom error message
avatar: pipe(requiredFile("Please upload a photo"), withField({ inputType: "file" }))

4. col-span / grid columns not applying

Dynamic Tailwind class names built with string interpolation (e.g. `col-span-${n}`) are purged at build time. This system avoids that by using static lookup maps, so all span classes (col-span-1 through col-span-12, col-span-full, and container-query responsive variants @sm: / @md: / @lg:) are always in the CSS bundle.

The grid div automatically includes @container so container queries are always active — no external @container wrapper is needed. If you extend ColumnSpan or add custom breakpoints, add the new literals to the corresponding lookup map in layout.ts.

5. Infinite loop on form load

Ensure you're not tracking formState.values directly in effects:

// ❌ Can cause loops
$effect(() => {
  console.log(formState.values);
});

// ✅ Safe - track specific values
$effect(() => {
  const email = formState.values.email;
  // ...
});

6. Input mask not working

The mask is applied in the Input component. Ensure you're passing it through:

withField({
  inputType: "tel",
  mask: "phone"  // Must specify mask
})

7. Select options not showing

Ensure options are provided in the annotation:

withField({
  inputType: "select",
  options: [
    { value: "a", label: "Option A" },
    { value: "b", label: "Option B" }
  ]
})

8. Validation not triggering

Check your FormController configuration:

const controller = new FormController(schema, {
  validateOnBlur: true,   // Validate when field loses focus
  validateOnChange: true  // Validate on every keystroke
});

Debug Mode

Add console logging to track form state:

<SchemaForm
  {controller}
  onChange={(values) => console.log("Values:", values)}
  onError={(errors) => console.log("Errors:", errors)}
/>

Examples

For complete working examples, see:

  • Basic Form: /form-schema - Simple login form
  • Multi-Section: /form-schema - Profile form with multiple sections (card variant)
  • Multi-Step: /form-schema - User registration wizard
  • Input Types: /form-schema - All supported input types showcase
  • Validation: /form-schema - Schema-level validation with custom messages

TypeScript Support

Full type inference from schema:

const UserSchema = Schema.Struct({
  name: Schema.String,
  age: Schema.Number,
  active: Schema.Boolean
});

// Inferred type
type User = Schema.Schema.Type<typeof UserSchema>;
// { name: string; age: number; active: boolean }

// Input type (for partial form state)
type UserInput = Schema.Schema.Encoded<typeof UserSchema>;

// Use in handler with full type safety
async function handleSubmit(data: User) {
  // data.name is string
  // data.age is number
  // data.active is boolean
}