From 2f26c22aff21f81f4128d7377ba24729f44d18d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Stanimirovi=C4=87?= Date: Fri, 3 Jul 2026 14:23:54 +0200 Subject: [PATCH 1/2] feat(signals): enable creation of dynamic deep signals 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 signalStore: const Store = signalStore( withState<{ user: { name: string } | null }>({ user: null }) ); const store = inject(Store); // store.user: DeepSignal<{ name: string }> | Signal deepComputed: const source = signal<{ a: number } | { b: number }>({ a: 1 }); const result = deepComputed(() => source()); // result: DeepSignal<{ a: number }> | DeepSignal<{ b: number }> --- modules/signals/spec/deep-computed.spec.ts | 76 ++++++++++++++++ modules/signals/spec/deep-signal.spec.ts | 47 ++++++++++ modules/signals/spec/signal-state.spec.ts | 26 +++++- modules/signals/spec/signal-store.spec.ts | 23 +++++ .../spec/types/signal-state.types.spec.ts | 71 ++++++++++++++- .../spec/types/signal-store.types.spec.ts | 89 +++++++++++++++++-- modules/signals/src/deep-computed.ts | 6 +- modules/signals/src/deep-signal.ts | 29 ++++-- modules/signals/src/index.ts | 2 +- modules/signals/src/signal-store-models.ts | 6 +- modules/signals/src/ts-helpers.ts | 12 +++ .../app/pages/guide/signals/deep-computed.md | 33 +++++++ .../app/pages/guide/signals/signal-state.md | 31 +++++++ .../pages/guide/signals/signal-store/index.md | 34 +++++++ 14 files changed, 455 insertions(+), 30 deletions(-) diff --git a/modules/signals/spec/deep-computed.spec.ts b/modules/signals/spec/deep-computed.spec.ts index 3f65e31428..4f83f34158 100644 --- a/modules/signals/spec/deep-computed.spec.ts +++ b/modules/signals/spec/deep-computed.spec.ts @@ -21,6 +21,67 @@ describe('deepComputed', () => { expect(result.count.value()).toBe(2); }); + it('creates a deep computed signal when computation result is a union of objects', () => { + const source = signal<{ s: 'asdf' } | { m: { s: string } }>({ + m: { s: 't' }, + }); + const result = deepComputed(() => source()); + + expect('m' in result).toBe(true); + expect('m' in result && result.m()).toEqual({ s: 't' }); + expect('m' in result && result.m.s()).toBe('t'); + + source.set({ s: 'asdf' }); + + expect('m' in result).toBe(false); + expect('s' in result).toBe(true); + expect('s' in result && result.s()).toBe('asdf'); + + source.set({ m: { s: 'ngrx' } }); + + expect('s' in result).toBe(false); + expect('m' in result).toBe(true); + expect('m' in result && result.m()).toEqual({ s: 'ngrx' }); + expect('m' in result && result.m.s()).toBe('ngrx'); + }); + + it('creates a deep computed signal when computation result is a union of nested objects', () => { + const source = signal<{ a: { b: number } } | { c: { d: string } }>({ + a: { b: 1 }, + }); + const result = deepComputed(() => source()); + + expect('a' in result).toBe(true); + expect('a' in result && result.a()).toEqual({ b: 1 }); + expect('a' in result && result.a.b()).toBe(1); + + source.set({ c: { d: 't' } }); + + expect('a' in result).toBe(false); + expect('c' in result).toBe(true); + expect('c' in result && result.c()).toEqual({ d: 't' }); + expect('c' in result && result.c.d()).toBe('t'); + }); + + it('creates a deep computed signal when computation result is a union of an object, a primitive, and null', () => { + const source = signal<{ m: { s: string } } | number | null>(null); + const result = deepComputed(() => source()); + + expect('m' in result).toBe(false); + expect(result()).toBe(null); + + source.set(1); + + expect('m' in result).toBe(false); + expect(result()).toBe(1); + + source.set({ m: { s: 'ngrx' } }); + + expect('m' in result).toBe(true); + expect('m' in result && result.m()).toEqual({ s: 'ngrx' }); + expect('m' in result && result.m.s()).toBe('ngrx'); + }); + it('does not create a deep computed signal when computation result is an array', () => { const source = signal(0); const result = deepComputed(() => [{ value: source() + 1 }]); @@ -29,4 +90,19 @@ describe('deepComputed', () => { expect(result()).toEqual([{ value: 1 }]); expect((result as any)[0]).toBe(undefined); }); + + it('does not create a deep computed signal when computation result is a primitive, null, or undefined', () => { + const num = deepComputed(() => 1); + const nul = deepComputed(() => null); + const und = deepComputed(() => undefined); + + expect(isSignal(num)).toBe(true); + expect(num()).toBe(1); + + expect(isSignal(nul)).toBe(true); + expect(nul()).toBe(null); + + expect(isSignal(und)).toBe(true); + expect(und()).toBe(undefined); + }); }); diff --git a/modules/signals/spec/deep-signal.spec.ts b/modules/signals/spec/deep-signal.spec.ts index d7313988ff..910d0806c9 100644 --- a/modules/signals/spec/deep-signal.spec.ts +++ b/modules/signals/spec/deep-signal.spec.ts @@ -77,6 +77,53 @@ describe('toDeepSignal', () => { expect('m' in deepSig && deepSig.m.s()).toBe('ngrx'); }); + it('creates a deep signal on the fly for a union of an object and a primitive', () => { + const sig = signal<{ m: { s: string } } | number>(1); + const deepSig = toDeepSignal(sig); + + expect('m' in deepSig).toBe(false); + expect(deepSig()).toBe(1); + + sig.set({ m: { s: 'ngrx' } }); + + expect('m' in deepSig).toBe(true); + expect('m' in deepSig && deepSig.m()).toEqual({ s: 'ngrx' }); + expect('m' in deepSig && deepSig.m.s()).toBe('ngrx'); + }); + + it('creates a deep signal on the fly for a union of an object and undefined', () => { + const sig = signal<{ m: { s: string } } | undefined>(undefined); + const deepSig = toDeepSignal(sig); + + expect('m' in deepSig).toBe(false); + expect(deepSig()).toBe(undefined); + + sig.set({ m: { s: 'ngrx' } }); + + expect('m' in deepSig).toBe(true); + expect('m' in deepSig && deepSig.m()).toEqual({ s: 'ngrx' }); + expect('m' in deepSig && deepSig.m.s()).toBe('ngrx'); + }); + + it('creates a deep signal on the fly for a union of an object, a primitive, and null', () => { + const sig = signal<{ m: { s: string } } | number | null>(null); + const deepSig = toDeepSignal(sig); + + expect('m' in deepSig).toBe(false); + expect(deepSig()).toBe(null); + + sig.set({ m: { s: 'ngrx' } }); + + expect('m' in deepSig).toBe(true); + expect('m' in deepSig && deepSig.m()).toEqual({ s: 'ngrx' }); + expect('m' in deepSig && deepSig.m.s()).toBe('ngrx'); + + sig.set(1); + + expect('m' in deepSig).toBe(false); + expect(deepSig()).toBe(1); + }); + it('does not affect signals with primitives as values', () => { const num = signal(0); const str = signal('str'); diff --git a/modules/signals/spec/signal-state.spec.ts b/modules/signals/spec/signal-state.spec.ts index 000d63b015..0aa39cf276 100644 --- a/modules/signals/spec/signal-state.spec.ts +++ b/modules/signals/spec/signal-state.spec.ts @@ -1,7 +1,6 @@ -import { computed, effect, isSignal } from '@angular/core'; +import { computed, effect, isSignal, WritableSignal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { patchState, signalState } from '../src'; -import { SignalsDictionary } from '../src/signal-store-models'; import { STATE_SOURCE } from '../src/state-source'; vi.mock('@angular/core', { spy: true }); @@ -23,7 +22,9 @@ describe('signalState', () => { it('creates its properties as Signals', () => { const state = signalState({ foo: 'bar' }); - const stateSource: SignalsDictionary = state[STATE_SOURCE]; + const stateSource: Record> = state[ + STATE_SOURCE + ]; expect(isSignal(state)).toBe(true); for (const key of Reflect.ownKeys(stateSource)) { @@ -38,6 +39,25 @@ describe('signalState', () => { expect(state()).toEqual(initialState); }); + it('supports a state slice that is a union of an object, null, and a primitive', () => { + const state = signalState<{ slice: { s: string } | null | string }>({ + slice: { s: 'ngrx' }, + }); + + expect(isSignal(state.slice)).toBe(true); + expect(state.slice()).toEqual({ s: 'ngrx' }); + expect('s' in state.slice).toBe(true); + expect('s' in state.slice && state.slice.s()).toBe('ngrx'); + + patchState(state, { slice: null }); + expect(state.slice()).toBe(null); + expect('s' in state.slice).toBe(false); + + patchState(state, { slice: 'signals' }); + expect(state.slice()).toBe('signals'); + expect('s' in state.slice).toBe(false); + }); + it('creates signals for nested state slices', () => { const state = signalState(initialState); diff --git a/modules/signals/spec/signal-store.spec.ts b/modules/signals/spec/signal-store.spec.ts index 01f2a729ea..ab17bfeff2 100644 --- a/modules/signals/spec/signal-store.spec.ts +++ b/modules/signals/spec/signal-store.spec.ts @@ -132,6 +132,29 @@ describe('signalStore', () => { expect(store.x.y.z()).toBe(10); }); + it('supports a state slice that is a union of an object, undefined, and a primitive', () => { + const Store = signalStore( + { protectedState: false }, + withState<{ slice: { n: number } | undefined | number }>({ + slice: { n: 1 }, + }) + ); + const store = new Store(); + + expect(isSignal(store.slice)).toBe(true); + expect(store.slice()).toEqual({ n: 1 }); + expect('n' in store.slice).toBe(true); + expect('n' in store.slice && store.slice.n()).toBe(1); + + patchState(store, { slice: undefined }); + expect(store.slice()).toBe(undefined); + expect('n' in store.slice).toBe(false); + + patchState(store, { slice: 42 }); + expect(store.slice()).toBe(42); + expect('n' in store.slice).toBe(false); + }); + it('overrides Function properties if nested state keys have the same name', () => { const Store = signalStore( withState({ name: { length: { name: false } } }) diff --git a/modules/signals/spec/types/signal-state.types.spec.ts b/modules/signals/spec/types/signal-state.types.spec.ts index 803379df6e..c926ca1678 100644 --- a/modules/signals/spec/types/signal-state.types.spec.ts +++ b/modules/signals/spec/types/signal-state.types.spec.ts @@ -196,7 +196,7 @@ describe('signalState', () => { result.toInfer('baz', 'Signal | undefined'); result.toInfer( 'x', - 'Signal<{ y: { z?: boolean | undefined; }; } | undefined> | undefined' + 'DeepSignal<{ y: { z?: boolean | undefined; }; }> | Signal | undefined' ); }); @@ -290,14 +290,79 @@ describe('signalState', () => { const result = expectSnippet(snippet); result.toInfer('state', 'SignalState'); - result.toInfer('foo', 'Signal'); + result.toInfer('foo', 'DeepSignal<{ s: string; }> | Signal'); result.toInfer('bar', 'DeepSignal<{ baz: { n: number; } | null; }>'); - result.toInfer('baz', 'Signal<{ n: number; } | null>'); + result.toInfer('baz', 'DeepSignal<{ n: number; }> | Signal'); result.toInfer('x', 'DeepSignal<{ y: { z: boolean | undefined; }; }>'); result.toInfer('y', 'DeepSignal<{ z: boolean | undefined; }>'); result.toInfer('z', 'Signal'); }); + it('does not split non-record union members into separate signals', () => { + const snippet = ` + type FooBar = 'foo' | 'bar'; + type State = { + flag: boolean; + status: FooBar; + mixed: string | number; + withRecord: { id: number } | boolean; + combo: { a: string } | FooBar | null; + multi: { a: number } | { b: string } | boolean; + withCollection: { id: number } | Set | boolean; + nested: { foo: boolean | { deep: string } }; + }; + + const state = signalState({ + flag: true, + status: 'foo', + mixed: 1, + withRecord: { id: 1 }, + combo: null, + multi: true, + withCollection: { id: 1 }, + nested: { foo: true }, + }); + const flag = state.flag; + const status = state.status; + const mixed = state.mixed; + const withRecord = state.withRecord; + const combo = state.combo; + const multi = state.multi; + const withCollection = state.withCollection; + const nested = state.nested; + const nestedFoo = state.nested.foo; + `; + + const result = expectSnippet(snippet); + result.toInfer('flag', 'Signal'); + result.toInfer('status', 'Signal'); + result.toInfer('mixed', 'Signal'); + result.toInfer( + 'withRecord', + 'Signal | DeepSignal<{ id: number; }>' + ); + result.toInfer( + 'combo', + 'DeepSignal<{ a: string; }> | Signal' + ); + result.toInfer( + 'multi', + 'Signal | DeepSignal<{ a: number; }> | DeepSignal<{ b: string; }>' + ); + result.toInfer( + 'withCollection', + 'DeepSignal<{ id: number; }> | Signal>' + ); + result.toInfer( + 'nested', + 'DeepSignal<{ foo: boolean | { deep: string; }; }>' + ); + result.toInfer( + 'nestedFoo', + 'Signal | DeepSignal<{ deep: string; }>' + ); + }); + it('succeeds when state contains Function properties', () => { const snippet = ` const state1 = signalState({ name: 0 }); diff --git a/modules/signals/spec/types/signal-store.types.spec.ts b/modules/signals/spec/types/signal-store.types.spec.ts index d536832d9b..d3083a9a6b 100644 --- a/modules/signals/spec/types/signal-store.types.spec.ts +++ b/modules/signals/spec/types/signal-store.types.spec.ts @@ -240,16 +240,84 @@ describe('signalStore', () => { const result = expectSnippet(snippet); result.toInfer( 'store', - '{ foo: Signal; bar: DeepSignal<{ baz: { b: boolean; } | null; }>; x: DeepSignal<{ y: { z: number | undefined; }; }>; } & StateSource<{ foo: number | { ...; }; bar: { ...; }; x: { ...; }; }>' + '{ foo: DeepSignal<{ s: string; }> | Signal; bar: DeepSignal<{ baz: { b: boolean; } | null; }>; x: DeepSignal<{ y: { z: number | undefined; }; }>; } & StateSource<...>' ); - result.toInfer('foo', 'Signal'); + result.toInfer('foo', 'DeepSignal<{ s: string; }> | Signal'); result.toInfer('bar', 'DeepSignal<{ baz: { b: boolean; } | null; }>'); - result.toInfer('baz', 'Signal<{ b: boolean; } | null>'); + result.toInfer('baz', 'DeepSignal<{ b: boolean; }> | Signal'); result.toInfer('x', 'DeepSignal<{ y: { z: number | undefined; }; }>'); result.toInfer('y', 'DeepSignal<{ z: number | undefined; }>'); result.toInfer('z', 'Signal'); }); + it('does not split non-record union members into separate signals', () => { + const snippet = ` + type FooBar = 'foo' | 'bar'; + type State = { + flag: boolean; + status: FooBar; + mixed: string | number; + withRecord: { id: number } | boolean; + combo: { a: string } | FooBar | null; + multi: { a: number } | { b: string } | boolean; + withCollection: { id: number } | Set | boolean; + nested: { foo: boolean | { deep: string } }; + }; + + const Store = signalStore( + withState({ + flag: true, + status: 'foo', + mixed: 1, + withRecord: { id: 1 }, + combo: null, + multi: true, + withCollection: { id: 1 }, + nested: { foo: true }, + }) + ); + const store = inject(Store); + const flag = store.flag; + const status = store.status; + const mixed = store.mixed; + const withRecord = store.withRecord; + const combo = store.combo; + const multi = store.multi; + const withCollection = store.withCollection; + const nested = store.nested; + const nestedFoo = store.nested.foo; + `; + + const result = expectSnippet(snippet); + result.toInfer('flag', 'Signal'); + result.toInfer('status', 'Signal'); + result.toInfer('mixed', 'Signal'); + result.toInfer( + 'withRecord', + 'Signal | DeepSignal<{ id: number; }>' + ); + result.toInfer( + 'combo', + 'DeepSignal<{ a: string; }> | Signal' + ); + result.toInfer( + 'multi', + 'Signal | DeepSignal<{ a: number; }> | DeepSignal<{ b: string; }>' + ); + result.toInfer( + 'withCollection', + 'DeepSignal<{ id: number; }> | Signal>' + ); + result.toInfer( + 'nested', + 'DeepSignal<{ foo: boolean | { deep: string; }; }>' + ); + result.toInfer( + 'nestedFoo', + 'Signal | DeepSignal<{ deep: string; }>' + ); + }); + it('succeeds when root state slices contain Function properties', () => { const snippet1 = ` const Store = signalStore( @@ -348,7 +416,10 @@ describe('signalStore', () => { result.toInfer('bar', 'DeepSignal<{ baz?: number | undefined; }>'); result.toInfer('baz', 'Signal | undefined'); result.toInfer('x', 'DeepSignal<{ y?: { z: boolean; } | undefined; }>'); - result.toInfer('y', 'Signal<{ z: boolean; } | undefined> | undefined'); + result.toInfer( + 'y', + 'DeepSignal<{ z: boolean; }> | Signal | undefined' + ); }); it('succeeds when root state slices are optional', () => { @@ -366,7 +437,10 @@ describe('signalStore', () => { `; const result = expectSnippet(snippet); - result.toInfer('foo', 'Signal<{ s: string; } | undefined> | undefined'); + result.toInfer( + 'foo', + 'DeepSignal<{ s: string; }> | Signal | undefined' + ); }); it('does not create deep signals when state is an unknown record', () => { @@ -999,9 +1073,8 @@ describe('signalStore', () => { logEntity: (entity: Entity) => void; }; }>(), - withMethods(({ entities, selectedEntity, selectedEntity2, logEntity }) => { + withMethods(({ entities, selectedEntity2, logEntity }) => { const e: Signal = entities; - const se: Signal = selectedEntity; const se2: Signal = selectedEntity2; const le: (entity: Entity) => void = logEntity; @@ -1056,7 +1129,7 @@ describe('signalStore', () => { `; const result = expectSnippet(snippet); - result.toInfer('selectedEntity', 'Signal'); + result.toInfer('selectedEntity', 'DeepSignal | Signal'); result.toInfer('selectedEntity2', 'Signal'); result.toInfer('loadEntities', '() => Promise'); }); diff --git a/modules/signals/src/deep-computed.ts b/modules/signals/src/deep-computed.ts index 08137ae769..a15bce552c 100644 --- a/modules/signals/src/deep-computed.ts +++ b/modules/signals/src/deep-computed.ts @@ -1,5 +1,5 @@ import { computed } from '@angular/core'; -import { DeepSignal, toDeepSignal } from './deep-signal'; +import { DeepSignalOf, toDeepSignal } from './deep-signal'; /** * @description @@ -26,8 +26,6 @@ import { DeepSignal, toDeepSignal } from './deep-signal'; * console.log(pagination.pageSize()); // 10 * ``` */ -export function deepComputed( - computation: () => T -): DeepSignal { +export function deepComputed(computation: () => T): DeepSignalOf { return toDeepSignal(computed(computation)); } diff --git a/modules/signals/src/deep-signal.ts b/modules/signals/src/deep-signal.ts index 3b1098f872..c2c0b222d1 100644 --- a/modules/signals/src/deep-signal.ts +++ b/modules/signals/src/deep-signal.ts @@ -1,5 +1,9 @@ import { computed, isSignal, Signal, untracked } from '@angular/core'; -import { IsKnownRecord } from './ts-helpers'; +import { + HasKnownRecordMember, + IsKnownRecord, + NonRecordMembers, +} from './ts-helpers'; const DEEP_SIGNAL = Symbol( typeof ngDevMode !== 'undefined' && ngDevMode ? 'DEEP_SIGNAL' : '' @@ -7,14 +11,25 @@ const DEEP_SIGNAL = Symbol( export type DeepSignal = Signal & (IsKnownRecord extends true - ? Readonly<{ - [K in keyof T]: IsKnownRecord extends true - ? DeepSignal - : Signal; - }> + ? Readonly<{ [K in keyof T]: DeepSignalOf }> : unknown); -export function toDeepSignal(signal: Signal): DeepSignal { +export type DeepSignalOf = + HasKnownRecordMember extends true + ? DeepSignalRecordMembers | DeepSignalNonRecordMembers + : Signal; + +type DeepSignalRecordMembers = T extends unknown + ? IsKnownRecord extends true + ? DeepSignal + : never + : never; + +type DeepSignalNonRecordMembers = [NonRecordMembers] extends [never] + ? never + : Signal>; + +export function toDeepSignal(signal: Signal): DeepSignalOf { return new Proxy(signal, { has(target: any, prop) { return !!this.get!(target, prop, undefined); diff --git a/modules/signals/src/index.ts b/modules/signals/src/index.ts index 6d10ca62be..26b6b1278e 100644 --- a/modules/signals/src/index.ts +++ b/modules/signals/src/index.ts @@ -1,5 +1,5 @@ export { deepComputed } from './deep-computed'; -export { DeepSignal } from './deep-signal'; +export { DeepSignal, DeepSignalOf } from './deep-signal'; export { signalMethod, SignalMethod } from './signal-method'; export { signalState, SignalState } from './signal-state'; export { signalStore } from './signal-store'; diff --git a/modules/signals/src/signal-store-models.ts b/modules/signals/src/signal-store-models.ts index e4c5779ccd..73b633aa4e 100644 --- a/modules/signals/src/signal-store-models.ts +++ b/modules/signals/src/signal-store-models.ts @@ -1,14 +1,12 @@ import { Signal } from '@angular/core'; -import { DeepSignal } from './deep-signal'; +import { DeepSignalOf } from './deep-signal'; import { WritableStateSource } from './state-source'; import { IsKnownRecord, Prettify } from './ts-helpers'; export type StateSignals = IsKnownRecord> extends true ? { - [Key in keyof State]: IsKnownRecord extends true - ? DeepSignal - : Signal; + [Key in keyof State]: DeepSignalOf; } : {}; diff --git a/modules/signals/src/ts-helpers.ts b/modules/signals/src/ts-helpers.ts index d78d78424c..01ce4babb6 100644 --- a/modules/signals/src/ts-helpers.ts +++ b/modules/signals/src/ts-helpers.ts @@ -35,6 +35,18 @@ export type IsKnownRecord = : true : false; +export type HasKnownRecordMember = true extends ( + T extends unknown ? IsKnownRecord : never +) + ? true + : false; + +export type NonRecordMembers = T extends unknown + ? IsKnownRecord extends true + ? never + : T + : never; + export type OmitPrivate = { [K in keyof T as K extends `_${string}` ? never : K]: T[K]; }; diff --git a/projects/www/src/app/pages/guide/signals/deep-computed.md b/projects/www/src/app/pages/guide/signals/deep-computed.md index 339316d306..55d9611ead 100644 --- a/projects/www/src/app/pages/guide/signals/deep-computed.md +++ b/projects/www/src/app/pages/guide/signals/deep-computed.md @@ -32,3 +32,36 @@ console.log(pagination.totalPages()); // logs: 4 For enhanced performance, deeply nested signals are generated lazily and initialized only upon first access. + + + +When the computation result is a union, `deepComputed` creates a `DeepSignal` for each object literal member. The remaining members (primitives, dynamic records, etc.) stay a regular `Signal`. + +```ts +type ValidationResult = + | { status: 'valid'; value: number } + | { status: 'invalid'; error: string }; + +const age = signal(null); + +// 👇 a DeepSignal is created for each object literal member; null is combined into a Signal +// validationResult: DeepSignal<{ status: 'valid'; value: number }> | DeepSignal<{ status: 'invalid'; error: string }> | Signal +const validationResult = deepComputed((): ValidationResult | null => { + const value = age(); + + if (value === null) { + return null; + } + + return value >= 21 + ? { status: 'valid', value } + : { status: 'invalid', error: 'Must be at least 21' }; +}); + +if ('error' in validationResult) { + const error = validationResult.error; // Signal + console.log(error()); +} +``` + + diff --git a/projects/www/src/app/pages/guide/signals/signal-state.md b/projects/www/src/app/pages/guide/signals/signal-state.md index e966ceb531..82b316b8eb 100644 --- a/projects/www/src/app/pages/guide/signals/signal-state.md +++ b/projects/www/src/app/pages/guide/signals/signal-state.md @@ -64,6 +64,37 @@ For enhanced performance, deeply nested signals are generated lazily and initial + + +When a property's type is a union, `signalState` creates a `DeepSignal` for each object literal member. The remaining members (primitives, dynamic records, etc.) stay a regular `Signal`. + +```ts +type User = { id: number; firstName: string }; +type Status = + | { type: 'success'; data: string } + | { type: 'error'; message: string }; + +const state = signalState<{ user: User | null; status: Status }>({ + user: null, + status: { type: 'success', data: '' }, +}); + +// 👇 object literal + null: state.user is DeepSignal | Signal +if ('firstName' in state.user) { + const firstName = state.user.firstName; // Signal + console.log(firstName()); +} + +// 👇 union of object literals: a DeepSignal is created for each member +// state.status: DeepSignal<{ type: 'success'; data: string }> | DeepSignal<{ type: 'error'; message: string }> +if ('message' in state.status) { + const message = state.status.message; // Signal + console.log(message()); +} +``` + + + ## Updating State The `patchState` function provides a type-safe way to perform updates on pieces of state. diff --git a/projects/www/src/app/pages/guide/signals/signal-store/index.md b/projects/www/src/app/pages/guide/signals/signal-store/index.md index 39e8afdcaa..55bf77bb82 100644 --- a/projects/www/src/app/pages/guide/signals/signal-store/index.md +++ b/projects/www/src/app/pages/guide/signals/signal-store/index.md @@ -49,6 +49,40 @@ The `BookSearchStore` instance will contain the following properties: +When a state slice's type is a union, `signalStore` creates a `DeepSignal` for each object literal member. The remaining members (primitives, dynamic records, etc.) stay a regular `Signal`. + +```ts +type Book = { id: number; title: string }; +type Status = + | { type: 'success'; data: string } + | { type: 'error'; message: string }; + +const BookStore = signalStore( + withState<{ book: Book | null; status: Status }>({ + book: null, + status: { type: 'success', data: '' }, + }) +); +const store = inject(BookStore); + +// 👇 object literal + null: store.book is DeepSignal | Signal +if ('title' in store.book) { + const title = store.book.title; // Signal + console.log(title()); +} + +// 👇 union of object literals: a DeepSignal is created for each member +// store.status: DeepSignal<{ type: 'success'; data: string }> | DeepSignal<{ type: 'error'; message: string }> +if ('message' in store.status) { + const message = store.status.message; // Signal + console.log(message()); +} +``` + + + + + The `withState` feature also has a signature that takes the initial state factory as an input argument. The factory is executed within the injection context, allowing initial state to be obtained from a service or injection token. From 9df40a91f0940c193e3c71a5051816da89b95213 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Stanimirovi=C4=87?= Date: Wed, 8 Jul 2026 15:55:43 +0200 Subject: [PATCH 2/2] test: extend type test - DeepSignal with generics --- modules/signals/spec/types/signal-store.types.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/modules/signals/spec/types/signal-store.types.spec.ts b/modules/signals/spec/types/signal-store.types.spec.ts index d3083a9a6b..6634b89429 100644 --- a/modules/signals/spec/types/signal-store.types.spec.ts +++ b/modules/signals/spec/types/signal-store.types.spec.ts @@ -6,6 +6,7 @@ describe('signalStore', () => { (code) => ` import { computed, inject, Signal } from '@angular/core'; import { + DeepSignalOf, getState, patchState, signalStore, @@ -1073,8 +1074,10 @@ describe('signalStore', () => { logEntity: (entity: Entity) => void; }; }>(), - withMethods(({ entities, selectedEntity2, logEntity }) => { + withMethods(({ entities, selectedEntity, selectedEntity2, logEntity }) => { const e: Signal = entities; + const se: DeepSignalOf = selectedEntity; + const seId: string | null = 'id' in se ? se.id() : null; const se2: Signal = selectedEntity2; const le: (entity: Entity) => void = logEntity;