Core concepts / Schema anatomy

Schema anatomy

The schema is a single JSON object that describes the entire form — fields, sections, validation, conditions, and submission. This page documents every property.

The root object

type FormEngineSchema = {  id: string                    // Unique form identifier  version: string               // Schema version — sent with every submission  title: string                 // Form title displayed at the top  description?: string          // Optional subtitle  branding?: BrandingConfig  settings?: FormSettings  sections: Section[]           // At least one required  submitAction: SubmitAction  onComplete?: CompleteAction}

id and version are included in every FormResponse sent to your backend. They let you associate submissions with forms and handle schema evolution — when you add a field next month, version tells your backend which fields to expect.

The engine uses id + sessionToken to scope draft storage. Different forms never collide.

Sections

type Section = {  id: string                        // Unique within the schema  title: string                     // Section heading  description?: string              // Optional subheading  showIf?: ConditionExpression      // Conditional visibility  questions: Question[]             // At least one required  onExit?: SectionExitAction        // Jump rules on leaving this section}

A section is a "page" in a multi-step form. In stepped mode, one section is visible at a time. In classic mode, all visible sections render on a single scrollable page.

When showIf evaluates to false, the section disappears from navigation. Its fields are excluded from validation and submission.

Jump rules

type SectionExitAction = {  rules: JumpRule[]          // Ordered — first match wins  default?: string           // Fallback section ID}type JumpRule = {  condition: ConditionExpression  jumpTo: string             // Target section ID}

When the user clicks "Next", the engine checks onExit.rules in order. The first rule whose condition is true determines the next section. If no rule matches, onExit.default is used. If neither exists, the next section in array order is shown.

Questions

type Question = {  id: string                                  // Unique across the entire schema  type: QuestionType                          // "short_text", "email", "rating", etc.  label: string                               // The question text  helpText?: string                           // Hint text below the field  placeholder?: string                        // Input placeholder  required?: boolean | ConditionExpression    // Static or conditional  showIf?: ConditionExpression                // Conditional visibility  disabled?: boolean | ConditionExpression    // Static or conditional  validation?: ValidationRule[]               // Validation rules  prefillKey?: string                         // Maps external data to this field  config?: QuestionConfig                     // Type-specific configuration  options?: Option[]                          // For select/dropdown/ranking types  layout?: QuestionLayout                     // Width, columns, custom CSS  customProps?: Record<string, unknown>       // Escape hatch for custom renderers}

Conditional properties

required, showIf, and disabled all accept boolean | ConditionExpression. This means a field can be:

// Always requiredrequired: true// Never requiredrequired: false// Conditionally required — only when another field has a specific valuerequired: { field: 'contact_method', operator: 'eq', value: 'email' }

Field IDs must be globally unique

The engine uses id as the key in state.values, state.errors, and state.touched. If two fields in different sections share an ID, they overwrite each other. The schema validator catches this:

FormEngineSchemaError: Invalid FormEngine schema:  - Duplicate question ID: "email"

Type-specific config

Every field type can have a config object. Its shape depends on type. A slider needs min, max, step. An email field doesn't. This keeps the generic Question type clean while allowing type-specific options.

See Field types for every config shape.

Options

For choice-based fields (single_select, multi_select, dropdown, ranking):

type Option = {  label: string                          // Display text  value: string | number | boolean       // Stored value  helpText?: string                      // Tooltip  icon?: string                          // Icon identifier  exclusive?: boolean                    // "None of the above" behaviour}

exclusive: true creates a mutually exclusive option. In a multi_select, checking an exclusive option unchecks everything else, and checking anything else unchecks the exclusive option.

options: [  { label: 'Headache', value: 'headache' },  { label: 'Fatigue', value: 'fatigue' },  { label: 'Nausea', value: 'nausea' },  { label: 'None of the above', value: 'none', exclusive: true },]

Settings

type FormSettings = {  displayMode?: 'classic' | 'stepped' | 'conversational'  allowDraftSave?: boolean  draftStorage?: 'local' | 'server' | 'both'  draftTtlHours?: number                    // Default: 72  showProgress?: boolean  progressStyle?: 'bar' | 'steps' | 'percentage'  prefill?: PrefillConfig  noPiiInLogs?: boolean  locale?: string  serverUrl?: string  submitButton?: {    label?: string    loadingLabel?: string    successLabel?: string  }  navigation?: {    showBack?: boolean    showSectionList?: boolean    nextLabel?: string    backLabel?: string    allowSkip?: boolean  }}
ModeBehaviour
classicAll sections on one scrollable page. No navigation buttons.
steppedOne section at a time. Next/Back buttons. Progress indicator.
conversationalOne question at a time. Typeform-style.

noPiiInLogs: true prevents the engine from logging field values to the console, even in development. Required for HIPAA-compliant forms.

Submit and complete actions

type SubmitAction = {  type: 'http' | 'callback' | 'adapter'  url?: string  method?: 'POST' | 'PUT' | 'PATCH'  headers?: Record<string, string>}
TypeWhat happens
httpEngine POSTs a FormResponse to url with optional headers
callbackEngine calls the onSubmit callback passed to the renderer
adapterEngine routes through registered SubmitAdapter instances
type CompleteAction = {  type: 'redirect' | 'message' | 'callback'  url?: string  message?: string  showSummary?: boolean}

Branding

type BrandingConfig = {  logoUrl?: string  logoAlt?: string  faviconUrl?: string  poweredBy?: boolean          // "Powered by FieldCraft" badge — default true}

Layout

type QuestionLayout = {  width?: 'full' | 'half' | 'third'  columns?: number                     // For option groups (2/3/4 columns)  className?: string                   // Custom CSS class (use fc- prefix)  style?: Record<string, string>       // Inline styles}

width: 'half' puts two fields side by side. width: 'third' fits three across. The renderer maps these to CSS grid classes.

FormState — the runtime snapshot

When the engine processes a schema, it creates a FormState — the live snapshot of everything happening in the form.

type FormState = {  // Field data  values: Record<string, unknown>  errors: Record<string, string[]>  touched: Record<string, boolean>  isDirty: boolean  // Submission  isSubmitting: boolean  isSubmitted: boolean  submitError?: string  submitAttempted: boolean  // Navigation  currentSectionId: string  currentSectionIndex: number  totalVisibleSections: number  progressPercent: number                // 0–100  visibleSectionIds: string[]  visitedSectionIds: string[]  canGoNext: boolean  canGoPrev: boolean  isCurrentSectionValid: boolean  // Scoring  scores: Record<string, number>  totalScore?: number  // Drafts  hasDraft: boolean  lastDraftSavedAt?: string}

React components never write to FormState directly. They call engine methods (setValue, nextSection, etc.) and subscribe to state changes via hooks.

FormResponse — what leaves the form

When the user submits, the engine builds a FormResponse:

type FormResponse = {  schemaId: string  schemaVersion: string  submittedAt: string               // ISO timestamp  sessionToken: string              // UUID generated at engine creation  values: Record<string, unknown>  scores?: Record<string, number>  totalScore?: number  metadata?: Record<string, unknown>  completionTimeMs?: number         // Time from engine creation to submit}

completionTimeMs is useful for bot detection (a form completed in 500ms wasn't filled out by a human) and analytics.

Next steps