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 |
- 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 FormElement Type
Every field in a form is aFormElement. This is a discriminated union—TypeScript’s way of representing “one of these types”:
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 anI18nString—a map from locale codes to translated text:
Validation Rules
Field-level validation with localized error messages:Field Types Deep Dive
Text Fields
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:Group Fields (Recursive)
Groups contain other elements, enabling nesting:Repeated Fields
For “Add another household member” patterns:Form Structure
Forms contain pages, pages contain elements:Zod Runtime Validation
The schema types aren’t just for TypeScript—they validate at runtime:- Missing required fields
- Wrong types (string instead of array)
- Invalid enum values
- Malformed nested structures
Database Storage
Theforms table stores schemas as JSONB:
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: -
Add to the union:
-
Add to FormElementSchema:
-
Create the renderer in
src/components/engine/fields/: -
Register in the registry:
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
SELECTa specific field across forms - Large schemas — entire form loaded for every operation
- Migrations — need custom code to transform existing schemas