npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-core

Entry 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?.();   // => true

In 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, valid and status are 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. createFieldContext takes a second, field-local source and the /reactive binding 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 empty promoCode is false.
  • 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 string

That 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 SignalContextWriteError

Two things are not routed through options.onError, in either adapter:

  • A parse error throws from bindFieldProperties itself, whatever the policy. Expressions are compiled eagerly, so visible: '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 /signals model, throws SignalContextWriteError with kind '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, a Date's setters and, where the context supplies Object, Object.assign, over a control's value or the /signals model, throw SignalContextWriteError with kind '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 through call, apply or bind. 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, and lastIndex on a regex you supplied.

    It holds through a call too. An assignment nested inside a call, [1].map(x => (country = 'CA')), reaches the bypass as SignalContextWriteError and is rethrown like a direct one. Up to eval-core 0.6.x the evaluator's own call wrapper re-raised it as a plain Error, which lost the class the bypass matches on, so it was routed by onError like 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 its eval-core floor 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 gone

From 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 subscription

It 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();                                // => false

Recompute 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.prototype that 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.constructor goes 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-core refuses to bind any of these names — an arrow parameter, a let or a destructured name — and throws Access 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 walk

The 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) } resolves ready to false, as createSignalContext resolves it, and a rule naming ready re-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:

  • disabled at /reactive. It ships at /signals and not here, and the asymmetry is the point rather than a gap. Applying it to a FormControl means calling control.disable(), which is three problems at once: it is a write back into the form rather than derived state; it emits on valueChanges by 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 Forms disabled is a schema rule over derived state and none of the three exists.
  • required and validators, which affect form validity rather than presentation.
  • Form state keys, FormArray and nested FormGroup, 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 await inside 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:production

Two 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.