Allow reading a nested property from a DeepSignal when there's a nullable object prior to the property
Which @ngrx/* package(s) are relevant/related to the feature request?
signals
Information
If you create a state containing the following:
object: { value: number };
nullableObject: null | { value: number };
undefinableObject?: { value: number };
You can then do:
store.object.value()
It works just fine as a DeepSignal.
However, both the nullable and potentially undefined ones don't work.
I'm not entirely sure what would be the best option here, either:
store.nullableObject?.value()
But I'm guessing that it may break the reactivity as it may not be caught by the signal graph
OR
store.nullableObject.value() // returns a type `number | null`
store.undefinableObject.value() // returns a type `number | undefined`
Describe any alternatives/workarounds you're currently using
Reading the top level signal, which isn't ideal ofc as I need another computed down the chain to get reactivity only on one property.
I would be willing to submit a PR to fix this issue
- [ ] Yes
- [x] No
my use case for this is
say the initial state is this
type MyNestedType = {
foo: number
}
type State = {
someObj: {
[id: string]: MyNestedType
}
}
I wanna access it as store.someObj[theDynamicId]?.foo()
mentioned here https://github.com/ngrx/platform/issues/4943
store.nullableObject?.value() is unlikely to be a viable solution. Optional chaining short-circuits access, which means the signal graph may not track nullableObject as a dependency—breaking reactivity. This is why wrapping the access in a computed() works: it explicitly reads the signal value, ensuring it's part of the reactive graph.
As a workaround, you could consider using a union type like:
{ value: number } | { value: null }
This keeps the object shape consistent and avoids null at the top level, making it easier to maintain reactivity while still expressing the possibility of a null value.
For dynamic ids, I would try to enforce typing with as .
const userSignal = store[id] as DeepSignal<User>;
Using a helper function would be preferred for larger codebases and maintainability:
function getDeepSignal<T>(signal: unknown): DeepSignal<T> {
return signal as DeepSignal<T>;
}
const userSignal = getDeepSignal<User>(store[id]);
@jdegand I could'nt get that to work for dynamic ids. Also ive got a field on the store which is the object, it's not on the store itself eg store.someObject[dynamicId]
i just get X is not a function even with all the as any casts etc.
Don't you think it would be worth adding first class support for this kind of thing?
I have been looking into the tradeoffs and potential improvements to toDeepSignal. You can compare the original to:
import { computed, isSignal, Signal, untracked } from '@angular/core';
import { IsKnownRecord } from './ts-helpers';
const DEEP_SIGNAL = Symbol('DEEP_SIGNAL');
export type DeepSignal<T> = Signal<T> &
(IsKnownRecord<T> extends true
? Readonly<{
[K in keyof T]: IsKnownRecord<T[K]> extends true
? DeepSignal<T[K]>
: Signal<T[K]>;
}>
: unknown);
// Cache to avoid recomputing signals for the same property
const deepSignalCache = new WeakMap<object, Map<string | symbol, Signal<any>>>();
export function toDeepSignal<T>(signal: Signal<T>): DeepSignal<T> {
if (!isSignal(signal)) {
throw new Error('toDeepSignal expects a Signal');
}
const cache = new Map<string | symbol, Signal<any>>();
return new Proxy(signal, {
get(target: Signal<T>, prop: string | symbol) {
// Preserve access to internal symbols like SIGNAL
if (typeof prop === 'symbol') {
return (target as any)[prop];
}
// Return cached signal if available
if (cache.has(prop)) {
return cache.get(prop);
}
// Create a computed signal for the property
const computedSignal = computed(() => {
const value = untracked(target);
const result = value?.[prop]; // this should help with undefined as all lookups need to be inside a computed
// Dev-mode warning for undefined access
if (value && !(prop in value)) {
console.warn(`[toDeepSignal] Property "${String(prop)}" does not exist on signal value`, value);
}
return result;
});
// Wrap recursively if it's a record
const wrapped = isRecord(untracked(computedSignal))
? toDeepSignal(computedSignal)
: computedSignal;
cache.set(prop, wrapped);
return wrapped;
},
}) as DeepSignal<T>;
}
const nonRecords = [
WeakSet,
WeakMap,
Promise,
Date,
Error,
RegExp,
ArrayBuffer,
DataView,
Function,
];
function isRecord(value: unknown): value is Record<string | symbol, unknown> {
if (value === null || typeof value !== 'object' || isIterable(value)) {
return false;
}
let proto = Object.getPrototypeOf(value);
if (proto === Object.prototype) {
return true;
}
while (proto && proto !== Object.prototype) {
if (nonRecords.includes(proto.constructor)) {
return false;
}
proto = Object.getPrototypeOf(proto);
}
return proto === Object.prototype;
}
function isIterable(value: any): value is Iterable<any> {
return typeof value?.[Symbol.iterator] === 'function';
}
sorry im too stupid
@EthanSK How do you get it to work?
@EthanSK How do you get it to work?
Oh, i never got it to work. I just want it to work like that lol