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:
ApproachProsCons
Relational tablesSQL queries per field, strong typingComplex joins, migrations for every change
JSON columnsFlexible, versionable, fast readsNo field-level SQL queries
External CMSUI for non-developersAnother service, sync complexity
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”:
// src/types/schema.ts
export type FormElement =
  | TextField
  | NumberField
  | DateField
  | ChoiceField
  | AddressField
  | FilesField
  | InfoField
  | SectionHeader
  | DividerField
  | BankField
  | PlaidIdVerificationField
  | PlaidBankVerificationField
  | SignatureField
  | ImageField
  | LinkField
  | StatementField
  | LanguagePreferenceField
  | GroupField      // Contains nested elements (recursive!)
  | RepeatedField;  // Repeatable group with min/max
The type property discriminates which variant you have:
function renderField(element: FormElement) {
  switch (element.type) {
    case "text":
      return <TextInput {...element} />;
    case "choice":
      return <ChoiceInput {...element} />;
    case "group":
      // Recursive! Groups contain elements
      return <Group>{element.elements.map(renderField)}</Group>;
    // ...
  }
}

Base Element Properties

Every element shares these properties:
const BaseElementSchema = z.object({
  id: z.string(),                        // Unique identifier
  label: I18nStringSchema,               // Display label (multi-language)
  helpText: I18nStringSchema.optional(), // Helper text below field
  visibility: VisibilityRuleSchema.optional(), // @deprecated
  logic: LogicConditionSchema.optional(),      // Expression tree
  logicAction: z.enum(["show", "hide"]).optional(),
  validation: ValidationRuleSchema.optional(),
  layout: LayoutOptionsSchema.optional(),
});

I18nString: Multi-Language by Design

Every user-facing string is an I18nString—a map from locale codes to translated text:
export type I18nString = Record<string, string>;

// Example
const label: I18nString = {
  en: "Full Name",
  es: "Nombre Completo",
  zh: "全名",
};
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:
const ValidationRuleSchema = z.object({
  required: z.boolean().optional(),
  minLength: z.number().optional(),
  maxLength: z.number().optional(),
  min: z.number().optional(),
  max: z.number().optional(),
  pattern: z.string().optional(),          // Regex
  message: I18nStringSchema.optional(),    // Custom error message
  numberType: z.enum([
    "any",       // Any number
    "whole",     // 0, 1, 2, 3...
    "integer",   // ...-2, -1, 0, 1, 2...
    "decimal",   // 1.5, 3.14, -2.7
    "monetary",  // 1234.56 (2 decimal places)
  ]).optional(),
  requireMatch: z.boolean().optional(),    // Double-entry confirmation
  minAge: z.number().optional(),           // For date fields
  maxAge: z.number().optional(),
});

Field Types Deep Dive

Text Fields

const TextFieldSchema = BaseElementSchema.extend({
  type: z.literal("text"),
  placeholder: I18nStringSchema.optional(),
  inputType: z.enum(["text", "email", "tel", "url"]).optional(),
  multiline: z.boolean().optional(), // textarea vs input
});
The inputType maps to HTML input types, enabling mobile keyboards (email shows @, tel shows numpad).

Choice Fields

One schema handles radios, checkboxes, and dropdowns:
const ChoiceFieldSchema = BaseElementSchema.extend({
  type: z.literal("choice"),
  options: z.array(z.object({
    value: z.string(),
    label: I18nStringSchema,
  })),
  multiple: z.boolean().optional(),        // checkbox vs radio
  optionLayout: z.enum(["vertical", "horizontal"]).optional(),
  variant: z.enum(["list", "cards"]).optional(),  // cards = large buttons
  renderAs: z.enum(["radio", "checkbox", "dropdown"]).optional(),
});

Address Fields (Composite)

Address is a composite field with built-in Smarty Streets integration:
const AddressFieldSchema = BaseElementSchema.extend({
  type: z.literal("address"),
  includeUnit: z.boolean().optional(),
});
When rendered, this expands to multiple inputs (street, city, state, zip) with optional autocomplete.

Group Fields (Recursive)

Groups contain other elements, enabling nesting:
export type GroupField = BaseElement & {
  type: "group";
  elements: FormElement[];  // Recursive!
  collapsible?: boolean;
};
This is how conditional sections work: a group with visibility logic.

Repeated Fields

For “Add another household member” patterns:
export type RepeatedField = BaseElement & {
  type: "repeated";
  elements: FormElement[];      // Template for each item
  buttonLabel: I18nString;      // "Add Member"
  itemTitle: I18nString;        // "Household Member"
  min: number;                  // Minimum required
  max: number;                  // Maximum allowed
  summaryFields?: string[];     // Fields to show in collapsed view
};
Each item in the repeated group gets its own copy of the template elements.

Form Structure

Forms contain pages, pages contain elements:
const FormPageSchema = z.object({
  id: z.string(),
  title: I18nStringSchema.optional(),
  description: I18nStringSchema.optional(),
  elements: z.array(FormElementSchema),
});

const FormSchemaDefinition = z.object({
  id: z.string(),
  version: z.string().optional(),
  title: I18nStringSchema,
  description: I18nStringSchema.optional(),
  pages: z.array(FormPageSchema),
});
A complete form schema looks like:
{
  "id": "rental-assistance",
  "version": "2.0",
  "title": { "en": "Rental Assistance Application" },
  "pages": [
    {
      "id": "personal",
      "title": { "en": "Personal Information" },
      "elements": [
        {
          "id": "full-name",
          "type": "text",
          "label": { "en": "Full Name" },
          "validation": { "required": true }
        },
        {
          "id": "dob",
          "type": "date",
          "label": { "en": "Date of Birth" },
          "validation": { "required": true, "minAge": 18 }
        }
      ]
    },
    {
      "id": "household",
      "title": { "en": "Household Members" },
      "elements": [
        {
          "id": "has-dependents",
          "type": "choice",
          "label": { "en": "Do you have dependents?" },
          "options": [
            { "value": "yes", "label": { "en": "Yes" } },
            { "value": "no", "label": { "en": "No" } }
          ]
        },
        {
          "id": "dependents",
          "type": "repeated",
          "label": { "en": "Dependents" },
          "logic": {
            "id": "show-dependents",
            "type": "rule",
            "fieldId": "has-dependents",
            "operator": "eq",
            "value": "yes"
          },
          "buttonLabel": { "en": "Add Dependent" },
          "itemTitle": { "en": "Dependent" },
          "min": 1,
          "max": 10,
          "elements": [
            {
              "id": "name",
              "type": "text",
              "label": { "en": "Name" }
            },
            {
              "id": "relationship",
              "type": "choice",
              "label": { "en": "Relationship" },
              "options": [
                { "value": "child", "label": { "en": "Child" } },
                { "value": "spouse", "label": { "en": "Spouse" } }
              ]
            }
          ]
        }
      ]
    }
  ]
}

Zod Runtime Validation

The schema types aren’t just for TypeScript—they validate at runtime:
import { FormSchemaDefinition } from "@/types/schema";

// Parse and validate JSON from database
function loadFormSchema(json: unknown): FormSchema {
  return FormSchemaDefinition.parse(json);
}

// This throws if the JSON doesn't match the schema
try {
  const schema = loadFormSchema(databaseRow.draft_schema);
} catch (error) {
  if (error instanceof z.ZodError) {
    console.error("Invalid schema:", error.issues);
  }
}
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:
CREATE TABLE forms (
  id UUID PRIMARY KEY,
  slug TEXT UNIQUE NOT NULL,
  status TEXT NOT NULL DEFAULT 'draft',

  -- Schema storage
  draft_schema JSONB,      -- Current working version
  published_schema JSONB,  -- What applicants see

  -- Metadata
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);
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:
    export const RatingFieldSchema = BaseElementSchema.extend({
      type: z.literal("rating"),
      maxStars: z.number().default(5),
    });
    
  2. Add to the union:
    export type FormElement =
      | TextField
      | ...
      | RatingField;  // Add here
    
  3. Add to FormElementSchema:
    export const FormElementSchema = z.lazy(() =>
      z.union([
        TextFieldSchema,
        ...
        RatingFieldSchema,  // Add here
      ])
    );
    
  4. Create the renderer in src/components/engine/fields/:
    export function RatingField({ element, value, onChange }) {
      return (
        <div className="flex gap-1">
          {Array.from({ length: element.maxStars }).map((_, i) => (
            <Star
              key={i}
              filled={i < value}
              onClick={() => onChange(i + 1)}
            />
          ))}
        </div>
      );
    }
    
  5. Register in the registry:
    // src/components/engine/registry.tsx
    export const fieldRenderers: Record<string, FieldRenderer> = {
      text: TextFieldRenderer,
      ...
      rating: RatingFieldRenderer,
    };
    
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.