IremboHub forms are JSON configurations interpreted by Angular Formly. The rules below are not style preferences — they reflect how Formly evaluates expressions at runtime, how the model is scoped, and how the submission pipeline works. Breaking them produces bugs that are often invisible in development but fail in production.
Read this page once before building a form. Each component page links back to the relevant sections.
Every form has exactly one sections element. It is the step/wizard container — not a list of sections, one element. All steps live as formly-group children inside it.
The nesting order is fixed. Never skip a level:
The root shape is always { "fields": [...] }. Never return a bare array.
Structural containers have required properties:
| Container | key |
props.label |
Other |
|---|---|---|---|
sections |
none | none | must have the debug expression |
formly-group |
SECTION_ prefix |
required — this is the step title | |
block |
BLOCK_ prefix |
required — this is the group title |
Every node that holds data or structure needs a key. The only exception is sections itself.
All keys are UPPER_SNAKE_CASE. Structural containers require a prefix that signals their role:
| Node | Prefix | Example |
|---|---|---|
formly-group |
SECTION_ |
SECTION_APPLICANT_DETAILS |
block |
BLOCK_ |
BLOCK_IDENTIFICATION |
| leaf fields | none | FIRST_NAME |
Keys must be unique across the entire form — not just within their section or block, globally.
Every field that renders visible UI needs props.label. Labels must also be globally unique — if two fields share a label, append a number: "Applicant Details 2".
Casing follows the content:
Rule: if the label ends with ?, use Sentence case. Otherwise, Title Case.
Never write "required": true on a field prop. Formly reads required as a static value — it cannot update it when fields show or hide. Hidden fields with required: true block form submission even when invisible.
Every field uses this three-property pattern instead:
{
"props": {
"required": false,
"defaultRequired": true
},
"expressions": {
"props.required": "!(field?.props?.hideField || field?.hide) && field?.props?.defaultRequired"
}
}
required: false — the static fallback, always falsedefaultRequired — your intent: true for required fields, false for optional onesfalse automatically when the field is hiddenTo make a field required: set defaultRequired: true.
To make a field optional: set defaultRequired: false.
Never remove the expression either way — it must always be present.
This expression is the same for every field type. Copy it exactly.
Use hide by default. It removes the field from the DOM and clears its value from the model on hide.
"expressions": {
"hide": "model?.DOCUMENT_TYPE !== 'PASSPORT'"
}
Use props.hideField only in two cases:
1. A data-fetch component populates this field silently. The field is never visible to the user, but its value must survive re-renders. hide would clear it.
2. A parent block or formly-group already has a hide expression. Adding a second hide on the child causes expression evaluation conflicts. Use props.hideField on the child instead.
Both field?.hide and field?.props?.hideField are checked by the required expression, so either mechanism correctly disables the required constraint when the field isn't visible.
The hidden-field-wrapper that powers props.hideField is auto-registered for every leaf field type. You do not add it to a wrappers array — setting the prop is enough.
| Mechanism | Removes from DOM | Clears model value | Use for |
|---|---|---|---|
expressions.hide |
yes | yes | Standard show/hide where the value should NOT persist while hidden. |
expressions.props.hideField |
no | no | Fields populated programmatically (data-fetch) or children of a hidden parent. |
Never set props.hideField: true statically — the field becomes permanently invisible with no way back. Always drive it via expressions["props.hideField"].
Two wrappers attach inline help text to a field. Both are auto-registered for every leaf field type — set the prop and the wrapper activates.
props.hint renders persistent help text near the field with an info icon. Use for guidance the user should always see (format requirements, character limits, instructions).
{
"key": "LEGAL_NAME",
"type": "input",
"props": {
"label": "Legal Name",
"required": false,
"defaultRequired": true,
"hint": "Enter the name exactly as it appears on the official document",
"hintPosition": "TOP"
},
"expressions": {
"props.required": "!(field?.props?.hideField || field?.hide) && field?.props?.defaultRequired"
},
"validation": {
"messages": {
"required": "Legal name is required."
}
}
}
props.hint — the hint text. Supports basic HTML via [innerHTML].props.hintPosition — "TOP" or "BOTTOM". Default "BOTTOM". Case-sensitive — lowercase silently falls back to "BOTTOM".props.tooltip shows supplementary text in a popover when the user hovers or focuses the info icon next to the label. Use for context most users don't need (format clarifications, cross-document references).
{
"key": "SPOUSE_NAME",
"type": "input",
"props": {
"label": "Spouse Full Name",
"required": false,
"defaultRequired": true,
"tooltip": "This name must match the marriage certificate exactly",
"tooltipPlacement": "top"
},
"expressions": {
"props.required": "!(field?.props?.hideField || field?.hide) && field?.props?.defaultRequired"
},
"validation": {
"messages": {
"required": "Spouse name is required."
}
}
}
props.tooltip — the tooltip text.props.tooltipPlacement — any ngb-tooltip placement: auto, top, top-start, top-end, bottom, bottom-start, bottom-end, start/left, end/right, plus the *-top/*-bottom variants. Invalid values silently fall back to auto.| Need | Use |
|---|---|
| Information the user must always see | props.hint |
| Supplementary context most users will skip | props.tooltip |
| Both | Set both props — they coexist on the same field |
Neither wrapper is registered for structural containers (block, formly-group, sections). Setting hint or tooltip on a container is a silent no-op.
Always use readonly. Never use disabled unless you explicitly want the field's value excluded from the submission payload — which is almost never the intent.
| Value submitted? | Renders predictably? | |
|---|---|---|
readonly |
yes | yes |
disabled |
no | inconsistently |
"props": { "readonly": true }
For dynamic states, drive it from an expression:
"expressions": {
"props.readonly": "model?.STATUS === 'locked'"
}
Always use optional chaining (?.) in every expression. A missing key without it throws a TypeError that Formly surfaces as a broken field.
model?.FIELD_KEY ✅
model.FIELD_KEY 🚫
Model scope is relative, not global. Because formly-group has a key, it creates a nested scope. model inside a block refers to the parent formly-group's model — not the root. A field inside SECTION_FOO > BLOCK_BAR sees model?.FIELD_KEY for siblings in the same block but cannot reach fields in other sections that way.
To reference a field in a different section or block, walk up the parent chain to the root:
"field?.parent?.parent?.parent?.parent?.model?.SECTION_KEY?.BLOCK_KEY?.FIELD_KEY"
That's 4 parents: field → block.fieldGroup → formly-group.fieldGroup → sections.fieldGroup → root.
Inside a customrepeater, add two more levels:
"field?.parent?.parent?.parent?.parent?.parent?.parent?.model?.SECTION_KEY?.BLOCK_KEY?.FIELD_KEY"
Never guess the depth — count it from the nesting structure.
Never reference a key that does not exist in the form. Every model path in an expression must correspond to a declared field.
Every prop that constrains a value needs a matching entry in validation.messages. No validator is ever silent.
"validation": {
"messages": {
"required": "First Name is required.",
"maxLength": "First Name cannot exceed 50 characters.",
"pattern": "Please enter a valid email address."
}
}
The message key matches the prop name exactly. If you add maxLength, add "maxLength". If you add pattern, add "pattern". Messages must be user-facing — not a description of the constraint.
Use a raw regex string in props.pattern — no / delimiters. Pair every pattern with a validation.messages.pattern entry.
Common patterns:
Email address
^[^<>()\[\]\\,;:%#^\s"$&!@]+@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z0-9]+\.)+[a-zA-Z]{2,}))$
Rwandan mobile (07[2389]xxxxxxx)
^07[2389]\d{7}$
Letters and numbers only
^[a-zA-Z0-9]*$
Digits only
^\d+$
No special characters
^[a-zA-Z0-9\s]*$
Custom patterns are valid for anything not listed. The message must always be clear and user-facing.
During development, the sections element must carry this expression:
"expressions": {
"props.hint": "console.log({ root_model: model, root_field: field })"
}
It logs the root model and field to the browser console on every change — essential for tracing expression scoping issues. Remove it before shipping. It logs to the console in production if left in.
Never invent a field type. Use only types from the component reference. If the type you need is not listed, stop and ask.