Skip to main content

Schema Design

Forms are trees, not tables. This design decision shapes everything.
Government benefit forms have deeply nested conditional logic: “If you have dependents, list them. If any dependent has a disability, show the accommodation questions.” Terra represents forms as recursive JSON trees stored in PostgreSQL JSONB columns, validated at runtime by Zod schemas.

Why JSON Trees?

We considered three approaches: We chose JSON for several reasons:
  1. Schema changes don’t require migrations — add a new field type by updating TypeScript, not SQL
  2. Conditional logic fits naturally — expression trees nest inside the form tree
  3. Export/import is trivial — the form is a single JSON document
  4. Versioning is simple — store draft and published schemas side by side
The tradeoff: you can’t query individual fields with SQL. Reporting requires parsing JSON. But for our use case—rendering forms and processing submissions—the tree structure is a perfect fit.

The FormElement Type

Every field in a form is a FormElement. This is a discriminated union—TypeScript’s way of representing “one of these types”:
The type property discriminates which variant you have:

Base Element Properties

Every element shares these properties:

I18nString: Multi-Language by Design

Every user-facing string is an I18nString—a map from locale codes to translated text:
This isn’t a bolted-on translation layer. Multi-language is in the data model from the start.

Validation Rules

Field-level validation with localized error messages:

Field Types Deep Dive

Text Fields

The inputType maps to HTML input types, enabling mobile keyboards (email shows @, tel shows numpad).

Choice Fields

One schema handles radios, checkboxes, and dropdowns:

Address Fields (Composite)

Address is a composite field with built-in Smarty Streets integration:
When rendered, this expands to multiple inputs (street, city, state, zip) with optional autocomplete.

Group Fields (Recursive)

Groups contain other elements, enabling nesting:
This is how conditional sections work: a group with visibility logic.

Repeated Fields

For “Add another household member” patterns:
Each item in the repeated group gets its own copy of the template elements.

Form Structure

Forms contain pages, pages contain elements:
A complete form schema looks like:

Zod Runtime Validation

The schema types aren’t just for TypeScript—they validate at runtime:
This catches:
  • Missing required fields
  • Wrong types (string instead of array)
  • Invalid enum values
  • Malformed nested structures

Database Storage

The forms table stores schemas as JSONB:
Draft vs Published: Admins edit draft_schema. Publishing copies draft to published_schema. This enables preview without affecting live forms.

Extending the Schema

To add a new field type:
  1. Define the schema in src/types/schema.ts:
  2. Add to the union:
  3. Add to FormElementSchema:
  4. Create the renderer in src/components/engine/fields/:
  5. Register in the registry:
No migrations. No database changes. The new field type works immediately.

Trade-offs

What Works Well

  • Rapid iteration — new field types in minutes
  • Complex nesting — groups and repeats compose naturally
  • Portable — export/import as JSON files
  • Version control — JSON diffs are readable

What’s Harder

  • Reporting — can’t SELECT a specific field across forms
  • Large schemas — entire form loaded for every operation
  • Migrations — need custom code to transform existing schemas
For our use case (rendering forms, processing submissions), the benefits far outweigh the costs.

Logic Engine

How visibility rules are evaluated

Field Types

Complete reference for all 18+ field types