@zvenigora/ng-eval-forms
v0.4.0
Published
Angular form field properties from ng-eval expressions
Readme
@zvenigora/ng-eval-forms
Angular form field properties — visible and text, plus disabled at the /signals entry
point — driven by string expressions resolved at runtime.
{ name: 'state', visible: "country === 'US'" }That rule is a string. It can be fetched from an API, typed into a form-builder UI by an administrator, or stored in a database and versioned separately from your application — because nothing about it is compiled in.
The sources for this package are in the main @zvenigora/ng-eval
repo. Expression syntax, security and the evaluator's own options are documented in the
repository README; reactivity and the
EvalSignal type are documented in
@zvenigora/ng-eval-signals.
This file documents the forms library.
When not to use this library
Angular 22 ships Signal Forms, whose schema
already expresses conditional disabled, hidden, readonly, required and arbitrary
per-field metadata. Every one of those rules is a LogicFn — a TypeScript closure,
compiled into your bundle.
If your conditions are known at compile time and you are on Angular 22, use Signal Forms' schema and not this library. It will be faster, fully typed, and one dependency lighter.
What this library adds is the one thing a closure cannot be: a condition that is a
string, resolved at runtime. That covers form definitions served by an API, rules
authored by an end user, and rules versioned independently of the application release.
Against the obvious alternative for those cases — new Function(…) — it brings what
@zvenigora/ng-eval-core already is: a sandboxed evaluator with no eval, safe under a
strict CSP, with prototype-pollution blocking, dependency introspection and
case-insensitive resolution.
Install
npm install @zvenigora/ng-eval-forms @zvenigora/ng-eval-signals @zvenigora/ng-eval-coreEntry points
| Import | Contains | Requires |
| :--- | :--- | :--- |
| @zvenigora/ng-eval-forms | The shared core — context composition and the coercion and error-policy rules every adapter uses. Imports nothing from @angular/core or @angular/forms. | — |
| @zvenigora/ng-eval-forms/reactive | The Angular Reactive Forms adapter — FormGroup / FormControl. visible and text. | Angular 19+ |
| @zvenigora/ng-eval-forms/signals | The Angular Signal Forms adapter — schema() / form(). visible, text and disabled. | Angular 22+ |
Pick the adapter that matches the forms API you already use. The two are independent — the
same expression string means the same thing at both — and neither imports the other. The design
and the measurements behind /signals are in
the Phase 6 plan.
Versions
peerDependencies are declared per package, not per entry point, so the manifest holds one
set of ranges covering both adapters:
"peerDependencies": {
"@angular/core": ">=19.0.0",
"@angular/forms": ">=19.0.0",
"rxjs": "^7.8.0",
"acorn-walk": "^8.3.0",
"@zvenigora/ng-eval-core": ">=0.11.0 <0.12.0",
"@zvenigora/ng-eval-signals": ">=0.4.0 <0.5.0"
}The Angular range is at the floor, and the floor is /reactive's. /signals requires
Angular 22 or later — that is when @angular/forms/signals ships — and the manifest cannot
say so: narrowing it to >=22.0.0 would break every Reactive Forms consumer on 19–21 to add a
diagnostic for an entry point they do not import. An older consumer importing /signals gets
Cannot find module '@angular/forms/signals' from Angular's own exports map rather than
anything this library declares.
acorn-walk is new in 0.2.0 and imposes no new install. Both adapters walk the parsed
expression with it, through the shared guardIdentifiers — /signals since 0.2.0, /reactive
since 0.3.0. A consumer of this package already peer-depends on
@zvenigora/ng-eval-core, whose own peers include acorn-walk ^8.3.0, so npm 7+ has already
placed it; it is declared here because importing it undeclared resolves today by accident of
hoisting and would not resolve at all under pnpm's isolated layout. acorn itself is
deliberately not declared — this package imports no acorn symbol, and acorn-walk
depends on it directly.
Quick start — Reactive Forms (/reactive)
Everything from here to Lifetime is the /reactive adapter, except for three
sections that cover both: How it fits together, Coercion
and When a rule fails. Signal Forms has
its own section below.
import { Injectable, Injector, OnDestroy, inject } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { bindFieldProperties } from '@zvenigora/ng-eval-forms/reactive';
@Injectable()
export class OrderFormService implements OnDestroy {
private readonly injector = inject(Injector);
readonly form = new FormGroup({
country: new FormControl('CA'),
state: new FormControl(''),
});
readonly binding = bindFieldProperties(
[{ name: 'state', visible: "country === 'US'" }],
this.form,
{ injector: this.injector }
);
ngOnDestroy() {
this.binding.destroy();
}
}Reading the bound property — form and binding are the service's, and the injector in
the later examples is the same one it injected:
binding.fields['state'].visible?.(); // => false
form.controls.country.setValue('US');
binding.fields['state'].visible?.(); // => trueIn a template, where orderForm is the injected OrderFormService:
<form [formGroup]="orderForm.form">
<select formControlName="country"> … </select>
@if (orderForm.binding.fields['state'].visible?.()) {
<input formControlName="state" />
}
</form>The recompute is Angular's own dependency tracking, per key: the expression named
country, so it recomputes when country changes and not when any other control does. A
worked example of a whole form is in
docs/forms/worked-example.md.
How it fits together
| Symbol | Entry point | What it is |
| :--- | :--- | :--- |
| bindFieldProperties(schema, group, options) | /reactive | The primary API. Validates the schema, mirrors the group, returns a FormBinding. |
| FormBinding | /reactive | { fields: Record<string, FieldProperties>; destroy(): void }. |
| FieldSchema | /reactive | { name, visible?, text? } — one field's rules. |
| FieldProperties | /reactive | { visible?: EvalSignal<boolean>; text?: EvalSignal<string> }. Both optional, because the schema's rules are. |
| createControlSource(group, options) | /reactive | The mirror on its own, for callers composing contexts by hand. |
| createFieldContext(formSource, fieldSource, options?) | core | Composes one EvalContext out of a form-wide and a field-local source. |
| toVisible(value) / toText(value) | core | The two coercions, exported so an adapter or a test can apply the same rule. |
| ExpressionErrorPolicy | core | 'throw' \| 'undefined' \| ((error) => unknown). |
| applyErrorPolicy(run, policy?) | core | Runs run under a policy, new in 0.2.0. Rethrows a SignalContextWriteError whatever the policy says — see When a rule fails. Exported so an adapter applies the rule rather than reimplementing it. |
| guardIdentifiers(expression, node) | core | Throws if a parsed expression names a member of Object.prototype as an identifier, new in 0.3.0. Both adapters call it before compiling — see Prototype-shadowed identifiers are rejected. Exported for the same reason as applyErrorPolicy. |
| createExpressionRules(model, options?) | /signals | The primary API. Binds one model signal and returns evalVisible / evalText / evalDisabled. |
| ExpressionRules | /signals | The three registrars, each (path, expression, options?) => void. |
| ExpressionRuleOptions | /signals | { eval?: EvalOptions; onError?: ExpressionErrorPolicy }, accepted by the factory and per registration. |
| TEXT | /signals | The metadata key evalText writes through and field().metadata(TEXT) reads back. |
FieldProperties members are EvalSignal, not plain Signal, and both extra members
matter here: invalidate() is the escape hatch described under
Reactivity, and destroy() is what
Lifetime counts.
The field schema
interface FieldSchema {
readonly name: string;
readonly visible?: string; // coerced by truthiness
readonly text?: string; // coerced to a string
}That is the whole descriptor. It is deliberately not a schema language — see What is not here.
A field does not have to name a control. { name: 'state', … } is legal on a form with
no state control; the name is how you look the properties up in binding.fields, and
rules naming a missing field resolve undefined.
It is validated when you bind
A schema that arrives from a server can be malformed in ways an expression cannot be, so
bindFieldProperties checks five things and throws rather than letting them surface
later as an evaluation result nobody can trace:
| Rejected | Why it is not a warning |
| :--- | :--- |
| A duplicate field name | The second would silently replace the first. |
| A visible / text that is not a string | Reaches the compiler as something it cannot parse. |
| A field name that is a member of Object.prototype | See below. |
| A control in the group that is not a FormControl | Nested groups and FormArray are out of scope; the alternative is a group's aggregate object arriving where a value was expected. |
| An expression naming a member of Object.prototype | The same failure as the name, reached through the expression — see Expressions are validated too. |
Field and control names may not be constructor, toString, valueOf,
hasOwnProperty, __proto__ or any other member of Object.prototype. FormGroup
accepts such a key — it rejects only names containing a dot — and an expression naming one
cannot read it. With @zvenigora/ng-eval-core up to 0.10.x it read the prototype's value,
which is a function, which is truthy, so a visible rule would render precisely the field
that has no data, with no error anywhere; since 0.11.0 the evaluator refuses the name. This is
checked over the schema's names and the group's controls, because the two are different sets
and the second is worse: the control exists and its value is unreadable.
A control added later is checked too, for its name and its class. Since 0.3.0, an
addControl whose name is a member of Object.prototype, or whose control is a nested
FormGroup or a FormArray, leaves the control unmirrored and the mirror reports it with the
construction-time message above, once. A setControl that swaps a nested group or a
FormArray in for a mirrored control is refused the same way: the key stops being mirrored,
until a FormControl is swapped back.
That report is not a throw from addControl or setControl, which has already returned: the
mirror runs in a group.events subscriber, so rxjs reports the error from a timer — to
config.onUnhandledError when one is set, otherwise by rethrowing it from that timer, where
your host's global error handling receives it (rxjs 7.8.2's reportUnhandledError). The rest
of that change is applied first, and later changes are still mirrored.
Expressions are validated too
Since 0.3.0, bindFieldProperties also refuses a visible or text expression that names a
member of Object.prototype as an identifier — with the rule and the message /signals uses,
because it is the same function, guardIdentifiers:
const form = new FormGroup({ country: new FormControl('CA') });
bindFieldProperties(
[{ name: 'city', visible: 'constructor' }],
form,
{ injector }
); // throws: Expression 'constructor': identifier 'constructor' is a member of Object.prototype …Without it, with @zvenigora/ng-eval-core up to 0.10.x, the identifier resolved off
Object.prototype to a function, a function is truthy, and truthiness means visible: the same
failure the field-name check above prevents, reached through the expression instead of through
the name. Since eval-core 0.11.0 the evaluator refuses the identifier too, but each time the
rule runs, where onError decides what the error becomes; this check refuses it once, when the
rule is bound. Up to 0.2.x this bound without complaint
and rendered the field — against a form with no city and no constructor, with nothing
logged — while /signals threw on the same string. Both entry points now refuse it.
No rule it refuses ever produced a value from your data. One that reads such a name read the
prototype's function: for it to read data, a field or a control would have to carry the name,
and both are refused above. One that binds such a name itself — an arrow parameter,
[1].some(valueOf => valueOf), or a let — bound here, but threw on every evaluation, because
@zvenigora/ng-eval-core refuses to bind those names
(Access to dangerous property "valueOf" is blocked …), and the default onError rendered that
as a blank. What changes is that such a schema now fails at bind time instead. Rename the
binding. The check's bounds are /signals' too — see
Prototype-shadowed identifiers are rejected. A
rule that does not parse is not checked here; it throws when it is compiled, as before.
What an expression can name
The values of the form's controls, by control name. Every control is mirrored, including disabled ones — a disabled control is excluded from its parent's aggregate value, but its own value is still readable here.
Not addressable in this release:
- Form state.
touched,dirty,pristine,validandstatusare not keys. The shape they should take is unresolved (a flat record cannot hold per-field state without either nesting signals or collapsing everything into one signal, which destroys the per-key tracking the design rests on), and real conditional-visibility rules read sibling values. - Nested groups and
FormArray. Flat forms only, and enforced rather than documented — see the validation table above. - Field-local keys.
createFieldContexttakes a second, field-local source and the/reactivebinding passes{}. What a field-local key set should contain is not specified anywhere yet, and inventing keys to fill a parameter is how a public surface acquires members nobody chose.
An empty control reads as absent
EvalContext.get treats undefined as absent at every step, and there is no way to tell
"no such key" from "key bound to undefined". An empty FormControl therefore behaves as
though the field were not there:
visible: "promoCode"on an emptypromoCodeisfalse.- Where a field-local key and a form key collide, the field wins while its value is not
undefined— an empty field falls through and the form value shows through instead.
This is a limitation, not a design goal; distinguishing the two would need a sentinel
threaded through EvalContext.get, which belongs to @zvenigora/ng-eval-core.
Coercion
visible is JavaScript truthiness, and nothing cleverer:
toVisible(undefined); // => false — an empty or missing field is not visible
toVisible(0); // => false
toVisible('false'); // => true — a non-empty stringThat third line is the one to know about, and it is the one a form-builder UI storing every
value as a string will hit. It is truthiness rather than parsing: a coercion that read
'false' as false would then owe an answer for 'no', '0' and 'off', and JavaScript
has one for none of them. Write visible: "flag === 'true'" if that is what you mean.
text is String(value), with null and undefined mapping to '':
toText(null); // => '' — never the literal text "null"
toText(undefined); // => ''
toText(0); // => '0' — not ''
toText(false); // => 'false'Every other falsy value stringifies normally. Mapping all falsy values to '' is the
obvious way to write this rule wrongly, and it blanks a field whose value is legitimately
zero.
The coercion sits in front of the signal rather than inside the walk, so a destroyed
property still answers false / '' rather than leaking undefined into a template.
When a rule fails
options.onError decides, and the default is 'undefined' — the opposite of
@zvenigora/ng-eval-signals' default:
| Value | Effect |
| :--- | :--- |
| 'undefined' (default) | The property resolves undefined, which coerces to false / ''. |
| 'throw' | Rethrow. |
| (error) => unknown | Your function's return value becomes the property's value. |
The default differs from upstream's because the author differs. An expression that fails in
@zvenigora/ng-eval-signals was written by the developer reading the stack trace; an
expression that fails here may have been typed into a form builder by an end user, and the
right response to "the administrator wrote a bad rule" is a field that does not render, not
an application that throws on every change-detection pass.
applyErrorPolicy is that decision on its own, exported so an adapter applies the rule rather
than reimplementing it — and so you can apply it to a rule invocation you drive yourself:
import { applyErrorPolicy } from '@zvenigora/ng-eval-forms';
// Neither adapter re-exports it, so it comes from its own package.
import { SignalContextWriteError } from '@zvenigora/ng-eval-signals';
const boom = () => { throw new Error('bad rule'); };
applyErrorPolicy(boom); // undefined — the default
applyErrorPolicy(boom, 'undefined'); // undefined
applyErrorPolicy(boom, () => '—'); // '—'
applyErrorPolicy(boom, 'throw'); // rethrows Error('bad rule')
applyErrorPolicy(() => 'fine'); // 'fine' — nothing thrown, nothing to police
// A write violation is rethrown whatever the policy says:
const write = () => { throw new SignalContextWriteError('country', 'country = "CA"'); };
applyErrorPolicy(write, 'undefined'); // throws SignalContextWriteErrorTwo things are not routed through options.onError, in either adapter:
A parse error throws from
bindFieldPropertiesitself, whatever the policy. Expressions are compiled eagerly, sovisible: 'country ==='fails at bind time — and the binding releases everything it had already built before rethrowing.An assignment throws
SignalContextWriteError, in every mode. Expression keys are read-only, and a write violation is static — illegal on every recompute with every dataset. Swallowing it under the default would hand you a silent blank for a syntax bug in the rule itself.So does a write into the form's data, since 0.3.0. A rule may write into what it created — object, array and regex literals, rest values, arrow functions — and not into anything it was given.
address.city = 'x', over a control whose value is{ city: 'Rome' }or over the/signalsmodel, throwsSignalContextWriteErrorwithkind'member', in every mode; up to 0.2.x it wrote into the very object Angular holds.So does a built-in method that would write into it, since 0.4.0.
tags.push('x'),tags.sort(),splice,Map#set, aDate's setters and, where the context suppliesObject,Object.assign, over a control's value or the/signalsmodel, throwSignalContextWriteErrorwithkind'method', in every mode, naming the method and what to call instead —toSorted,toReversed,toSpliced,with, or a spread into a literal. So does each one reached throughcall,applyorbind. Up to 0.3.x the call went through and mutated the form's data. On a copy the rule made they still work:[...tags].sort(). Not caught: a method you wrote, andlastIndexon a regex you supplied.It holds through a call too. An assignment nested inside a call,
[1].map(x => (country = 'CA')), reaches the bypass asSignalContextWriteErrorand is rethrown like a direct one. Up toeval-core0.6.x the evaluator's own call wrapper re-raised it as a plainError, which lost the class the bypass matches on, so it was routed byonErrorlike any other failure: under the default, a blank field with nothing in the console. The peer range admitted those versions until 0.3.0 raised itseval-corefloor to 0.10.0.
Reactivity, and its two holes
A property recomputes when a control it named emits on valueChanges. The mirror
subscribes per control, never to the group, so a disabled control stays readable, and
tracking is per key rather than per form.
Two cases the mirror cannot see. Both take the same hatch — invalidate() — and there is
one corner at the end of the second that no hatch reaches:
{ emitEvent: false } freezes a value
setValue, patchValue, reset, enable and disable all accept it, and it does what it
says: no event, so no recompute, so a stale property with no error. There is no fix
available from this side — the observable is the only signal there is. invalidate() is
the documented hatch for exactly this case:
const form = new FormGroup({ country: new FormControl('CA') });
const binding = bindFieldProperties(
[{ name: 'country', text: 'country' }],
form,
{ injector }
);
binding.fields['country'].text?.(); // => 'CA'
form.controls.country.setValue('US', { emitEvent: false });
binding.fields['country'].text?.(); // => 'CA' — stale
binding.fields['country'].text?.invalidate();
binding.fields['country'].text?.(); // => 'US'(The first read is not decoration. A property is a computed(), so one that has never been
read has nothing cached and answers with whatever the form holds now — the staleness
starts at the first read, not at the write.)
The key set is not reactive
Values are reactive; the set of keys is not. A property that already read age recorded
a dependency on that key, and removeControl('age') does not itself produce a recompute:
// A control set that changes at runtime needs an index-signature type:
// `addControl` / `removeControl` on a group typed from an object literal
// accept only the keys that literal had.
const form = new FormGroup<{ [key: string]: AbstractControl }>({
age: new FormControl(30),
});
const binding = bindFieldProperties([{ name: 'age', text: 'age' }], form, { injector });
binding.fields['age'].text?.(); // => '30'
form.removeControl('age');
binding.fields['age'].text?.(); // => '30' — the value it last read
binding.fields['age'].text?.invalidate();
binding.fields['age'].text?.(); // => '' — the key is goneFrom the next recompute onward it is reactive again against whatever now holds the key, so
one invalidate() per structural change is the whole obligation. The properties to
invalidate are the ones whose expressions name the affected key.
And there is one corner with no hatch at all: addControl / removeControl /
setControl called with { emitEvent: false } suppress group.events, so the mirror
never learns the control set changed. invalidate() cannot rescue that — re-running the
expression finds the same stale mirror. Do not pass { emitEvent: false } to the
control-set methods on a mirrored group.
Lifetime
destroy() is yours to call. Every signal a binding creates is built with an explicit
injector, which means none of them registers its own teardown:
const binding = bindFieldProperties(schema, form, { injector });
// …
binding.destroy(); // releases every property signal and every subscriptionIt is idempotent, and it releases the whole mirror — every per-control subscription and the
group.events one — in a single call.
There is a net under that, and it is a net rather than a substitute: the binding
registers its teardown on the DestroyRef of the injector you passed, so a binding wired to
a component or route injector is released when that injector dies even if nobody called
destroy(). A binding built on the root injector is released at the end of the
application and no sooner.
Constructed, not computed
Build the binding in a service or a factory, and call destroy() from the same place.
bindFieldProperties uses toSignal internally, which opens with
assertNotInReactiveContext — so calling it from inside an effect() or a computed()
throws, with an error naming toSignal and nothing naming this library. If you see
NG0602 and no toSignal of your own, this is why.
options.injector is required for the same reason it is required upstream: an optional
one would silently pick up the ambient injection context when there is one, and teardown
would then run at a time that varied with where the call happened to sit.
Signal Forms — /signals
Requires Angular 22 or later. Import from @zvenigora/ng-eval-forms/signals.
One factory, bound to one model signal, returning three registrars you call from inside a
schema() body beside Angular's own rules:
import { signal } from '@angular/core';
import { form, schema } from '@angular/forms/signals';
import { TEXT, createExpressionRules } from '@zvenigora/ng-eval-forms/signals';
interface Order {
country: string;
state: string;
zip: string;
orderTotal: number;
}
const model = signal<Order>({ country: 'CA', state: '', zip: '', orderTotal: 80 });
const rules = createExpressionRules(model);
const orderSchema = schema<Order>((p) => {
rules.evalVisible(p.state, "country === 'US'");
rules.evalText(p.zip, "orderTotal >= 100 ? 'Free shipping' : 'Standard'");
rules.evalDisabled(p.zip, "country !== 'US'", { reason: 'ZIP is US-only' });
});
const f = form(model, orderSchema);There is no FormBinding here and nothing to look a field up in: the rules register through
Angular's own primitives, so you read them back off Angular's field state.
f.state().hidden(); // => true
f.zip().metadata(TEXT)?.(); // => 'Standard'
f.zip().disabled(); // => true
f.zip().disabledReasons().map((r) => r.message); // => ['ZIP is US-only']
model.set({ country: 'US', state: '', zip: '', orderTotal: 120 });
f.state().hidden(); // => false
f.zip().metadata(TEXT)?.(); // => 'Free shipping'
f.zip().disabled(); // => falseRecompute is Angular's own dependency tracking, per key: a rule that named country
re-evaluates when country changes and not when any other key does. A worked example of a whole
form is in
docs/forms/worked-example-signals.md.
The three registrars, and their polarity
| Registrar | Registers | A true expression means |
| :--- | :--- | :--- |
| evalVisible(path, expression, options?) | Angular's hidden, inverted | the field is visible |
| evalText(path, expression, options?) | metadata(path, TEXT, …) | — |
| evalDisabled(path, expression, options?) | Angular's disabled, uninverted | the field is disabled |
evalVisible is named after the property, not after Angular's rule, and the inversion lives
inside the library. That is the whole reason it exists rather than a thin hidden wrapper:
the same rule string means the same thing at both entry points, so country === 'US' is "show
it when the country is US" under /reactive's visible and under evalVisible alike, with no
consumer's expression carrying a !. evalDisabled keeps Angular's polarity instead, because
/reactive ships no disabled — there is no second entry point for its expressions to agree
with, and true disabling is what an author expects.
reason is a static string, never expression-derived. Angular's disabled config is a
single field: when returns boolean | string, and a truthy string is both "disabled" and
"the reason". A registrar forwarding the expression's value raw would disable a field on the
string 'false' with the reason "false" — Coercion's truthiness trap in a
new shape. Keeping the expression boolean and sourcing the reason from the registration is what
kills it. A dynamic reason is out of scope for the same reason.
Lifetime — there is nothing to destroy
Unlike /reactive, this entry point creates no EvalSignal, registers nothing
with a DestroyRef, and has no destroy(). Angular owns the field tree's lifetime and the
rules die with the schema. What is retained, on two different clocks: per factory, one
private memo of computeds, one per key and casing rule, bounded by the union of keys the rules
name — twice over at most, when registrations disagree about caseInsensitive — it lives as
long as the createExpressionRules value does, which for a factory built at module scope is
longer than any one form; and per rule per form(), one evaluation context and one compiled
expression, garbage when that form is.
Reuse a schema function, not a schema value
The registrars close over the factory's model, and a factory is bound to one model. So the supported way to share rules across forms is a function of the rules, called once per form:
import { ExpressionRules } from '@zvenigora/ng-eval-forms/signals';
const makeSchema = (rules: ExpressionRules) =>
schema<Order>((p) => {
rules.evalVisible(p.state, "country === 'US'");
});
const modelA = signal<Order>({ country: 'US', state: '', zip: '', orderTotal: 0 });
const modelB = signal<Order>({ country: 'CA', state: '', zip: '', orderTotal: 0 });
const fA = form(modelA, makeSchema(createExpressionRules(modelA)));
const fB = form(modelB, makeSchema(createExpressionRules(modelB)));Sharing a schema value across models compiles, runs, and is wrong. Angular re-invokes the
schema body once per form(), so each form does mint its own contexts — but every rule inside
them still reads the model the factory was given. A schema built from
createExpressionRules(modelA) and passed to form(modelB, …) yields a fully functional form B
rendering against form A's data, silently, with no error anywhere.
Prototype-shadowed identifiers are rejected
An expression naming a member of Object.prototype — constructor, toString, valueOf,
hasOwnProperty, isPrototypeOf, propertyIsEnumerable, toLocaleString, and the five
__proto__-style accessors Object.getOwnPropertyNames(Object.prototype) also returns —
throws, naming the expression and the identifier:
const bad = schema<Order>((p) => {
rules.evalVisible(p.state, 'constructor');
});
form(model, bad); // throws: identifier 'constructor' is a member of Object.prototype …Without the check, with @zvenigora/ng-eval-core up to 0.10.x, the identifier resolved off the
prototype to a function, a function is truthy, and evalVisible would render precisely the
field that has no data — with nothing logged. Since eval-core 0.11.0 the evaluator refuses it
on each evaluation instead, where onError decides what the error becomes. The fix is to rename
the model key.
The throw arrives from form(), not from schema(). The schema body is what registers, and
Angular invokes that body once per form() — so building the schema is silent and every
form() made from it throws. /reactive makes the same check, beside its name-based ones, at
the bindFieldProperties(…) call instead, so the two entry points reject at different times.
Three bounds on the check, none of them obvious from the paragraph above:
- It is the expression that is checked, never the model. A model key named off
Object.prototypethat no expression names stays unreadable and unreported. That is harmless — a key is only ever read because some expression names it — but it is not covered, and it is the one thing/reactive's control-name check catches that this does not. - A member expression is not this check's business.
user.constructorgoes to@zvenigora/ng-eval-core's prototype-pollution guard, under the rules documented there. - It refuses a name the expression binds itself, whether or not it reads it.
'[1].map(valueOf => valueOf)'and'[1].map(valueOf => 1)'both throw at registration, as do'let toString = 1; 2'and'(({ valueOf }) => 1)({})'. That costs nothing: the expression could never evaluate, because@zvenigora/ng-eval-corerefuses to bind any of these names — an arrow parameter, aletor a destructured name — and throwsAccess to dangerous property "valueOf" is blocked …on every evaluation. Up to 0.2.x this bullet called the refusal an over-rejection, saying the parameter would have resolved correctly; measured, it does not. And up to 0.2.x a name bound and never read registered, because the walk did not visit a binding — then threw that same error on every evaluation. Rename the binding.
/reactive makes the same check on its expressions, since 0.3.0 — see
Expressions are validated too.
caseInsensitive per registration
ExpressionRuleOptions — { eval?, onError? } — is accepted by the factory and by each
registration, and registration wins per key: a registration supplying only onError keeps
the factory's eval, and vice versa. Neither key is deep-merged, so a registration's eval: {}
turns off a factory's caseInsensitive for that rule.
The resolution is exact for both keys. caseInsensitive has to reach three places — the
factory's key memo, the evaluation context each rule is given, and the walk — and a
registration's value reaches all three, so an identifier and a property name obey the same
rule:
interface Profile {
country: string;
address: { name: string };
label: string;
}
const profile = signal<Profile>({ country: 'US', address: { name: 'HQ' }, label: '' });
const rules = createExpressionRules(profile); // caseInsensitive off at the factory
const profileSchema = schema<Profile>((p) => {
rules.evalText(p.label, 'Country + address.NAME', {
eval: { caseInsensitive: true }, // on for this registration
});
});
form(profile, profileSchema).label().metadata(TEXT)?.();
// => 'USHQ'
// Country an *identifier* key, corrected through the memo and the context
// address.NAME a *property* name, corrected by the walkThe memo holds one entry per key and casing rule, so two registrations on one factory that
name the same key under different settings each resolve it under their own. Up to 0.3.0 only
the walk saw a registration's value: the memo and the context were built from the factory's
options, and the block above printed 'undefinedHQ', one expression obeying two casing rules.
Two things that are not available here
Neither is about resolution. Every key an expression can name resolves, at any spelling
caseInsensitive allows, whether or not the model held it when the form was built — there is
no invalidate() at this entry point and nothing to call it on.
- The nested-signal diagnostic does not reach
/signals. A nested property holding a signal —{ user: { name: signal('a') } }— is read un-called by the member visitor, and@zvenigora/ng-eval-signals' dev-mode warning never fires here. That shape is precisely what the upstream scan reports, so the check is neither switched off nor blind to it: it runs over the source record a context is built from, and this adapter hands it an empty one. Every model key resolves through a lookup instead, where nothing scans. Widening the scan would not recover it. A top-level key holding a signal is not this case:{ ready: signal(false) }resolvesreadytofalse, ascreateSignalContextresolves it, and a rule namingreadyre-runs when that signal changes. - The form's key set is not enumerable from upstream, because the memo is deliberately
private. That is the fix rather than the cost: an enumerable record is exactly what froze a
case-insensitively matched key to its first spelling for the life of the form. It is the
counterpart to
/reactive's key-set caveat and the milder one — nothing here goes stale.
What is not here
Deferred deliberately, each additive when it arrives:
disabledat/reactive. It ships at/signalsand not here, and the asymmetry is the point rather than a gap. Applying it to aFormControlmeans callingcontrol.disable(), which is three problems at once: it is a write back into the form rather than derived state; it emits onvalueChangesby default, so a rule naming its own field re-enters its own input and whether that converges depends on the expression; and it removes the value from the parent's aggregate. Under Signal Formsdisabledis a schema rule over derived state and none of the three exists.requiredand validators, which affect form validity rather than presentation.- Form state keys,
FormArrayand nestedFormGroup, and field-local keys — see What an expression can name. - Anything asynchronous. Every property is derived synchronously from control values,
which is all the mirror supplies; there is no
awaitinside an expression and no async variant of the binding. The upstream question is@zvenigora/ng-eval-signals'.
Development
npx nx test eval-forms
npx nx run eval-forms:build:productionTwo readme-examples.spec.ts files execute the runnable examples in this document, and the
split is not quite by folder: the one under reactive/src/lib/ covers the /reactive blocks,
the shared core's two Coercion blocks and the
worked example;
the one under signals/src/lib/ covers the /signals blocks and the /signals
worked example,
plus the one /reactive block whose subject is the difference between the two entry points,
because that claim is a pair and splitting it would let either half drift alone. So a documented example that stops working fails
the suite rather than shipping. The template and manifest blocks are not executable and are not
covered.
