ngrx / platform

Reactive State for Angular
https://ngrx.io
Other
8k stars 1.97k forks source link

[signals] Potential typing bug when using discriminated unions #4467

Open jits opened 1 month ago

jits commented 1 month ago

Which @ngrx/* package(s) are the source of the bug?

signals

Minimal reproduction of the bug/regression with instructions

See https://stackblitz.com/edit/stackblitz-starters-ucjue4?file=src%2Fstore.ts

For posterity, here is the code that causes the typing error:

import { Injectable } from '@angular/core';
import { signalStore, withState } from '@ngrx/signals';

type A = {
  status: 'A';
  thing: null;
};

type B = {
  status: 'B';
  thing: string;
};

type State = A | B;

const initialState: State = {
  status: 'A',
  thing: null,
};

const _Store = signalStore(
  { providedIn: 'root' },
  withState<State>(initialState)
);

@Injectable({ providedIn: 'root' })
export class Store extends _Store {}    // <-- Typing error occurs here

The typing error occurs on the line export class Store extends _Store {}, with the following TypeScript error:

Base constructor return type '({ status: Signal<"A">; thing: Signal<null>; } | { status: Signal<"B">; thing: Signal<string>; }) & StateSource<{ status: "A"; thing: null; } | { status: "B"; thing: string; }>' is not an object type or intersection of object types with statically known members.(2509)

Expected behavior

Should compile fine and not give any TypeScript errors.

Versions of NgRx, Angular, Node, affected browser(s) and operating system(s)

NgRx: v18.0.0 Angular: latest v18 TypeScript: v5.5

Other information

No response

I would be willing to submit a PR to fix this issue

rainerhahnekamp commented 1 month ago

The error goes away if you just have

const _Store = signalStore(
  { providedIn: 'root' },
  withState(initialState)
);

initialState has already a declared type, so no need to provide the type a second time. Not sure if that helps in the long run, though.

jits commented 1 month ago

Thanks @rainerhahnekamp! That works for now (as a workaround) πŸ‘πŸ½

rainerhahnekamp commented 1 month ago

@markostanimirovic, if that's the right approach for now, should we leave a note in the docs?

jits commented 1 month ago

@rainerhahnekamp β€” I spoke too soon β€” unfortunately it still fails elsewhere, when using the discriminated union types. I've updated the Stackblitz to show this (https://stackblitz.com/edit/stackblitz-starters-ucjue4?file=src%2Fstore.ts).

Looks like the inferred state type of the store is not picking up the full discriminated union, just the specific type of initialState.

markostanimirovic commented 1 month ago

Union types can be used for state properties, not for the entire state because SignalStore/SignalState generates state signals based on the initial state.

// From:
type State = A | B;
const initialState: State = { /* ... */ };

// To:
type State = { prop: A | B };
const initialState: State = { prop: /* ... */ };

We can add a section about unions to the FAQ page and update the answer for the "Can I define my SignalStore as a class?" question to contain a note that although it's possible to create a class-based SignalStore, using the functional style is recommended.

jits commented 1 month ago

@markostanimirovic β€” ahh, that's a shame! I've been using discriminated union types (with extended class) up until (and including) RC2. Is there anything that can be done to support this use case, as I feel it's more akin to modelling state like a state machine, and gives us much better type safety. I'm keen to use this with extended classes so I can add extra properties etc. (as mentioned in https://github.com/ngrx/platform/discussions/4141#discussioncomment-7629034).

A concrete example of where I'm doing this (an AuthStore):

rainerhahnekamp commented 1 month ago

@jits. nested state with union works. We can take that as workaround

type A = {
  status: 'A';
  thing: null;
};

type B = {
  status: 'B';
  thing: string;
};

type NestedState = A | B;

type State = { nested: NestedState };

const initialState: State = { nested: { status: 'A', thing: null } };

const _Store = signalStore(
  { providedIn: 'root' },
  withState(initialState),
  withMethods((store) => {
    return {
      updateToB(thing: string): void {
        const newState: B = { status: 'B', thing };
        patchState(store, { nested: newState });
      },
    };
  })
);
markostanimirovic commented 1 month ago

@markostanimirovic β€” ahh, that's a shame! I've been using discriminated union types (with extended class) up until (and including) RC2. Is there anything that can be done to support this use case, as I feel it's more akin to modelling state like a state machine, and gives us much better type safety. I'm keen to use this with extended classes so I can add extra properties etc. (as mentioned in #4141 (reply in thread)).

A concrete example of where I'm doing this (an AuthStore):

Thank you for the detailed explanation @jits!

We'll consider adding a withProps base feature to add properties to the SignalStore in the next major release. With that feature, I guess class-based stores won't be needed anymore.

In the meantime, I suggest a different approach. Instead of extending a SignalStore, you can create a class that injects it:

const AuthStore = signalStore({ providedIn: 'root' }, /* ... */);

@Injectable({ providedIn: 'root' })
export class AuthFacade {
  readonly #store = inject(AuthStore);

 // ... expose all public store APIs
 // ... add additional properties
}

By the way, discriminated unions for the entire state work with functional SignalStores: https://stackblitz.com/edit/stackblitz-starters-hefo5v?file=src%2Fstore.ts

jits commented 1 month ago

Thanks for clarifying @markostanimirovic, and for your suggestions.

Sounds like functional SignalStores are the way forward. A future withProps base feature would be handy, but a wrapper / helper class will work for now.

Congrats to you and the team on the NgRx SignalStore first stable release! πŸŽ‰

(Please feel free to close this issue if no further action is needed.)