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:
Approach Pros Cons Relational tables SQL queries per field, strong typing Complex joins, migrations for every change JSON columns Flexible, versionable, fast reads No field-level SQL queries External CMS UI for non-developers Another service, sync complexity
We chose JSON for several reasons:
Schema changes don’t require migrations — add a new field type by updating TypeScript, not SQL
Conditional logic fits naturally — expression trees nest inside the form tree
Export/import is trivial — the form is a single JSON document
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.
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.
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:
Define the schema in src/types/schema.ts:
export const RatingFieldSchema = BaseElementSchema . extend ({
type: z . literal ( "rating" ),
maxStars: z . number (). default ( 5 ),
});
Add to the union :
export type FormElement =
| TextField
| ...
| RatingField ; // Add here
Add to FormElementSchema :
export const FormElementSchema = z . lazy (() =>
z . union ([
TextFieldSchema ,
...
RatingFieldSchema , // Add here
])
);
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 >
);
}
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