Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs-terra.withunify.org/llms.txt

Use this file to discover all available pages before exploring further.

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.

Logic Engine

How visibility rules are evaluated

Field Types

Complete reference for all 18+ field types