feat(signals): enable creation of dynamic deep signals - #5187
Conversation
BREAKING CHANGES:
Union state slices and computed results that include an object literal now
create a `DeepSignal` for each object literal member, instead of exposing the
whole union as a single `Signal`.
BEFORE:
A union that included an object literal was exposed as a single `Signal` of the
whole union.
signalState:
const state = signalState<{ user: { name: string } | null }>({ user: null });
// state.user: Signal<{ name: string } | null>
signalStore:
const Store = signalStore(
withState<{ user: { name: string } | null }>({ user: null })
);
const store = inject(Store);
// store.user: Signal<{ name: string } | null>
deepComputed:
const source = signal<{ a: number } | { b: number }>({ a: 1 });
const result = deepComputed(() => source());
// result: Signal<{ a: number } | { b: number }>
AFTER:
Each object literal member becomes its own `DeepSignal`; the remaining members
stay a regular `Signal`.
signalState:
const state = signalState<{ user: { name: string } | null }>({ user: null });
// state.user: DeepSignal<{ name: string }> | Signal<null>
signalStore:
const Store = signalStore(
withState<{ user: { name: string } | null }>({ user: null })
);
const store = inject(Store);
// store.user: DeepSignal<{ name: string }> | Signal<null>
deepComputed:
const source = signal<{ a: number } | { b: number }>({ a: 1 });
const result = deepComputed(() => source());
// result: DeepSignal<{ a: number }> | DeepSignal<{ b: number }>
rainerhahnekamp
left a comment
There was a problem hiding this comment.
As always, great stuff! I just wanted to bring up two points for discussion:
1. Typing Issues with Deep Signals
This isn't necessarily a new issue, but it became much clearer to me during this review. The following snippet shows how our deep signal feature can lead to some tricky type safety gaps:
it('gives a wrong type guarantee', () => {
type Status =
| { status: 'error'; message: string }
| { status: 'success'; value: number };
const state = signal({
status: 'error',
message: 'did not work',
} as Status);
const deepState = toDeepSignal(state);
if ('message' in deepState) {
const message = deepState.message;
state.set({ status: 'success', value: 1 });
// returns undefined although it is typed as Signal<string> 😬
message().toUpperCase();
}
});While we could argue that developers shouldn't access a message signal without a proper runtime check, I still see this as a problem from a strict TypeScript perspective.
Strictly speaking, message should be typed as Signal<string | undefined>. However, I wouldn't want to make that change, as I'm really hoping the scenario above is just an edge case. Instead, I suggest we simply throw a runtime error.
2. Vitest Type Tests vs. ts-snippet
Regarding our testing setup, why don't we use Vitest's built-in type testing capabilities instead of ts-snippet? I found that they worked quite nicely for in PR #5186.
The same issue can be reproduced with plain TypeScript, and this is not something we can control: type Status = { status: 'success' } | { status: 'error'; message: string; }
const state: Status = { status: 'error', message: 'x' };
if (state.status === 'error') {
Object.assign(state, { status: 'success', message: undefined });
state.message.toUpperCase(); // typed string, undefined at runtime. Compiles clean.
}Anyway, I don't see this as any kind of blocker - in properly implemented logic, write and read blocks should be separated, and type narrowing of deep signals is needed only for the read logic. If someone wants to abuse our APIs, there is always a way (
For consistency reasons, I don't think we should mix both within a single project ( |
rainerhahnekamp
left a comment
There was a problem hiding this comment.
As discussed, we see my first comment as a theoretical concern and wait if those things actually happen in real life.
As for the typing tests, they would be handled in a separate issue.
timdeschryver
left a comment
There was a problem hiding this comment.
LGTM, I also asked a review from codex, which gave 1 comment.
PR Checklist
Please check if your PR fulfills the following requirements:
PR Type
What kind of change does this PR introduce?
What is the current behavior?
Closes #4847
Fixes signals tests in #5173
What is the new behavior?
Enables creation of dynamic deep signals in
signalState,signalStore, anddeepComputed. Example:Does this PR introduce a breaking change?