Custom Expressions
How to write JavaScript with more than one statement in a form expression. This page gives the rules for IIFE expressions and shows the approved patterns.
Formly compiles each value in expressions at run time. It builds a function from your string:
Function("model", "formState", "field", `return ${expression};`);Your expression goes in the return position. A return statement accepts an expression. It does not accept a statement. Therefore const, if, for and early returns cause a syntax error.
An IIFE (immediately invoked function expression) gives you a function body. Statements are legal in a function body.
(() => {
/* Write statements here. */
})();When to use an IIFE
An IIFE gives you statements. If you do not need a statement, do not use an IIFE.
Use a plain expression for a comparison, for boolean logic, for one ternary, or for one array method.
"expressions": {
"hide": "model?.DOCUMENT_TYPE !== 'PASSPORT'"
}"expressions": {
"props.readonly": "formState?.APPLICATION_STATE === 'PENDING_RESUBMISSION'"
}"expressions": {
"hide": "!(field?.parent?.parent?.parent?.parent?.model?.SECTION_OWNERS?.BLOCK_OWNERS?.OWNER_DETAILS ?? []).some((row) => row?.IS_PRIMARY === 'Yes')"
}Use an IIFE only for one of these conditions:
- You use a calculated value more than one time.
- You must return early when an input is not available.
- You must divide a text value or an object into parts.
- You need a loop, and no array method does the same task.
If no condition applies, remove the IIFE.
Standard form
Use an arrow function. Call it immediately. Do not put a semicolon at the end.
(() => {
return true;
})();The older form (function () { … })() also operates correctly, and some forms contain it. Do not write new expressions with this form.
Do not use this in an expression. The two forms give this a different value. You do not need this, because field is already a parameter.
Data in scope
An expression has three identifiers. It has no other data, but it can use the JavaScript global objects.
| Identifier | Contents |
|---|---|
model | The data for the current scope. This is not the data of the full form. |
field | The Formly configuration of this field, with its parent chain. |
formState | State that all the fields of the form share. See Form State. |
model gives the data of the nearest parent that has a key. A field in SECTION_FOO > BLOCK_BAR reads only the keys of its own block. To read a key in a different section or block, go up the parent chain. Form Rules gives the depth rules. The same rules apply in an IIFE.
Add guards for data that is not available
An expression that throws an error stops the field. Formly shows the message [Formly Error] [Expression "hide"] …. Formly has no fallback value and no error boundary.
Optional chaining is not enough. undefined?.filter gives undefined, but the next call in the chain throws an error. Also, the model of a repeater is undefined until the first row shows.
For an array, make a safe value before you use it:
const rows = Array.isArray(raw_rows) ? raw_rows : [];For a single value, return early when the value is not available:
if (!birth_date) return true;Select the early return value with care. For hide, the value true keeps the field hidden until the user gives the input.
Names
Use snake_case for the local variables in an IIFE.
Use UPPER_SNAKE_CASE only for form keys, in the same form as the JSON file. Do not give a local variable the name of the key that it holds.
// The key is UPPER_SNAKE_CASE. The local variable is not.
const shareholder_details =
field?.parent?.parent?.parent?.parent?.model?.SECTION_CAPITAL?.BLOCK_SHAREHOLDERS
?.SHAREHOLDER_DETAILS;The result is clear: UPPER_SNAKE_CASE in an expression is always a form key. All other names are local variables.
What an expression must not do
An expression calculates a value. It does no other task.
- Do not assign a value to
model,field, orformState. The advanced pattern returns a value. It does not assign one. - Do not call an API, do not use storage, and do not start a timer. A fetch component gets external data.
- You can use
new Date()for a comparison. The first example shows this. But do not write a value fromnew Date()into the model. Such a value changes at each pass and causes continuous updates.
Performance
Formly calculates each expression of each field at each change-detection pass.
Formly compares the new value with the last value with JSON.stringify. Therefore you can return a new array or a new object at each pass. When the two values are equal, Formly does nothing. But the value must be compatible with JSON. A structure that refers to itself throws an error.
Keep the body small. A sort, a deep copy, or a scan of a large repeater at each keystroke makes the form slow for the user.
Format in the JSON file
Write the IIFE on one line in the JSON string. All the expressions in production use one line. Escaped newline characters in JSON are more difficult to read than one long line.
This limit is useful. Each example below shows the code two times: first with indentation, then as the one line that you commit. If the one-line version is too difficult to read, the IIFE is too large. Divide the logic between fields, or move it out of the form.
Return values
An expression can return any type. The host decides how it uses the value. Match the type to the host:
| Host | Type to return |
|---|---|
hide | Boolean. true hides the field. |
props.hideField | Boolean. true hides the field but keeps the value. |
props.readonly | Boolean. |
props.label | Text. |
props.hint | Text. Return '' when there is no hint. |
props.max | Number. |
props.options | An array of objects. Each object has a value and a label. |
model.SOME_KEY | Any value that is compatible with JSON. See below. |
Return one type from all the paths of your expression. A prop that gets text at one pass and undefined at the next pass gives a defect that is difficult to find.
Examples
Hide a block by a calculated age
This block shows only when the applicant is less than 16 years old. The block stays hidden until the user gives a birth date.
(() => {
const birth_date = model?.APPLICANT_BIRTH_DATE;
if (!birth_date) return true;
const [day_str, month_str, year_str] = birth_date.split("/");
const birth = new Date(Number(year_str), Number(month_str) - 1, Number(day_str));
const today = new Date();
let age = today.getFullYear() - birth.getFullYear();
const month_delta = today.getMonth() - birth.getMonth();
if (month_delta < 0 || (month_delta === 0 && today.getDate() < birth.getDate())) age--;
return age >= 16;
})();"expressions": {
"hide": "(() => { const birth_date = model?.APPLICANT_BIRTH_DATE; if (!birth_date) return true; const [day_str, month_str, year_str] = birth_date.split('/'); const birth = new Date(Number(year_str), Number(month_str) - 1, Number(day_str)); const today = new Date(); let age = today.getFullYear() - birth.getFullYear(); const month_delta = today.getMonth() - birth.getMonth(); if (month_delta < 0 || (month_delta === 0 && today.getDate() < birth.getDate())) age--; return age >= 16; })()"
}An IIFE is necessary here. The code uses birth and today more than one time. The code also returns early when the birth date is not available.
Change the options of a dropdown
Remove the option "Spouse" when the applicant is the spouse, or when the event is a marriage.
Always send each label through formState.getTranslation(). The portal shows the option labels in the language of the user. A label that you return directly stays in one language.
(() => {
const all_options = [
{ value: "SPOUSE", label: "Spouse" },
{ value: "PARENT", label: "Parent" },
{ value: "CHILD", label: "Child" },
{ value: "SIBLING", label: "Sibling" },
];
const excludes_spouse =
model?.RELATIONSHIP_TO_APPLICANT === "SPOUSE" || model?.REASON_FOR_REQUEST === "MARRIAGE";
const selected_options = excludes_spouse
? all_options.filter((option) => option.value !== "SPOUSE")
: all_options;
return selected_options.map((option) => ({
value: option.value,
label: formState?.getTranslation(option.label),
}));
})();"expressions": {
"props.options": "(() => { const all_options = [{ value: 'SPOUSE', label: 'Spouse' }, { value: 'PARENT', label: 'Parent' }, { value: 'CHILD', label: 'Child' }, { value: 'SIBLING', label: 'Sibling' }]; const excludes_spouse = model?.RELATIONSHIP_TO_APPLICANT === 'SPOUSE' || model?.REASON_FOR_REQUEST === 'MARRIAGE'; const selected_options = excludes_spouse ? all_options.filter((option) => option.value !== 'SPOUSE') : all_options; return selected_options.map((option) => ({ value: option.value, label: formState?.getTranslation(option.label) })); })()"
}Translate the label. Do not translate the value. The value goes to the workflow and to the integrations, therefore it must stay the same in all languages.
This expression returns a new array at each pass. This is correct. Formly compares the arrays with JSON.stringify, and it does nothing when the options do not change.
In the JSON string, use single quotes for the JavaScript text values. The JSON string uses the double quotes.
Show a hint from repeater rows
Show a hint only when the shareholder rows show that a beneficial owner is missing.
(() => {
const raw_rows =
field?.parent?.parent?.parent?.parent?.model?.SECTION_CAPITAL?.BLOCK_SHAREHOLDERS
?.SHAREHOLDER_DETAILS;
const shareholders = Array.isArray(raw_rows) ? raw_rows : [];
const declared_owners = shareholders.filter((row) => row?.IS_BENEFICIAL_OWNER === "Yes");
if (declared_owners.length > 0) return "";
const has_major_holder = shareholders.some((row) => Number(row?.OWNERSHIP_PERCENTAGE) > 25);
return has_major_holder ? formState?.getTranslation("beneficialOwnerMissingHint") : "";
})();"expressions": {
"props.hint": "(() => { const raw_rows = field?.parent?.parent?.parent?.parent?.model?.SECTION_CAPITAL?.BLOCK_SHAREHOLDERS?.SHAREHOLDER_DETAILS; const shareholders = Array.isArray(raw_rows) ? raw_rows : []; const declared_owners = shareholders.filter((row) => row?.IS_BENEFICIAL_OWNER === 'Yes'); if (declared_owners.length > 0) return ''; const has_major_holder = shareholders.some((row) => Number(row?.OWNERSHIP_PERCENTAGE) > 25); return has_major_holder ? formState?.getTranslation('beneficialOwnerMissingHint') : ''; })()"
}Number(row?.OWNERSHIP_PERCENTAGE) is necessary. Form values are text. The comparison "30" > 25 gives the correct result, but the comparison "9" > 25 gives an incorrect result. Change the type to a number before you compare.
Advanced: derived model keys
Use this pattern only when no other solution is possible.
An expression with the key model.SOME_KEY writes to the model. It does not read the model. Formly assigns the return value to that key. The key becomes part of the model, therefore the portal sends it with the application data.
The platform already makes the application data flat when the user submits the form. Do not use this pattern to change the shape of data that a field holds. Use it only for a value that no field holds and that no field can calculate, for example a total across the rows of a repeater.
This expression gives the sum of the declared ownership percentages. The workflow rules compare this total with 100.
(() => {
const raw_rows =
field?.parent?.parent?.parent?.parent?.model?.SECTION_CAPITAL?.BLOCK_SHAREHOLDERS
?.SHAREHOLDER_DETAILS;
const shareholders = Array.isArray(raw_rows) ? raw_rows : [];
const total = shareholders.reduce((sum, row) => {
const percentage = Number(row?.OWNERSHIP_PERCENTAGE);
return Number.isFinite(percentage) ? sum + percentage : sum;
}, 0);
return Math.round(total * 100) / 100;
})();"expressions": {
"model.TOTAL_DECLARED_OWNERSHIP": "(() => { const raw_rows = field?.parent?.parent?.parent?.parent?.model?.SECTION_CAPITAL?.BLOCK_SHAREHOLDERS?.SHAREHOLDER_DETAILS; const shareholders = Array.isArray(raw_rows) ? raw_rows : []; const total = shareholders.reduce((sum, row) => { const percentage = Number(row?.OWNERSHIP_PERCENTAGE); return Number.isFinite(percentage) ? sum + percentage : sum; }, 0); return Math.round(total * 100) / 100; })()"
}Number.isFinite removes the rows that hold no number. Without this test, one empty row gives the result NaN, and NaN is not compatible with JSON. Math.round removes the small errors of decimal addition, which stop the JSON.stringify comparison from finding two equal values.
Obey all of these rules:
- Do not use a key that a field owns. The expression writes to the key at each pass and removes the data of the user.
- Return only data that is compatible with JSON. Formly compares the value with
JSON.stringify, then the portal sends it. A structure that refers to itself throws an error. A member with the valueundefinedis lost. - Return the same result for the same input at each pass. Do not use the time, a random value, or data from outside the model.
- Calculate only. Do not remove data. Read the fields that the user completed. Do not replace this data with a shape that you cannot trace back.
- Record the key in your service documentation. No field declares this key. The expression is the only record that the key exists.
Form validation does not see a derived key. The field validators do not see it. A person who reads the form for the first time does not see it. Before you use this pattern, examine the system that receives the data. When that system can calculate the value, let it do this task.
Form Rules
The rules that apply to every field in every IremboHub form. Read this before building or reviewing any form configuration.
Form State
The contents of formState, the third identifier in scope in a form expression. This page gives the keys that the portal writes, the data that each key holds, and the rules for their use.