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
              └── field

The root shape is always { "fields": [...] }. Never return a bare array.

Structural containers have required properties:

Containerkeyprops.labelOther
sectionsnonenonemust have the debug expression
formly-groupSECTION_ prefixrequired — this is the step title
blockBLOCK_ prefixrequired — 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:

NodePrefixExample
formly-groupSECTION_SECTION_APPLICANT_DETAILS
blockBLOCK_BLOCK_IDENTIFICATION
leaf fieldsnoneFIRST_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, always false
  • defaultRequired — your intent: true for required fields, false for optional ones
  • The expression — runtime evaluation; returns false automatically 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?
readonlyyesyes
disablednoinconsistently
"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.

On this page