platform icon indicating copy to clipboard operation
platform copied to clipboard

Allow reading a nested property from a DeepSignal when there's a nullable object prior to the property

Open maxime-cloudnc opened this issue 6 months ago • 8 comments

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

maxime-cloudnc avatar Jun 26 '25 16:06 maxime-cloudnc

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

EthanSK avatar Aug 26 '25 17:08 EthanSK

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.

jdegand avatar Aug 27 '25 01:08 jdegand

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 avatar Aug 27 '25 03:08 jdegand

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

EthanSK avatar Aug 28 '25 13:08 EthanSK

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';
}

jdegand avatar Aug 29 '25 01:08 jdegand

sorry im too stupid

EthanSK avatar Aug 29 '25 14:08 EthanSK

@EthanSK How do you get it to work?

jdegand avatar Aug 29 '25 16:08 jdegand

@EthanSK How do you get it to work?

Oh, i never got it to work. I just want it to work like that lol

EthanSK avatar Aug 31 '25 11:08 EthanSK