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-signals

v0.4.0

Published

Angular signals for ng-eval expressions

Readme

@zvenigora/ng-eval-signals

Angular Signals for @zvenigora/ng-eval-core expressions.

Evaluate a JavaScript expression over a context of signals and get back a Signal that recomputes exactly when a key the expression actually read has changed.

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; this file documents the signals library.

Install

npm install @zvenigora/ng-eval-signals @zvenigora/ng-eval-core

Peer dependencies: @angular/core >=19 and @zvenigora/ng-eval-core >=0.11.0 <0.12.0. The floor is the write guard, which needs eval-core 0.11.0 to ask about a built-in method's write as well as a member write — see Writes are not supported.

Quick start

import { Component, signal } from '@angular/core';
import { createEvalSignal } from '@zvenigora/ng-eval-signals';

@Component({ /* … */ })
export class OrderComponent {
  readonly price = signal(10);
  readonly quantity = signal(3);
  readonly shipping = signal(5);

  readonly total = createEvalSignal('price * quantity', {
    price: this.price,
    quantity: this.quantity,
    shipping: this.shipping,
  });
}

Reading it from inside the component:

this.total();              // 30
this.quantity.set(4);
this.total();              // 40  — recomputed
this.shipping.set(0);
this.total();              // 40  — not recomputed: the expression never read `shipping`

That last line is the point of the library. The recompute is Angular's own dependency tracking, not a diff of the context: the expression is walked synchronously inside a computed(), and a context read ends in a signal call, so Angular records the dependency natively — per key, exactly as the walk performed it.

Called from a field initializer as above, the signal is inside an injection context and tears itself down with the component. See Lifetime for every other case.

How it fits together

| Symbol | What it is | | :--- | :--- | | createEvalSignal(expression, source, options?) | The primary API. Compiles once, returns an EvalSignal. | | EvalSignalService.create(…) | The same thing for callers outside an injection context. See Lifetime. | | createSignalContext(source, options?) | The context adapter on its own, for use with EvalService directly. | | SignalContextWriteError | Thrown when an expression assigns to a context key, or to a member of anything it did not create. | | EvalSignal<T> | Signal<T> plus dependencies, invalidate() and destroy(). The factory returns EvalSignal<unknown> — an expression's type is not knowable, so narrow at the call site. | | EvalSignalOptions | eval, equal, onError, trackDependencies, injector. |

source is a plain record whose values may be signals, plain values or functions — signals are unwrapped on read, everything else is passed through. It may also be an EvalContext you built yourself, which is handed to the walk unchanged.

Options

createEvalSignal('user.name', { user }, {
  eval: { caseInsensitive: true },   // forwarded to the context *and* the walk
  equal: isDeepEqual,                // computed() equality; default Object.is
  onError: 'undefined',              // 'throw' (default) | 'undefined' | (error) => value
  trackDependencies: true,           // collect `dependencies`; default false
  injector,                          // resolves services outside an injection context
});

onError defaults to 'throw', which is a computed()'s own behaviour: the error is cached and rethrown on every read until a dependency changes. 'undefined' renders a blank instead, and a function maps the error to a value. Two things are not routed through it — a parse error, which throws from createEvalSignal itself because the expression is compiled eagerly, and a SignalContextWriteError, which bypasses onError in every mode because an illegal assignment is a bug in the expression rather than a runtime failure to render around.

equal matters more than it looks for expressions producing objects or arrays: a fresh literal per recompute is never Object.is-equal to the last one, so every downstream consumer re-runs.

Dependency introspection

trackDependencies: true records what the last recompute read:

const price = signal(10);
const quantity = signal(3);
const shipping = signal(5);

const total = createEvalSignal('price * quantity', { price, quantity, shipping },
  { trackDependencies: true });

total();                  // 30
total.dependencies;       // Set { 'price', 'quantity' }

It is a debugging surface, not a reactive one — a plain getter, deliberately, so reading it neither triggers a recompute nor subscribes anything to it. It is off by default because registering the read hook turns on key resolution and path reconstruction at every read site for the whole walk, which a consumer who never reads dependencies should not pay for.

Under caseInsensitive, a path's first segment is spelled as your record spells it (since 0.2.0): 'PRICE * QUANTITY' over the record above reports price and quantity. Only the first segment — the key of the record — is respelled; the rest are property names inside a value and stay as the expression wrote them, so 'user.NAME' over { user } reports user and user.NAME. Without caseInsensitive every segment is as written, which is then also how the record spells it.

Setting it together with your own hook registry (eval: { hooks }) throws at createEvalSignal, rather than installing a read hook into a registry this library does not own and cannot hand you an unsubscribe for. The escape hatch is to install createDependencyTracker() on your registry yourself — it is the same tracker.

An empty set means one of three things, and they look identical. Either trackDependencies was never set; or it was set and the computed has not run yet, since it is lazy and reading dependencies does not trigger it — read the signal first; or it ran and genuinely read nothing. The middle one is the one nothing in the type or the option name hints at, and it is the usual answer when dependencies is empty on a signal you have not called.

What it reports is not what the signal recomputes on: reactivity is Angular's and is per signal, not per path. See Edge cases for where the two diverge.

Contexts that are not signal-backed

If the source has no reactive surface — a plain object you own and do not want to convert — nothing can tell the signal it changed, so you say so:

const plainObject = { price: 10, quantity: 3 };

const total = createEvalSignal('price * quantity', plainObject);

total();              // 30

plainObject.quantity = 4;
total.invalidate();   // the next read re-evaluates
total();              // 40

Coarse by construction: it re-evaluates regardless of what changed, or whether anything did. Calls collapse — three between two reads produce one recompute.

Lifetime

destroy() ends a signal: it drops the compiled callback, the context and the recorded dependencies. It is idempotent, and a destroyed signal reads undefined from that moment rather than from the next time a dependency happens to move. invalidate() afterwards is a no-op rather than a throw, because teardown order is not something a consumer controls.

Auto-teardown is not universal, and the rule is worth learning once:

| How you create it | DestroyRef teardown | | :--- | :--- | | createEvalSignal(…) in a field initializer, constructor, or other injection context | Automatic | | createEvalSignal(…, { injector }) | No — call destroy() yourself | | EvalSignalService.create(…) | No — call destroy() yourself |

options.injector resolves services; it does not scope lifetime. The injector a caller has to hand is routinely a long-lived one — EvalSignalService supplies the root injector — and a teardown callback registered there would be retained for that injector's whole life, once per signal ever created. So the service, whose entire job is supplying an injector, never auto-destroys:

export class PriceComponent implements OnDestroy {
  private readonly signals = inject(EvalSignalService);
  readonly price = signal(10);
  readonly quantity = signal(3);

  readonly total = this.signals.create('price * quantity', {
    price: this.price,
    quantity: this.quantity,
  });

  ngOnDestroy(): void {
    this.total.destroy();
  }
}

Use the service when you are outside an injection context — a lifecycle hook, a subscription callback, a plain method — where the free function would throw NG0203.

Writes are not supported

The keys of a signal context are read-only. An assignment throws SignalContextWriteError on the first read, naming the key and the expression:

const count = signal(1);

const broken = createEvalSignal('count = 5', { count });

try {
  broken();
} catch (error) {
  if (error instanceof SignalContextWriteError) {
    error.key;          // 'count'
    error.expression;   // 'count = 5'
  }
}

A signal write inside a computed() is illegal to Angular anyway (NG0600), and a derived value that mutates its own inputs has no stable value. The error is raised by this library so the message names the cause rather than surfacing an Angular error code from inside a TypeError.

A signal expression may write into what it created — object, array and regex literals, rest values, arrow functions — and not into anything it was given or got back from a call. Since 0.3.0, user.name = 'Bob' throws SignalContextWriteError with kind 'member' and key 'name', and so do user.n++, let u = user; u.name = 'Bob' and [user].map(u => (u.name = 'Bob')). Up to 0.2.x each of them wrote into the object your signal holds. A call's result counts as given even when it is new, because a call can as easily hand back your own object — [user].find(u => true) does.

To change something you were given, spread it into a literal first, and write into the copy:

const user = signal({ name: 'Ada', tags: ['a'] });

const renamed = createEvalSignal('let u = { ...user }; u.name = "Bob"; u', { user });
renamed();   // { name: 'Bob', tags: ['a'] } — user() is unchanged

const marked = createEvalSignal('let t = [...user.tags.map(s => s + "!")]; t[0] = "x"; t', { user });
marked();    // ['x']

A spread copies one level: u.tags above is still your array, and writing into it throws.

A built-in method that would write into what the expression was given is refused too, since 0.4.0. user.tags.push('x'), user.tags.sort(), splice and the rest of Array's mutators, the typed arrays' own, Map#set, Set#add and their removers, a Date's setters, and, when your context supplies Object, Object.assign(user, …) and Object's other mutators each throw SignalContextWriteError with kind 'method' and key naming the method, 'Array.prototype.push'. The message says what to call instead where JavaScript has it — toSorted, toReversed, toSpliced, with — or to spread the value into a literal and change the copy. Up to 0.3.x each of these calls went through and mutated your data. On a copy the expression made they still work: [...user.tags].sort(). Reached through call, apply or bind — [].push.call(user.tags, 'x') — a method is refused as a direct call of it is, and bind is refused when it binds.

Two things this does not catch:

  • A regex you supplied keeps its lastIndex behaviour. With a global or sticky regex, test and exec advance it, and match, replace and replaceAll given a global one reset it to 0. Refusing them would break the commonest rule there is, pattern.test(value).
  • A method you wrote is your own code, and runs as written, whatever it is called.

The cost is a record of each object an expression creates, kept only for a signal context, and a lookup of each function called in a table of the built-ins above. eval-core does both for a context that asks, and nothing extra for one that does not.

Async expressions

There is no async primitive in this release — createEvalSignalAsync is Phase 5 of the roadmap. You do not need one to call an async function: the walk is synchronous and returns the promise as the value, so the signal carries a promise you compose with in your own application, at your own Angular floor.

const user = createEvalSignal('loadUser(id)', { id, loadUser });

// The read goes in `params`, never in `loader`.
const userResource = resource({
  params: () => user() as Promise<User>,
  loader: ({ params }) => params,
});

Put the read in params. A resource's loader body runs untracked, so resource({ loader: () => user() }) computes once and then never reloads when id changes — a signal that silently stops updating. toSignal and rxResource take an Observable, so they need from(promise) first; resource is the only one that takes a promise directly.

Three limits apply until Phase 5 closes them:

  • An expression cannot use top-level await — the parser runs at ecmaVersion: 2020 without allowAwaitOutsideFunction, so await load(id) is a parse error. Inside an async arrow it parses and evaluates: (async () => await load(id))().
  • Nested promises are not resolved. A promise inside a returned object or array stays a promise; only eval-core's evaluateAsync walks a result resolving those, and this library does not use it.
  • onError never sees a rejection. The error handling is synchronous, so a rejecting promise passes straight through the signal and is yours to catch. Setting onError: 'undefined' does not give you a blank here.

The return type is EvalSignal<unknown> — the promise is a runtime shape you narrow to, not something the type says.

Before you use it

Four things that decide whether this library fits, rather than surprises you later.

  • Nested signals are not tracked. { user: signal({ name: 'a' }) } tracks at user; { user: { name: signal('a') } } tracks nothing — the member hop reads the signal function itself and never calls it. createSignalContext scans one level and warns about that shape in dev mode. Signals inside arrays, Maps, class instances, or behind getters are not scanned at all.
  • caseInsensitive has to be passed once, in the right place — and that place depends on who built the context. Through createEvalSignal, eval: { caseInsensitive: true } reaches both halves and is all you need. If you build the context yourself with createSignalContext and drive it through EvalService, you must pass it twice: to the adapter, which corrects identifier keys, and to the evaluation, which is what corrects property names (user.NAME).
  • Construct the context once and reuse it. createEvalSignal does this for you; it matters if you build contexts yourself. Tracking still works on a context rebuilt inside the computation — that part is Angular's — but you pay the allocation and the dev-mode nested scan on every read, and you lose EvalContext identity, so anything you put on the context (prior scopes, extra lookups) has to be rebuilt with it.
  • Async is not a first-class signal — see Async expressions above.

Edge cases you may hit

  • A closure that escapes the evaluation is not tracked. list.map(x => x.n) runs during the walk and tracks normally, but a closure called after the evaluation returns reads outside the reactive context. Inherent to Angular's model rather than to this library.
  • dependencies reports paths, with three limits. A computed member (obj[expr]) has no reconstructible path and contributes nothing; a name used as an arrow parameter anywhere in the expression is dropped everywhere in it; and under caseInsensitive only a path's first segment is spelled as your record does — 'USER.NAME' over { user } reports user.NAME. Up to 0.1.x the first segment was as the expression spelled it too. None of them affect reactivity.
  • Only your own record's keys are respelled. Under caseInsensitive a signal context names a key of its own record as the record spells it — in dependencies and in SignalContextWriteError.key, which since 0.2.0 is count for COUNT = 5 over { count } rather than undefined. A name something else resolves — an arrow parameter, a prior scope, a lookup you pushed onto the context — gets eval-core's answer, which for a lookup is the name as the expression wrote it.
  • Lookups resolve last. A key resolvable earlier in EvalContext.get's order shadows the source. The adapter starts with an empty original, but an empty object is not an absent one: up to eval-core 0.10.x, Object.prototype names — toString, valueOf, constructor, hasOwnProperty — resolved off the prototype. Since 0.11.0 eval-core refuses them, and the rest of its prototype-pollution blocklist, as an identifier or a member of this, so an expression naming one throws. A source key with one of those names is unreachable either way.
  • A signal holding undefined does not shadow. EvalContext.get treats undefined as "not found" and keeps going down its resolution order. Tracking is unaffected — the signal was called, so the dependency is recorded — but if you pushed another lookup onto the context after this one, that lookup answers instead. With a context this library built and nothing added to it there is nothing further to reach, so the read simply resolves to undefined.
  • The scope guard covers createEvalSignal, not a raw context. EvalContext.push and pop are public, so a function in your source can push a scope onto the context and never pop it, and every later read of that name finds the scope first. createEvalSignal unwinds the context to the depth it started at after every recompute; a context you drive directly through EvalService gets no such unwind. Up to 0.2.x this bullet described an eval-core defect that leaked an arrow function's parameter scope; eval-core fixed it in 0.4.0, and 0.3.0's peer floor of 0.10.0 excludes the versions that had it.

Using the adapter directly

createSignalContext is the context on its own, for callers who want EvalService:

import { EvalService } from '@zvenigora/ng-eval-core';

const price = signal(10);
const quantity = signal(3);
const evalService = inject(EvalService);

const context = createSignalContext({ price, quantity });
const total = computed(() => evalService.simpleEval('price * quantity', context));

total();   // 30

You keep native per-key tracking and lose what the factory adds: compile-once, dependencies, invalidate(), destroy(), the scope guard, and the caseInsensitive forwarding above.

Development

npx nx run eval-signals:test
npx nx run eval-signals:lint
npx nx run eval-signals:build:production