Field Model
The Form Config v1 work (plan) is expanding the
fields: entry in the Config Schema from the original
four-property shape (name, required, type) into a richer field object.
Phase 1 landed the parser and structural-validation side of that: the
config parser (internal/config) now recognizes the full v1 field vocabulary,
enforces strict parsing, and rejects structurally invalid fields. Phase 2
added full normalization of choice-field options — see
Options below. Phase 3 added the multi-value submission
model so repeated fields like checkboxes and multi-select survive end to
end — see Multi-value fields below. Phase 4 added
server-side enforcement of the validate: constraint block and the
messages: map — see Validation below. Phase 5 added a
restricted server-side template engine that evaluates {{ ... }} expressions
in output config values — see Templating in the config
reference. Phase 6 made form.RenderHTML (and webform-relay form html)
render the full enriched field model as accessible, deterministic HTML —
see Rendered HTML below.
Field types
| Type | Notes |
|---|---|
text | Default type when type is omitted |
textarea | Multi-line text |
email | Single email address |
url | Single URL |
tel | Telephone number |
number | Numeric input |
date | Date input |
select | Dropdown; requires options |
radios | Radio button group; requires options |
checkboxes | Checkbox group (multi-select); requires options |
checkbox | Single boolean checkbox; no options |
hidden | Not shown to the user; requires default |
html | Static content, not an input — renders content verbatim; see Static HTML & wrapping |
Common properties
These are recognized on every field type:
| Property | Type | Description |
|---|---|---|
name | string | Field key in the submitted form body (required) |
type | string | One of the field types above (default text) |
label | string | Display label |
label_hidden | bool | Visually hide the label (still present for accessibility) |
help | string | Longer explanatory text |
hint | string | Short inline hint |
hint_position | string | before or after — where the hint renders relative to the input |
placeholder | string | Placeholder text |
default | string | Default value |
required | bool | Default false |
disabled | bool | |
readonly | bool | |
hidden | bool | |
autocomplete | string | autocomplete attribute value |
attributes | map | Extra attributes to merge onto the rendered input |
validate | block | Server-side validation constraints — see Validation |
messages | map | Per-constraint error messages — see Messages |
prefix | string | Verbatim HTML emitted immediately before the element — see wrapping |
suffix | string | Verbatim HTML emitted immediately after the element |
wrapper | string | A Go template wrapping the element — see wrapping |
Static HTML & element wrapping
The html element
A type: html element inserts static content into the form at its position in
the fields list. It is not an input: it has no name, submits nothing, is
skipped by validation, and rejects data-field properties (required, options,
validate, min/max/step, …). Its content is required and rendered
verbatim — never escaped.
fields:
- type: html
content: |
<h2>Get in touch</h2>
<p>We usually reply within one business day.</p>
- name: email
type: email
required: true
- type: html
content: '<hr>'Trust boundary.
content(and theprefix/suffix/wrapperbelow) are emitted as raw HTML. They come from the config in S3, which is operator-authored — treat them as trusted markup and never assemble them from untrusted input. (Submitted user values are always escaped; they only ever reach the output through the normal, escaped input rendering.)
prefix, suffix, wrapper (any element)
Every element — inputs and html alike — may be decorated:
| Key | What it does |
|---|---|
prefix | verbatim string emitted immediately before the element |
suffix | verbatim string emitted immediately after the element |
wrapper | a Go text/template that wraps the element |
Composition is prefix + wrapper(element) + suffix.
The wrapper template sees two values: {{ .Element }} — the element’s
already-rendered HTML (emitted verbatim) — and {{ .Field }} — the element’s
metadata ({{ .Field.Name }}, {{ .Field.Label }}, {{ .Field.Type }}, …):
fields:
- name: email
type: email
prefix: '<div class="row">'
suffix: '</div>'
wrapper: '<div class="col-6" data-field="{{ .Field.Name }}">{{ .Element }}</div>'A wrapper with invalid template syntax fails at config load. A
syntactically-valid template that references something nonexistent
({{ .Field.Bogus }}) degrades gracefully at render time — the element is
rendered without the wrapper and the error is logged, rather than failing the
request.
Type-specific properties
| Type | Extra properties |
|---|---|
text, email, url, tel, hidden | — (hidden requires default) |
textarea | rows |
number | min, max, step |
date | min, max |
select | multiple, empty_label, options |
radios | inline, options |
checkboxes | inline, options |
checkbox | — (single boolean; no options) |
options accepts either a shorthand scalar list or explicit maps — see
Options below for the full normalization rules.
Options
Phase 2 fully normalizes the options list on select, radios, and
checkboxes fields. Each entry may be written in either of two forms, and the
two forms may be mixed within the same list.
Shorthand — a bare scalar. The value and label are the same string:
options: [small, medium, large]Explicit — a map with value, label, selected, and disabled. If
label is omitted it defaults to value. selected and disabled are
booleans (default false):
options:
- value: small
- value: medium
label: "Medium (default)"
selected: true
- value: large
disabled: trueMixing shorthand and explicit entries in one list is allowed:
options:
- small
- value: medium
label: "Medium (default)"
selected: true
- largeRejected in v1
The parser rejects the following at parse time, each with an error naming the form and field:
- Grouped options —
{ group: ..., options: [...] }— not supported in v1. options_from(loading options from an external source) — not supported in v1.- Duplicate option values within the same field.
- Unknown keys inside an option map (e.g. a typo’d
lableinstead oflabel). - An empty or null option value.
- An option entry that is neither a scalar nor a map (e.g. a list or another unexpected YAML node type).
Strict parsing
The parser now decodes YAML with KnownFields(true). Any key that isn’t part
of the recognized field, form, output, or captcha schema — including a typo
like requried or option instead of options — is a hard parse error.
Breaking change: configs that previously had stray or misspelled keys parsed successfully (the extra keys were silently ignored). Those configs will now fail to load. Run
webform-relay config validateagainst your config after upgrading.
Structural validation
After decoding, each field is checked for structural consistency. A form fails to load if any field violates one of these rules:
- Reserved name —
id,form_id,submitted_at, andhandler(case-insensitive) are claimed by the submission envelope and cannot be used as a fieldname. optionson a non-choice type — onlyselect,radios, andcheckboxesmay declareoptions.- Choice type with no options —
select,radios, andcheckboxesmust declare at least one option. - Numeric properties on the wrong type —
min/max/stepare only valid onnumber;min/maxare also valid ondate. multipleon a non-selecttype.rowson anything other thantextarea.inlineon anything other thanradios/checkboxes.empty_labelon anything other thanselect.hiddenfield with nodefault— a hidden field’s value comes fromdefault, so it must be set.
Each violation returns an error naming the form, field, and offending property, so a bad config fails loudly at parse time rather than misbehaving at render or submission time.
Multi-value fields
Phase 3 preserves every value submitted for a field that appears more than
once in the request body — most commonly a checkboxes group or a
multi-select, which POST a repeated key
(interest=design&interest=engineering). Previously each field collapsed to
its first submitted value; now all values survive end to end: submission
parsing → validation → field mapping → relay outputs. See
Multi-value fields in the config reference for how
each output type serializes them.
Validation over a multi-value field:
requiredis satisfied if any submitted value is non-blank.- The 10 000-byte length limit is enforced per value, not on the concatenation of all values.
- An
email-type field’s format is checked against its first non-blank value only.
fields:
- name: interest
type: checkboxes
options: [design, engineering, sales]Submitting interest=design&interest=engineering keeps both values through
to every configured output.
Conditional visibility (show_when)
Any element may carry a show_when clause that makes it visible only when
another field’s value meets a condition. Evaluation is entirely server-side;
the affected form is re-rendered over htmx when a controlling field changes (see
the reveal-on-correct-entry how-to).
fields:
- name: account_type
type: radios
options: [personal, business]
- name: company
type: text
label: Company name
required: true
show_when: { field: account_type, equals: business }A single condition is { field: <name>, <operator>: <value> }. Combine several
with all (AND) or any (OR):
show_when:
all:
- { field: account_type, equals: business }
- { field: employees, one_of: [medium, large] }Operators
| Operator | Value | True when |
|---|---|---|
equals | scalar | the referenced field’s submitted value(s) include it |
not_equals | scalar | they do not include it (empty/absent → true) |
one_of | sequence | some submitted value is in the list |
not_one_of | sequence | no submitted value is in the list |
filled | true | the field has at least one non-empty value |
empty | true | the field has no non-empty value |
equals/not_equals take a single scalar (a sequence is a config error);
one_of/not_one_of take a sequence; filled/empty take exactly true.
Rules and behavior
fieldmust reference a declared input field in the same form. Referencing an undeclared name, or anhtmlelement (which has no value), is a config-load error. A field may reference itself (the reveal gate depends on this).- When a condition is not met: a data field (one with a
name) collapses to a value-preserving<input type="hidden">— not shown for editing, not validated (a hiddenrequiredfield never blocks the submission), but its value survives the re-render and the submit. A non-data element (html) is omitted entirely. - Controllers re-render the form. A field referenced by any
show_whenis a controller; when served by the form endpoint it is rendered with htmx attributes so a change re-fetches and re-renders the whole form, preserving everything already entered. - Evaluation is one pass over the submitted values. Deep multi-level cascades (a controller that is itself hidden by another condition) are not resolved to a fixpoint in v1 — keep condition chains shallow.
Validation
Phase 4 makes form.Validate enforce the validate: constraint block
from the field model — introduced in Phase 1 as parsed-and-stored vocabulary
— against the submitted value(s) for each field, and applies the
messages: map to produce the resulting error text.
Constraints
| Constraint | Applies to | Meaning |
|---|---|---|
min_length | any | Minimum character (rune) count of each non-blank value |
max_length | any | Maximum character (rune) count of each non-blank value |
min | number, date | Minimum value — compared numerically for number, as an ISO string for date |
max | number, date | Maximum value — compared numerically for number, as an ISO string for date |
step | number | Value must be an integer multiple of step, based at 0 |
pattern | any | RE2 regular expression, matched against the entire value; an invalid pattern is rejected at config-parse time |
one_of | any | Each value must be a member of the allowed set |
min_selected | checkboxes, multi-select | Minimum count of non-blank submitted values |
max_selected | checkboxes, multi-select | Maximum count of non-blank submitted values |
matches_field | any | This field’s value must equal the named field’s submitted value |
differs_from | any | This field’s value must differ from the named field’s submitted value |
For a multi-value field (a checkboxes group or multi-select), every
constraint except min_selected/max_selected is checked against each
non-blank submitted value; min_selected/max_selected check the count of
non-blank values instead.
Behavior notes
- A blank optional field skips the per-value constraints above
(
min_length,max_length,min,max,step,pattern,one_of,matches_field,differs_from) entirely, butmin_selected/max_selectedare still enforced against the (zero) count of non-blank values — so an optionalcheckboxesgroup can still require at least one selection. - A
numberfield with amin,max, orstepconstraint rejects a non-numeric submitted value. - Assumptions (v1):
datemin/maxcompare values as ISO strings rather than parsed dates, which only sorts correctly for zero-padded ISO values (e.g.2024-03-05) — the format HTMLdate/timeinputs produce.stepis always based at 0, not atmin.
Messages
messages: maps a constraint name to a custom error string. When a
constraint fails, its message under messages: is used verbatim in
place of the default, field-naming message, if set to a non-empty value.
Recognized keys: required, min_length, max_length, min, max,
step, pattern, one_of, min_selected, max_selected,
matches_field, differs_from. messages.required overrides the default
field "<name>" is required message just like the others.
fields:
- name: promo_code
type: text
required: true
validate:
min_length: 4
max_length: 12
pattern: "[A-Z0-9-]+"
messages:
required: "Enter your promo code."
min_length: "Promo codes are at least 4 characters."
pattern: "Promo codes use uppercase letters, digits, and dashes only."Rendered HTML
Phase 6 makes form.RenderHTML (used by webform-relay form html <name>)
render the full field model — every type and common/type-specific property
above — as accessible, server-rendered HTML with no client-side JavaScript.
Rendering is deterministic: the same config always produces byte-identical
output.
Markup per field category
- Text-like fields (
text,email,url,tel,number,date),select, andtextareaare each wrapped in<div class="field">with a<label for="{name}">; the control itself hasid="{name}". radios/checkboxesrender as<fieldset class="field"><legend>{label}</legend>, with one<input>+<label for="{name}-{value}">per option (each option’s input hasid="{name}-{value}").- Single
checkboxrenders inline inside a<div class="field">: one<input type="checkbox" value="1">followed by its<label>on the same line. hidden— thehiddentype, or any field withhidden: trueregardless of its declared type — renders a bare<input type="hidden" name="{name}" value="{default}">with no wrapper, label, orid.- A field with no
labelomits the<label>element entirely rather than emitting an empty one.
Accessibility wiring
helprenders as<p class="help" id="{name}-help">;hintrenders as<span class="hint" id="{name}-hint">, positioned before or after the control perhint_position(defaultafter).- The control carries
aria-describedbylisting the hint id then the help id, whichever of the two are actually present (space-separated). label_hiddenkeeps the<label>element (so the field stays labelled for assistive technology) but addsclass="visually-hidden"to it.- This targets WCAG 2.2 AA label/description patterns: every control is
labelled, help/hint text is associated via
aria-describedby, and option groups usefieldset/legendrather than bare inputs.
Attributes, escaping, and determinism
required,disabled, andreadonlyrender as boolean HTML attributes;placeholder,autocomplete,min,max,step,multiple,rows, anddefault(as the control’svalue/content) render when set and applicable to the field’s type.- Custom
attributesare merged onto the control with their keys sorted, so output order is stable regardless of map iteration order. - Every interpolated value — labels, help/hint text, attribute values, option values/labels — is HTML-escaped.
Notes and limitations
- Group
requiredis enforced server-side, not client-side. A requiredradios/checkboxesgroup does not get arequiredattribute on any of its per-option<input>elements, because a checkbox group’s “at least one selected” rule has no clean single-attribute HTML representation. Enforcement happens via thevalidate:block (required/min_selected; see Validation) at submission time. - Custom
attributeskeys are validated at config-parse time. A key must match the safe HTML attribute-name pattern^[A-Za-z][A-Za-z0-9_.:-]*$; a config with an unsafe key fails to load rather than producing malformed markup.
Example
fields:
- name: full_name
type: text
label: "Full name"
help: "Your legal name"renders as:
<div class="field">
<label for="full_name">Full name</label>
<input type="text" id="full_name" name="full_name" aria-describedby="full_name-help">
<p class="help" id="full_name-help">Your legal name</p>
</div>What’s not here yet
Phases 1–6 cover parsing, structural validation, options normalization, the
multi-value submission model, constraint enforcement, templated output
values (see Templating in the config reference), and
accessible HTML rendering (see Rendered HTML above). There
is no remaining gap between the declared field-model vocabulary and what the
parser, validator, and renderer support in v1.