Form Rules
The rules that apply to every field in every IremboHub form. Read this before building or reviewing any form configuration.
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.
Form anatomy
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:
sections
└── formly-group one per wizard step
└── block one per visual group within the step
└── fieldThe 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 |
Keys
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.
Labels
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:
- Nouns and field names → Title Case: "First Name", "National ID Number", "Date of Birth"
- Questions → Sentence case: "Is this a new application?", "Does the applicant have a guardian?"
Rule: if the label ends with ?, use Sentence case. Otherwise, Title Case.
Required fields
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, alwaysfalsedefaultRequired— your intent:truefor required fields,falsefor optional ones- The expression — runtime evaluation; returns
falseautomatically when the field is hidden
To 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.
Showing and hiding fields
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.
Readonly vs disabled
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'"
}Expressions and model scope
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.
Validation messages
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.
Pattern validation
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.
Debug expression
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.
Field types
Never invent a field type. Use only types from the component reference. If the type you need is not listed, stop and ask.