SignalTree error codes
August 11, 2026 · View on GitHub
Every SignalTree error and dev-mode warning carries a stable, greppable code in
brackets, e.g. [ST2001]. Search the code — in your editor, a stack trace,
or this file — to find the cause and fix. Codes are append-only and never
reused.
ST1xxx— core: tree creation, updates, lifecycle, enhancersST2xxx— entity collections and markers (mostly dev-mode guardrails)
Dev-mode warnings (
ST20xx) fire only whenngDevModeis true; they never run in production builds. They exist to catch mistakes — especially in AI-generated code — early.
ST1xxx — core / update / enhancer
| Code | Meaning | Common cause → fix |
|---|---|---|
| ST1001 | null/undefined | A value that must be defined was null/undefined. Check the path/argument. |
| ST1002 | circular reference | State contains a cycle. SignalTree state must be a tree (acyclic). Break the cycle or store an id reference. |
| ST1003 | updater invalid | An updater passed to .update() wasn't a function (current) => next. |
| ST1004 | lazy fallback | Lazy materialization fell back to eager — informational. |
| ST1005 | signal creation failed | A leaf signal couldn't be created — usually a non-serializable/proxy value at a leaf. |
| ST1006 | update path not found | A merge/update targeted a path that doesn't exist in the tree shape. |
| ST1007 | update failed | An update threw mid-apply; see the chained error. |
| ST1008 | rollback failed | A transactional rollback couldn't restore prior state. |
| ST1009 | cleanup error | tree.destroy() / teardown threw; usually safe to ignore. |
| ST1010 | unknown preset | An unrecognized config preset name was passed. |
| ST1011 | strategy select | Internal: update-strategy selection issue. |
| ST1012 | tree destroyed | Operating on a tree after destroy(). Create a new tree. |
| ST1013 | update transaction | Transactional update issue; see chained error. |
| ST1014 | batching disabled | A batching API was called without the batching() enhancer. Add .with(batching()). |
| ST1015 | memoize disabled | Removed in 9.0.1 — use Angular computed() directly. |
| ST1016 | middleware missing | Middleware API used without the providing enhancer. |
| ST1017 | entity helpers missing | Entity helper used without the entity feature available. |
| ST1018 | time travel missing | .undo()/.redo() used without the timeTravel enhancer. Add it from @signaltree/core. |
| ST1019 | optimize missing | Optimization API used without @signaltree/enterprise. |
| ST1020 | update optimized missing | updateOptimized() requires the enterprise() enhancer. |
| ST1021 | cache missing | Cache API used without the providing enhancer. |
| ST1022 | performance disabled | Performance API used without it enabled in config. |
| ST1023 | enhancer order failed | Enhancers couldn't be ordered — check declared requires/provides. |
| ST1024 | enhancer cycle | Two enhancers require each other. Break the dependency cycle. |
| ST1025 | enhancer requirement missing | An enhancer requires another that isn't applied. Add the prerequisite .with(...). |
| ST1026 | enhancer provides missing | An enhancer declared a capability it didn't provide. |
| ST1027 | enhancer failed | An enhancer threw while applying; see chained error. |
| ST1028 | enhancer not a function | A value passed to .with() wasn't an enhancer function. |
| ST1029 | no Angular context (effect) | effect() was created outside an injection context. Call within a constructor/runInInjectionContext. |
| ST1030 | no Angular context (subscribe) | Subscription created outside an injection context. |
| ST2031 | a node held across changeId resolves undefined | changeId(from, to) deliberately drops the old per-entity signal rather than aliasing it — an alias would be shared with a future addOne({ id: from }), which is a worse failure than a stale node. So a node held from byId(oldId) reads undefined forever, and did so silently. Re-read with byId(newId), or hold the id and call byId(id()) at the point of use rather than holding the node across a rekey. The shape that hits it is a long-lived selectById(tempId) closing over the pre-server id during an optimistic create. Dev-only, reported once per retired id. |
| ST2032 | timeTravel({ maxHistorySize }) cannot support undo | maxHistorySize is a buffer LENGTH, not a step count: N retained entries yield N-1 undo steps, because the oldest retained entry is the state you land ON rather than a step you spend. MEASURED after 10 writes — omitted: 10 steps, 5: 4, 2: 1, 1: 0, 0: 0. So 0 (which reads as "no limit") and 1 (which reads as "one step") both left canUndo() permanently false, -1 additionally drove getCurrentIndex() to -1, and NaN was silently UNBOUNDED because length > NaN is never true. A silently dead undo button is the same failure class as a phantom step — the API reports undo is available and it does nothing. Any value below 2, or non-finite, now falls back to the default of 50 and reports this. Dev-only. |
| ST1031 | invalid security config | security was given a raw config object. Pass security(config) from @signaltree/core/security — the validator is injected so it tree-shakes when unused. |
| ST1032 | lazy not injected | useLazySignals: true does nothing on its own. Pass lazy: lazy() from @signaltree/core/lazy; the machinery is injected for the same reason. |
ST2xxx — entity / markers (dev-mode guardrails)
| Code | Meaning | Cause → fix |
|---|---|---|
| ST2001 | entityMap entity has no id | Entities resolved to null/undefined id, so they collide under one key. Give entities an id field, or entityMap({ selectId: (e) => e.yourKey }). |
| ST2002 | entityMap unknown method | A method from another library (Akita .upsert/.add, Elf .addEntities/.setProps, RxJS .next) was called. Use the SignalTree equivalent named in the warning (upsertOne, addMany, …). |
| ST2003 | ref-identical write skipped | A merge write passed a value reference-identical to the current value — a no-op. You likely mutated an object/array in place; return a NEW reference (spread/slice/map) so the change is observed. |
| ST2004 | entityMap raw load function | (v12.0.0+) entityMap({ load: fn, staleTime, swr, tags, … }) with a raw function on load is rejected — wrap it with loader() from @signaltree/core and move the loader-family options (staleTime/swr/tags/persist/equal/lazy) into its second argument: entityMap({ load: loader(fn, { staleTime, tags }) }). |
| ST2005 | signalForm + marker asyncValidators | Bridging a form() marker that has asyncValidators configured into signalForm() throws — the marker's async path and Signal Forms' validateAsync/validateHttp can't both drive one form. Pick one authority: remove asyncValidators from the marker and use Signal Forms' validateAsync/validateHttp, or don't bridge and drive the form through the marker's own validateField()/submit(). |
| ST2006 | form() history not from history() | (v13.0.0+) form({ history: <value> }) was given something other than history()'s return value. Import history from @signaltree/core and pass history({ capacity, exclude }) — a raw config object on history is not accepted. |
| ST2007 | derived value dropped | (13.2.0+) A value in a .derived(...) object was neither a signal, a derived marker, nor a plain object to merge, so it was not added to the tree. Most often this means your app loads two copies of @angular/core: each has its own Symbol(SIGNAL), so isSignal() inside @signaltree/core rejects a computed() your code created, and every derived value is silently discarded. The warning distinguishes the two cases. Fix the duplicate in your bundler (Vite: resolve: { dedupe: ['@angular/core'] }; Jest: moduleNameMapper) or hoist @angular/core to one version. |
| ST2008 | value omitted from snapshot | (13.4.0+) tree()/unwrap() skipped a value because it is a function that is neither an Angular signal nor a node accessor, so the key is missing from the snapshot — and from anything built on it (serialize, persistence(), devtools, audit). Usually a materialized marker that did not conform to either shape. |
| ST2009 | applyState replaced a live value | (13.4.0+) applyState (the devtools state-replay path) overwrote a callable that is neither a signal nor a node accessor with a raw value. The signal at that path is gone and reading it will throw. |
| ST2010 | write to a key outside the initial shape | (13.4.0+) A tree's signal graph is built from the object passed to signalTree(). A write to a key that was never in that shape has no signal to land on and is discarded. Add the key to the initial state — a declared-but-optional TypeScript property is not enough, the key must be present at construction. |
| ST2011 | marker in a lazy tree | (13.4.0+) A marker (stored(), status(), entityMap(), form(), …) was reached inside a lazy tree. Lazy trees resolve values through a proxy that never runs marker materialization, so the marker stays a placeholder: unusable as a signal and dropped from every snapshot. Use markers only in eagerly-built trees, or move the marker out of the lazy subtree. |
| ST2014 | write to a branch discarded | A branch position was written with a non-object value (tree.$.user.set(null), or an updater returning null/a Promise — the forgotten await). A branch has no signal of its own, so the write goes nowhere. Write the leaves, or use a marker if that position should hold a value. |
| ST2016 | prototype-pollution key rejected | A state key named __proto__, constructor or prototype was rejected at construction. If the payload came from JSON.parse, this is an attempted prototype-pollution (CWE-1321) and the key is dropped deliberately. |
| ST2017 | enhancer forward target missing | An enhancer built a new tree object but could not find the method it was forwarding to. Enhancers must copy property DESCRIPTORS (copyTreeProperties), not Object.assign — every tree method is non-enumerable, so Object.assign silently drops all of them. |
| ST2018 | collection stored as a plain array leaf | (13.5.0+) A leaf holds 32+ objects carrying a stable id/_id/uuid/key. As a plain array leaf every update rebuilds the whole array and every equality check walks it. Measured on 1000 updates to a 50k collection: array leaf 49.80 ms vs entityMap 1.63 ms (~30x), which puts the array leaf at parity with the immutable stores SignalTree otherwise beats. Fix: entityMap({ selectId: (e) => e.id }). If the array is genuinely read-only or always replaced wholesale, that is fine — wrap it in compared() with your own comparator to silence the warning. Dev-only, deduped per key, bounded 64-element sample. |
| ST2019 | invalid compared() comparator | (13.5.0+) compared(value, equal) was given a non-function comparator, or byKeys() was called with no keys (with none, every value compares equal and no write would ever notify). |
| ST2020 | duplicate stored() key | (14.0.0+) Two stored() markers were created for the same storage key. Each call makes its OWN signal — they do not observe each other, so one holds a stale value and they race on write, last-writer-wins. Create the marker once and share the tree node, or use distinct keys. Not merged automatically because two calls may carry conflicting defaultValue/version/migrate with no correct merge. |
| ST2021 | marker inside an array | (14.0.0+) A marker (stored(), status(), entityMap(), …) was found inside an array. Array elements are never traversed, so the marker is never materialized: it stays a raw object, it is not a signal, and writes to it are lost. Markers belong at object positions; for a keyed collection use entityMap({ selectId }). |
| ST2022 | marker registered without declaring state | (14.0.0+) registerMarkerProcessor() was called with neither snapshot nor transient: true, so nothing ever answered "what of this marker is state?" Its value is dropped from every snapshot — tree(), persistence(), devtools, audit and undo/redo — silently. Pass { snapshot, hydrate }, or { transient: true } if it deliberately has no restorable state. Checked at registration rather than at materialization, because materializeMarkers swallows create() throws and a guard there would fail open. Affects custom markers only. |
| ST2023 | marker can be snapshotted but never restored | (14.0.0+) A marker declares snapshot but no hydrate, and its node is not a writable signal. It is captured by every snapshot consumer and every attempt to write it back is silently discarded, so tree(tree()) loses its value. [ST2022] cannot see this — a snapshot hook satisfies it. Add a hydrate hook, or transient: true if it genuinely is not restorable. Silent when the node IS a writable signal, since the ordinary write path handles it with no hook. Affects custom markers only; no built-in trips it. |
| ST2024 | hydrate payload was malformed | (14.0.0+) A marker's hydrate was handed a payload it could not read — entityMap with no all array, or status() with an unrecognised state string — so the node was left unchanged rather than throwing (one bad key should not kill an app) or resetting (that would silently discard state). This is a PAYLOAD problem, not a registration one: usually a pre-2.0.0 snapshot, where entityMap emitted a map that JSON renders as {} and the entities were never in the payload at all. Previously reported as [ST2022], which is a different condition entirely. |
| ST2025 | an onTreeError listener threw | (14.0.0+) A listener registered with onTreeError threw while being notified. The ORIGINAL error was still handled normally — this reports the listener failing, not the operation. Swallowed deliberately: letting it propagate would make adding error reporting a source of errors, and it would surface at whichever marker happened to report first, which is the least debuggable outcome available. Fix the listener; the tree is fine. |
| ST2026 | an inline predicate is defeating the where/find cache | (14.0.0+) where()/find() memoise per predicate IDENTITY, so the natural template form @for (row of tree.$.rows.where(r => !r.done)(); …) allocates a NEW arrow every change-detection cycle, misses the cache every time and re-scans the collection — measured at 75x a hoisted predicate over 1,000 entities (0.27ms vs 20.54ms). It is NOT a leak: the cache is a WeakMap and 50,000 inline calls retain ~0MB after forced GC, which is exactly why it needs a diagnostic — nothing grows, nothing breaks, the app is simply slow forever. Detected by source text plus RATE: byte-identical source is necessary but not sufficient, because v => v.x > threshold rebuilt when threshold changes looks identical too. Counting alone eventually accuses both, and "hoist it" is the wrong advice for the second — so it now takes 12 distinct identities within 2 seconds (~6/second, far above anything user-driven and far below a frame loop). Hoist the predicate to a class field or module constant. Dev-only, warned once per source. |
| ST2027 | a write changed nothing, and not the reference kind | (14.0.0+) The new value is a DIFFERENT object that deep-equals the current one, so the whole structure was compared and the write discarded. The shape a re-fetched payload takes. Distinct from [ST2003], which covers re-setting the identical REFERENCE: this one is the expensive twin, because deepEqual cannot short-circuit and must walk everything to conclude nothing changed — ~2.8ms on a 50k array, to do nothing. Invisible by construction: no error, no notification, no state change, only slowness. It corrupted this repo's own benchmarks twice before anyone noticed. Skip the write when the data is unchanged, or use compared() to pick a cheaper equality. Gated to values of 32+ elements/keys, deduped per path, dev-only. |
| ST2028 | an edit session holds a value structuredClone cannot copy | (14.0.0+) createEditSession clones with structuredClone, which THROWS on a function — so one callback anywhere in the edited value drops the whole object onto the fallback. That fallback used to be JSON.parse(JSON.stringify(...)), which is silent corruption of an undo stack: undo handed back Date as a string, Map/Set as {}, undefined keys gone, and the callback itself gone. It is now a type-aware walk that preserves Date, Map, Set, RegExp, undefined, cycles, and class prototypes (an ApiError comes back an ApiError, with its non-enumerable message intact). What remains is narrow and deliberate: a FUNCTION is shared by reference between history entries rather than copied — correct, since a function has no state to restore — and a class instance's private # fields are not carried across. Keep stateful non-cloneables out of the edited object. Dev-only, reported once. |
| ST2029 | time travel is retaining a lot of collection pointers | (14.0.0+) A history entry holds the tree's snapshot, and a collection's snapshot is one POINTER PER ENTITY, rebuilt whenever the collection changes — so entries x width x ~8 bytes is the FLOOR for touching that collection at all, and every write to it is O(collection). MEASURED over 50 recorded writes via tools/bench-retention-arms.mjs: 1,000 rows = 0.51MB, 10,000 = 3.95MB, 50,000 = 19.38MB. It is a floor and not a worst case — one changed row costs the same as fifty different ones, and each CHANGED row adds ~40 bytes on top, so changing all 50,000 retains 114.77MB (5.9x the floor). Warns past ~500k retained pointers (~4MB). Judged on RETENTION, not row count, so a big collection with a short history and a small one with a long history are held to the same standard — a row-count threshold gets both wrong. Checked at RECORD time, sampled every 16 entries: an app attaches timeTravel() when it builds the tree and the rows arrive later from a fetch, so an attach-time check sees an empty collection every time. If a collection should persist but not be undoable, pass entityMap({ recordHistory: false }) — MEASURED, that makes retention INDEPENDENT of collection width (~0.15MB at 1k, 10k and 50k alike), not merely smaller; if neither, use transient: true. Note the tradeoff: undo() will NOT revert an excluded collection. Dev-only, reported once. |
| ST2030 | guardrails could not copy a container in state | (14.0.0+) @signaltree/guardrails answers "did anything change" from snapshot REFERENCE identity, which is exact and O(1) — tree() returns the identical object when nothing changed. The one thing that escapes it is a container mutated IN PLACE (tree.$.rows().push(x) notifies nothing), so guardrails copies each array/Map/Set/Date it finds and re-checks those. structuredClone throws on a function or class instance, so a container holding one cannot be copied: its SHAPE stays watched (anything changing length or size is still caught, at O(1)) but an in-place edit of its CONTENTS is not. Scoped to that one container — everything else in state is unaffected. Contents watching is budgeted in AGGREGATE (5,000 elements across the whole tree, ~4µs per poll), not per container — fifty containers of 999 elements each pass a per-container cap individually and cost 50,000 comparisons twenty times a second. Past the budget a container is shape-watched by design rather than by failure. Remove the non-cloneable field; rules.noFunctionsInState() locates it. Dev-only, reported once. |
Compile-time symptoms (not ST codes)
Some mistakes surface as a TypeScript error at author time, so they never get an ST code — but
this page is where people search, so they're routed here. (The ST tables above stay append-only;
this section is deliberately separate from that numbering.)
| Symptom | Cause → fix |
|---|---|
TS2339: Property 'set' does not exist on type 'NodeAccessor<…>' | You initialized an object state key as a plain object ({} / {} as Dto), which makes it a nested node (fields become individual leaves), then tried to .set() it as a single value. To store an object as one settable leaf, initialize it null with the object type: firmware: null as FirmwareDto | null — then .set(dto) / () work, and consumers default with ?? {}. Full discussion: Myth 19 · typing patterns |