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

TypeNotes
textDefault type when type is omitted
textareaMulti-line text
emailSingle email address
urlSingle URL
telTelephone number
numberNumeric input
dateDate input
selectDropdown; requires options
radiosRadio button group; requires options
checkboxesCheckbox group (multi-select); requires options
checkboxSingle boolean checkbox; no options
hiddenNot shown to the user; requires default
htmlStatic content, not an input — renders content verbatim; see Static HTML & wrapping

Common properties

These are recognized on every field type:

PropertyTypeDescription
namestringField key in the submitted form body (required)
typestringOne of the field types above (default text)
labelstringDisplay label
label_hiddenboolVisually hide the label (still present for accessibility)
helpstringLonger explanatory text
hintstringShort inline hint
hint_positionstringbefore or after — where the hint renders relative to the input
placeholderstringPlaceholder text
defaultstringDefault value
requiredboolDefault false
disabledbool
readonlybool
hiddenbool
autocompletestringautocomplete attribute value
attributesmapExtra attributes to merge onto the rendered input
validateblockServer-side validation constraints — see Validation
messagesmapPer-constraint error messages — see Messages
prefixstringVerbatim HTML emitted immediately before the element — see wrapping
suffixstringVerbatim HTML emitted immediately after the element
wrapperstringA 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 the prefix/suffix/wrapper below) 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:

KeyWhat it does
prefixverbatim string emitted immediately before the element
suffixverbatim string emitted immediately after the element
wrappera 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

TypeExtra properties
text, email, url, tel, hidden— (hidden requires default)
textarearows
numbermin, max, step
datemin, max
selectmultiple, empty_label, options
radiosinline, options
checkboxesinline, 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: true

Mixing shorthand and explicit entries in one list is allowed:

options:
  - small
  - value: medium
    label: "Medium (default)"
    selected: true
  - large

Rejected 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 lable instead of label).
  • 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 validate against 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 nameid, form_id, submitted_at, and handler (case-insensitive) are claimed by the submission envelope and cannot be used as a field name.
  • options on a non-choice type — only select, radios, and checkboxes may declare options.
  • Choice type with no optionsselect, radios, and checkboxes must declare at least one option.
  • Numeric properties on the wrong typemin/max/step are only valid on number; min/max are also valid on date.
  • multiple on a non-select type.
  • rows on anything other than textarea.
  • inline on anything other than radios/checkboxes.
  • empty_label on anything other than select.
  • hidden field with no default — a hidden field’s value comes from default, 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:

  • required is 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

OperatorValueTrue when
equalsscalarthe referenced field’s submitted value(s) include it
not_equalsscalarthey do not include it (empty/absent → true)
one_ofsequencesome submitted value is in the list
not_one_ofsequenceno submitted value is in the list
filledtruethe field has at least one non-empty value
emptytruethe 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

  • field must reference a declared input field in the same form. Referencing an undeclared name, or an html element (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 hidden required field 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_when is 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

ConstraintApplies toMeaning
min_lengthanyMinimum character (rune) count of each non-blank value
max_lengthanyMaximum character (rune) count of each non-blank value
minnumber, dateMinimum value — compared numerically for number, as an ISO string for date
maxnumber, dateMaximum value — compared numerically for number, as an ISO string for date
stepnumberValue must be an integer multiple of step, based at 0
patternanyRE2 regular expression, matched against the entire value; an invalid pattern is rejected at config-parse time
one_ofanyEach value must be a member of the allowed set
min_selectedcheckboxes, multi-selectMinimum count of non-blank submitted values
max_selectedcheckboxes, multi-selectMaximum count of non-blank submitted values
matches_fieldanyThis field’s value must equal the named field’s submitted value
differs_fromanyThis 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, but min_selected/ max_selected are still enforced against the (zero) count of non-blank values — so an optional checkboxes group can still require at least one selection.
  • A number field with a min, max, or step constraint rejects a non-numeric submitted value.
  • Assumptions (v1): date min/max compare values as ISO strings rather than parsed dates, which only sorts correctly for zero-padded ISO values (e.g. 2024-03-05) — the format HTML date/time inputs produce. step is always based at 0, not at min.

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, and textarea are each wrapped in <div class="field"> with a <label for="{name}">; the control itself has id="{name}".
  • radios/checkboxes render as <fieldset class="field"><legend>{label}</legend>, with one <input> + <label for="{name}-{value}"> per option (each option’s input has id="{name}-{value}").
  • Single checkbox renders inline inside a <div class="field">: one <input type="checkbox" value="1"> followed by its <label> on the same line.
  • hidden — the hidden type, or any field with hidden: true regardless of its declared type — renders a bare <input type="hidden" name="{name}" value="{default}"> with no wrapper, label, or id.
  • A field with no label omits the <label> element entirely rather than emitting an empty one.

Accessibility wiring

  • help renders as <p class="help" id="{name}-help">; hint renders as <span class="hint" id="{name}-hint">, positioned before or after the control per hint_position (default after).
  • The control carries aria-describedby listing the hint id then the help id, whichever of the two are actually present (space-separated).
  • label_hidden keeps the <label> element (so the field stays labelled for assistive technology) but adds class="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 use fieldset/legend rather than bare inputs.

Attributes, escaping, and determinism

  • required, disabled, and readonly render as boolean HTML attributes; placeholder, autocomplete, min, max, step, multiple, rows, and default (as the control’s value/content) render when set and applicable to the field’s type.
  • Custom attributes are 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 required is enforced server-side, not client-side. A required radios/checkboxes group does not get a required attribute 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 the validate: block (required/min_selected; see Validation) at submission time.
  • Custom attributes keys 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.