A dynamic form is a form whose definition is data: a survey builder, an order form configured in an admin panel, a checklist downloaded from a server. Sooner or later, that definition needs logic. A total that is computed from other fields. A question that appears only when the previous answer is "yes". A rule that says the age must be at least 18. A sentence that greets the user by name.
That logic has to be stored as text, next to the form, and written by whoever configures the form. So you need a small language. Two shortcuts are tempting, and both end badly: a {{field}} string replacer that grows one special case at a time, or evaluating real code that comes from outside the app.
form_expressions is a small expression language made for this job. It is pure Dart, it is sandboxed, and it never evaluates Dart code. It covers calculated fields, show/hide conditions, validation rules and {{field}} text piping.
What it does
A proper pipeline. A lexer feeds a recursive-descent parser, which builds an AST for the evaluator. You compile an expression once and evaluate it many times, which fits a form where answers change on every keystroke.
Types. Number, string, bool, null, list and date. Maps from your data are reachable through paths.
Operators. Arithmetic + - * / % ^ with standard precedence; comparisons == != < <= > >=; and, or, not (also written &&, ||, !); in and not in; the ternary a ? b : c; the null fallback a ?? b; list literals, indexing and string concatenation with +.
Variables. Write $field, a bare field, or ${any-name}. Dotted paths such as address.city and items.0.price work too. They are resolved from a Map or from your own resolver callback.
Built-in functions. Maths: sum min max avg abs round(x, n) floor ceil. General: if len count contains isEmpty number string. Dates: today date age dateDiff addDays. Strings: upper lower trim concat. You can register your own functions with argument-count checks, and those checks run at compile time.
Static analysis. expression.variables lists every referenced path, so you know which fields a condition depends on. orderCalculations sorts calculated fields topologically and reports circular dependencies. evaluateCalculations evaluates the whole set in that order.
Typed errors. ParseError and EvalError carry the offset, the line and column, and a caret snippet. Division by zero, type mismatches and failing custom functions give clear errors, never crashes.
Templates. {{name}}, {{price * qty}}, filters such as {{name | trim | upper}} and {{ratio | round(2)}}, a fallback with {{name ?? "friend"}}, and \{{ for a literal {{.
Limits. Caps on expression length, template length and nesting depth, so hostile input cannot exhaust the stack or the CPU.
How to use it
Add it from GitHub:
dependencies:
form_expressions:
git: https://github.com/AbdeldjalilChougui/form_expressions
Here is the README's example, with a custom function, calculated fields, a condition and a template:
import 'package:form_expressions/form_expressions.dart';
void main() {
final engine = ExpressionEngine()
..register('vat', (args) => (args[0]! as num) * 0.19,
minArgs: 1, maxArgs: 1);
final answers = <String, Object?>{
'name': 'amina',
'birth_date': '1994-05-17',
'country': 'DZ',
'items': [
{'price': 1200, 'qty': 2},
{'price': 450, 'qty': 1},
],
'discount': null,
};
// Calculated fields: parse once, evaluate whenever an answer changes.
final calculations = {
'total': engine.compile('subtotal + vat(subtotal) - (discount ?? 0)'),
'subtotal': engine
.compile('items.0.price * items.0.qty + items.1.price * items.1.qty'),
'is_adult': engine.compile('age(birth_date) >= 18'),
};
final result = evaluateCalculations(calculations, variables: answers);
print(result.values); // {subtotal: 2850, total: 3391.5, is_adult: true}
// Show/hide condition.
final showTaxId = engine.compile(r'$country in ["DZ", "TN"] and is_adult');
print(showTaxId.evaluateBool(variables: {...answers, ...result.values}));
print(showTaxId.variables); // {country, is_adult}
// Text piping.
final greeting = engine.template(
'Hello {{ name | upper }}, total {{ total | round(2) }} DZD. '
'Coupon: {{ coupon ?? "none" }}.');
print(greeting.render(variables: {...answers, ...result.values}));
}
Look at total and subtotal: total is declared first but depends on subtotal. evaluateCalculations works out the order for you. If two fields depend on each other, you find out before evaluating anything:
final order = orderCalculations({
'a': engine.compile('b + 1'),
'b': engine.compile('a + 1'),
'c': engine.compile('a * 2'),
});
order.cycles; // [[a, b]]
order.blocked; // {c}
If your answers do not live in a Map, pass a resolver. It receives the whole path as a key:
final expr = engine.compile('customer.age >= 18');
expr.evaluate(resolver: (path) => myStore.lookup(path)); // path == 'customer.age'
And a bad expression fails with a position you can show to the person who wrote it:
try {
engine.compile('1 + * 2');
} on ParseError catch (e) {
print(e); // ParseError: Unexpected '*' at line 1, column 5 ...
}
Semantics worth knowing
The README has a full precedence table and a list of rules. The ones that matter most in forms:
-
Empty answers. Ordering comparisons with
null, such as$age > 18when the age is empty, arefalse. Arithmetic withnullis an error whose message suggests??.sum,min,max,avgandcountskip empty values. -
Numbers. Integer arithmetic stays integral, and switches to a double instead of wrapping when a result leaves the exactly representable range.
/always returns a double.%is Euclidean, so-7 % 3is2. An infinite or NaN result is anEvalError. -
Rounding.
round(x, n)rounds half away from zero and corrects binary noise, soround(1.005, 2)is1.01. -
Dates.
date(),age(),dateDiff()andaddDays()acceptDateTimevalues and ISO-8601 strings.dateDiffcounts calendar days, so daylight-saving shifts do not matter.age()counts completed years. -
Precedence of
not. It binds looser than comparisons, sonot a == bmeansnot (a == b). Also,-2 ^ 2is-4. - Function names and keywords are case-insensitive.
The security model
The grammar has no member access, no method calls on values, no assignment and no loops. A path such as a.b is only a key handed to your resolver. Only built-in functions and the functions you register can run, and an unknown name is a ParseError at compile time.
Host values must be null, num, String, bool, DateTime, an Iterable or a Map. Anything else is an EvalError. ExpressionLimits sets maxLength (4096 by default), maxDepth (64) and maxTemplateLength (65536). Deep nesting becomes a ParseError, never a stack overflow, and long flat chains are evaluated iteratively.
Things to know
- No user-defined variables, lambdas or loops. That is by design.
-
No decimal type. Numbers are Dart
num, so0.1 + 0.2is0.30000000000000004. Useround(x, n)for display. -
No time-zone database. Dates are compared as the
DateTimeinstants you supply, andtoday()uses the engine's clock. - String ordering is by UTF-16 code units. It is not locale-aware collation.
- Number formatting in templates is not localised. No thousands separators or locale decimal marks. Register a custom filter for that.
-
If you come from a simple
{{field}}replacer: an unknown field now renders as an empty string instead of being left intact, the single-brace{field}form is not supported, and division by zero is anEvalErrorinstead of0.
Why I built it
Form logic is written by people outside the codebase and stored as text, so it has to be treated as untrusted input. A string replacer is safe but cannot compute a total or decide whether to show a question. Evaluating real code can do everything, which is the problem.
I wanted the middle: a language small enough to reason about, where the only things that can run are functions the app registered, where a broken or hostile expression gives a typed error with a line and a column, and where the engine can tell you which fields an expression reads and in which order to compute them. The limits and the iterative evaluation are there for the same reason: an expression from outside should never be able to crash the app that displays the form.
Get the code
The source, the README with the full language reference, the example and the tests are on GitHub: https://github.com/AbdeldjalilChougui/form_expressions. It's MIT licensed. Issues and pull requests are welcome.
Originally published at abdeldjalilchougui.biz.
Top comments (0)