@formloom/schema
v0.4.0
Published
Schema spec and validator for Formloom - LLM-driven dynamic form generation
Maintainers
Readme
@formloom/schema
The core schema specification for Formloom. TypeScript types, constants, and a validator. Zero runtime dependencies.
Installation
npm install @formloom/schemaSchema Spec
A Formloom schema is a JSON object with this shape:
{
"version": "1.2",
"title": "Job Application",
"description": "Tell us a bit about yourself",
"submitLabel": "Submit",
"fields": [
{
"id": "email",
"type": "text",
"label": "Email Address",
"placeholder": "[email protected]",
"validation": {
"required": true,
"pattern": "^[^@]+@[^@]+\\.[^@]+$",
"patternMessage": "Must be a valid email"
}
},
{
"id": "years_experience",
"type": "number",
"label": "Years of experience",
"validation": { "min": 0, "max": 60, "integer": true, "required": true }
},
{
"id": "employment_type",
"type": "radio",
"label": "Employment type",
"options": [
{ "value": "full_time", "label": "Full-time" },
{ "value": "contract", "label": "Contract" }
],
"validation": { "required": true }
},
{
"id": "day_rate",
"type": "number",
"label": "Day rate (USD)",
"showIf": { "field": "employment_type", "equals": "contract" }
},
{
"id": "resume",
"type": "file",
"label": "Resume",
"accept": "application/pdf",
"maxSizeBytes": 2000000,
"validation": { "required": true }
}
],
"sections": [
{ "id": "details", "title": "Details", "fieldIds": ["email", "years_experience"] },
{ "id": "role", "title": "Role", "fieldIds": ["employment_type", "day_rate"] },
{ "id": "docs", "title": "Documents", "fieldIds": ["resume"] }
]
}Field types
These are rendering primitives, not semantic types. A text field with an email regex IS an email field.
| Type | Description | Extra properties |
|------|-------------|------------------|
| text | Single-line text input | placeholder, defaultValue (string) |
| boolean | Yes/no toggle | defaultValue (boolean) |
| radio | Pick one from options | options (required), defaultValue (string) |
| select | Dropdown or multi-select | options (required), multiple, placeholder, defaultValue (string / string[]) |
| date | Date picker | placeholder, defaultValue (ISO 8601 string) |
| number | Numeric input | placeholder, defaultValue (number), validation: min, max, step, integer |
| file | File upload | accept (MIME globs), maxSizeBytes, multiple |
Validation rules
| Property | Type | Applies to | Description |
|----------|------|------------|-------------|
| required | boolean | all | Field must be filled |
| pattern | string | text, date | Regex (no delimiters). Catastrophic patterns are rejected. |
| patternMessage | string | text, date | Error message on pattern failure |
| min, max | number | number | Inclusive range |
| step | number | number | Granularity; must be > 0 |
| integer | boolean | number | Reject non-integers |
Conditional visibility (showIf)
Attach a showIf rule to any field. Hidden fields are excluded from submitted data.
// leaf rules
{ "field": "country", "equals": "US" }
{ "field": "plan", "in": ["pro", "enterprise"] }
{ "field": "coupon", "notEmpty": true }
// composites
{ "allOf": [ ... ] }
{ "anyOf": [ ... ] }
{ "not": { ... } }Dependency cycles and references to unknown fields are caught at validation time.
The package exports three helpers so custom renderers and servers can share the same semantics as the React hook:
import {
evaluateShowIf,
collectShowIfDependencies,
findShowIfCycle,
} from "@formloom/schema";
evaluateShowIf(field.showIf, currentData); // boolean
collectShowIfDependencies(field.showIf); // Set<string> of referenced field ids
findShowIfCycle(schema.fields); // string[] (e.g. ["a","b","a"]) or nullSections
Optional top-level grouping. When sections is present, every field id must belong to exactly one section.
"sections": [
{ "id": "personal", "title": "Personal", "fieldIds": ["name", "email"] },
{ "id": "employment", "fieldIds": ["role", "start_date"] }
]Rendering hints
Hints are advisory — renderers honour what they know and ignore the rest. The canonical registry is exported as CANONICAL_HINTS:
| Key | Values | Meaning |
|-----|--------|---------|
| display | "textarea", "password", "toggle", "stepper" | Ask for a non-default widget |
| width | "full", "half", "third" | Layout hint |
| rows | integer | Textarea row count |
| autocomplete | HTML autocomplete token | Browser autofill hint |
Unknown hints pass through, so you can extend without a version bump.
Validator
import { validateSchema } from "@formloom/schema";
const result = validateSchema(maybeSchema);
if (result.valid) {
// result.errors is []; result.warnings may contain non-fatal notices
} else {
// result.errors is ValidationError[] with { path, message }
console.log(result.errors);
}Collects all errors rather than failing on the first one.
If you prefer a throw-on-invalid style, wrap the result:
import { validateSchema, FormloomValidationError } from "@formloom/schema";
const result = validateSchema(maybeSchema);
if (!result.valid) throw new FormloomValidationError(result.errors);FormloomValidationError extends Error with an errors: ValidationError[] property and a pre-formatted message listing every issue.
Forward compatibility
A v1.5 schema emitted by a newer LLM may contain a field type this runtime doesn't know. Control the behaviour via the forwardCompat option:
validateSchema(schema, { forwardCompat: "lenient" });
// → unknown field types are dropped (with a warning) and listed in result.droppedFields
// → `"strict"` (default) errors outCapabilities (v1.3)
Pass a FormloomCapabilities object to restrict what the schema may contain on a given surface. Disallowed field types and features become validation errors in strict mode or silent drops with warnings in lenient mode (same knob as forwardCompat).
validateSchema(schema, {
capabilities: {
fieldTypes: ["text", "select", "boolean"], // no file, number, date, radio
features: { showIf: false }, // reject conditional fields
variants: ["combobox"], // hints.variant allowlist
maxFields: 7,
},
forwardCompat: "strict",
});Omit any key to allow it (omit = permissive). The error paths match the existing convention (fields[2].type, fields[0].hints.variant, etc.) so surfaces are greppable. For the ergonomic path — narrowing the system prompt and tool JSON Schema from the same declaration — use createFormloomCapabilities in @formloom/llm.
Versioning
Any 1.x version string is accepted. Export constants:
FORMLOOM_SCHEMA_VERSION // "1.2"
FORMLOOM_MIN_SUPPORTED_VERSION // "1.0"
FIELD_TYPES // readonly ["text","boolean","radio","select","date","number","file"]Helpers for working with version strings:
import {
parseSchemaVersion,
isSupportedVersion,
compareVersions,
} from "@formloom/schema";
parseSchemaVersion("1.2"); // { major: 1, minor: 2 }
parseSchemaVersion("not-a-ver"); // null — malformed input returns null instead of throwing
isSupportedVersion("1.3", 1); // true — same major
isSupportedVersion("2.0", 1); // false — different major
compareVersions({ major: 1, minor: 2 }, { major: 1, minor: 0 }); // positive: a > bSafe regex
LLM-authored regex patterns are passed to safeRegexTest, which detects catastrophic shapes (nested quantifiers, overlapping alternations, backrefs) and caps input length. It never hangs and never throws.
import {
safeRegexTest,
isCatastrophicPattern,
isValidRegexSyntax,
DEFAULT_MAX_INPUT_LENGTH,
} from "@formloom/schema";
const result = safeRegexTest(pattern, value);
// { matched: boolean, skipped: boolean, reason?: string }
//
// skipped = true when:
// - the pattern is syntactically invalid
// - the pattern matches a known catastrophic shape
// - the input exceeds DEFAULT_MAX_INPUT_LENGTH (10 KB by default)
isCatastrophicPattern("(a+)+"); // true
isValidRegexSyntax("^[a-z]+$"); // truePass { maxInputLength } as the third argument to change the cap per-call.
File matching
Every layer of Formloom (validator, React hook, Zod adapter) uses the same accept-string matcher, so behaviour is identical across the stack.
import { fileMatchesAccept, mimeMatches } from "@formloom/schema";
fileMatchesAccept("image/*,.pdf", "image/png", "photo.png"); // true
fileMatchesAccept("image/*,.pdf", "application/pdf", "cv.pdf"); // true (via .pdf)
// mimeMatches is MIME-only — extension tokens are ignored because
// there is no filename context. Prefer fileMatchesAccept when you have a name.
mimeMatches("image/png", "image/*"); // true
mimeMatches("application/pdf", ".pdf"); // falseTypeScript types
import type {
// Schema shape
FormloomSchema,
Section,
FieldType,
BaseField,
FormField,
TextField, BooleanField, RadioField, SelectField,
DateField, NumberField, FileField,
// Values
FormloomData, FormloomFieldValue, FormloomFileValue,
// Per-field bits
FieldOption, ValidationRule, NumberValidationRule,
RenderHints,
CanonicalHints, CanonicalHintEntry, CanonicalDisplayHint, CanonicalWidthHint,
FieldHints,
ShowIfRule,
// Capabilities (v1.3)
FormloomCapabilities, ResolvedFeatures,
// Validator
ValidationResult, ValidationError, ValidationWarning, ValidateOptions,
// Version helpers
ParsedVersion,
// Safe regex
SafeRegexOptions, SafeRegexResult,
} from "@formloom/schema";What's new in v1.2
FieldOption.description— optional one-line sub-label for radio/select options. Turns single-word labels into two-line choices without bloating the label itself.allowCustomon radio/select — opt-in "Other…" freeform input. Whentrue, options are treated as suggestions; the submitted value may be any string (or anystring[]formultiple: true). Pattern validation still applies.- Paired with
customLabel(default"Other") andcustomPlaceholderfor the freeform input. - Helpers:
resolveMultiSelectValue(field, values)splits a submitted array into{ selected, custom };isRadioCustomValue(field, value)identifies a freeform radio pick.
- Paired with
hints.variant— opaque host-defined widget key. The sanctioned extension point for custom field types (see below).readOnly/disabledon any field — presentation flags passed straight through to renderers. Read@formloom/react's hook-level options for the full story.
Custom field variants
When a host ships a specialized widget that's shaped exactly like an existing primitive — a multi-select with search, a tool picker, an agent autocomplete — use hints.variant instead of forging a new field type. The schema stays canonical; the renderer keys off the variant.
{
"id": "tools",
"type": "select",
"label": "Tools to enable",
"multiple": true,
"options": [
{ "value": "jira", "label": "Jira" },
{ "value": "linear", "label": "Linear" }
],
"hints": { "variant": "tool-select" }
}Host renderer:
function FieldRenderer({ field, state, onChange }: FieldProps) {
if (field.type === "select" && field.hints?.variant === "tool-select") {
return <ToolSelect field={field} value={state.value} onChange={onChange} />;
}
// …fall through to default widgets
}The schema validator accepts any string for variant and leaves the meaning to the host. Hosts can also declaration-merge FieldHints to get typed access:
declare module "@formloom/schema" {
interface FieldHints {
variant?: "tool-select" | "agent-picker" | "combobox";
}
}License
MIT
