From 78191f4f287ba83d77196f8392ca1363e281a968 Mon Sep 17 00:00:00 2001 From: Rainer Hahnekamp Date: Fri, 28 Aug 2026 01:44:34 +0200 Subject: [PATCH 1/7] fix(with-resource): preserve hasValue narrowing --- libs/ngrx-toolkit/src/lib/with-resource.spec.ts | 14 +++++++------- libs/ngrx-toolkit/src/lib/with-resource.ts | 4 +++- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/libs/ngrx-toolkit/src/lib/with-resource.spec.ts b/libs/ngrx-toolkit/src/lib/with-resource.spec.ts index 232f392..da0705f 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource.spec.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource.spec.ts @@ -14,7 +14,7 @@ import { withState, } from '@ngrx/signals'; import { of } from 'rxjs'; -import { Assert, AssertNot, IsEqual, Satisfies } from './test-utils/types'; +import { Assert, IsEqual, Satisfies } from './test-utils/types'; import { ErrorHandling, mapToResource, withResource } from './with-resource'; import { Address, venice, vienna } from './with-resource/tests/util/fixtures'; import { paramsForResourceTypes } from './with-resource/tests/util/params-for-resource-types'; @@ -394,7 +394,7 @@ describe('withResource', () => { } }); - it('fails on hasValue as type predicate when not explicitly typed', () => { + it('removes undefined from the inferred value type after hasValue', () => { const Store = signalStore( { providedIn: 'root' }, withResource(() => resource({ loader: () => Promise.resolve(1) })), @@ -402,7 +402,7 @@ describe('withResource', () => { const store = TestBed.inject(Store); if (store.hasValue()) { const _value = store.value(); - type _T1 = AssertNot>; + type _T1 = Assert>; } }); }); @@ -449,7 +449,7 @@ describe('withResource', () => { } }); - it('fails on hasValue as type predicate when not explicitly typed', () => { + it('removes undefined from the inferred value type after hasValue', () => { const Store = signalStore( { providedIn: 'root' }, withResource(() => resource({ loader: () => Promise.resolve(1) }), { @@ -459,7 +459,7 @@ describe('withResource', () => { const store = TestBed.inject(Store); if (store.hasValue()) { const _value = store.value(); - type _T1 = AssertNot>; + type _T1 = Assert>; } }); }); @@ -506,7 +506,7 @@ describe('withResource', () => { } }); - it('fails on hasValue as type predicate when not explicitly typed', () => { + it('removes undefined from the inferred value type after hasValue', () => { const Store = signalStore( { providedIn: 'root' }, withResource(() => resource({ loader: () => Promise.resolve(1) }), { @@ -516,7 +516,7 @@ describe('withResource', () => { const store = TestBed.inject(Store); if (store.hasValue()) { const _value = store.value(); - type _T1 = AssertNot>; + type _T1 = Assert>; } }); }, diff --git a/libs/ngrx-toolkit/src/lib/with-resource.ts b/libs/ngrx-toolkit/src/lib/with-resource.ts index ef28ef7..b95accc 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource.ts @@ -29,7 +29,9 @@ export type ResourceResult = { snapshot: Signal>; }; methods: { - hasValue(): this is Resource>; + hasValue: >( + this: R, + ) => this is Resource> & R; _reload(): boolean; }; }; From 62fc7b96cd9f15ad8ed9b2d5e532b273480aa8ad Mon Sep 17 00:00:00 2001 From: Michael Small Date: Thu, 27 Aug 2026 19:38:26 -0500 Subject: [PATCH 2/7] test(with-resource): cover named hasValue narrowing --- .../src/lib/with-resource.spec.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/libs/ngrx-toolkit/src/lib/with-resource.spec.ts b/libs/ngrx-toolkit/src/lib/with-resource.spec.ts index da0705f..7507825 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource.spec.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource.spec.ts @@ -462,6 +462,29 @@ describe('withResource', () => { type _T1 = Assert>; } }); + // Named resource is not narrowing + it('removes undefined from the inferred value type after hasValue', () => { + const Store = signalStore( + { providedIn: 'root' }, + withResource( + () => { + return { + thing: resource({ + loader: () => Promise.resolve(1), + }), + }; + }, + { + errorHandling: 'native', + }, + ), + ); + const store = TestBed.inject(Store); + if (store.thingHasValue()) { + const _value = store.thingValue(); + type _T1 = Assert>; + } + }); }); describe.each(['previous value', 'native'] as const)( From 769025f61386fe67790ca612b5dff3280de79ae7 Mon Sep 17 00:00:00 2001 From: Michael Small Date: Sat, 29 Aug 2026 10:09:15 -0500 Subject: [PATCH 3/7] fix: undefined error handling + named resource hasValue narrowing --- .../with-resource.component.html | 19 ++++++++ .../with-resource/with-resource.component.ts | 45 +++++++++++-------- .../src/lib/with-resource.spec.ts | 4 +- libs/ngrx-toolkit/src/lib/with-resource.ts | 23 ++++++++-- 4 files changed, 68 insertions(+), 23 deletions(-) create mode 100644 apps/demo/src/app/with-resource/with-resource.component.html diff --git a/apps/demo/src/app/with-resource/with-resource.component.html b/apps/demo/src/app/with-resource/with-resource.component.html new file mode 100644 index 0000000..5b8d1f9 --- /dev/null +++ b/apps/demo/src/app/with-resource/with-resource.component.html @@ -0,0 +1,19 @@ +, +

withResource

+withResource doc page + +

Single Resource

+
value: {{ store.value() | json }}
+
status: {{ store.status() }}
+
error: {{ store.error() | json }}
+
hasValue: {{ store.hasValue() }}
+ +

Named Resource

+
{{ store.listValue() | json }}
+
status: {{ store.listStatus() }}
+
error: {{ store.listError() | json }}
+
hasValue: {{ store.listHasValue() }}
diff --git a/apps/demo/src/app/with-resource/with-resource.component.ts b/apps/demo/src/app/with-resource/with-resource.component.ts index 43b5d42..dfccbe3 100644 --- a/apps/demo/src/app/with-resource/with-resource.component.ts +++ b/apps/demo/src/app/with-resource/with-resource.component.ts @@ -9,7 +9,9 @@ const url = 'https://demo.angulararchitects.io/api/flight?from=Paris&to='; export const FlightStore = signalStore( withState({ flightTo: 'New York' }), - withResource(({ flightTo }) => httpResource(() => `${url}${flightTo()}`)), + withResource(({ flightTo }) => + httpResource(() => `${url}${flightTo()}`), + ), withResource(({ flightTo }) => ({ list: httpResource(() => `${url}${flightTo()}`, { defaultValue: [], @@ -20,27 +22,34 @@ export const FlightStore = signalStore( @Component({ selector: 'demo-with-resource', imports: [JsonPipe], - template: `, -

withResource

- withResource doc page + // template: `, + //

withResource

+ // withResource doc page -

Single Resource

-
value: {{ store.value() | json }}
-
status: {{ store.status() }}
-
error: {{ store.error() | json }}
-
hasValue: {{ store.hasValue() }}
+ //

Single Resource

+ //
value: {{ store.value() | json }}
+ //
status: {{ store.status() }}
+ //
error: {{ store.error() | json }}
+ //
hasValue: {{ store.hasValue() }}
-

Named Resource

-
{{ store.listValue() | json }}
-
status: {{ store.listStatus() }}
-
error: {{ store.listError() | json }}
-
hasValue: {{ store.listHasValue() }}
`, + //

Named Resource

+ //
{{ store.listValue() | json }}
+ //
status: {{ store.listStatus() }}
+ //
error: {{ store.listError() | json }}
+ //
hasValue: {{ store.listHasValue() }}
`, + templateUrl: './with-resource.component.html', providers: [FlightStore], }) export class WithResourceComponent { store = inject(FlightStore); + + constructor() { + if (this.store.hasValue()) { + const value = this.store.value(); + } + } } diff --git a/libs/ngrx-toolkit/src/lib/with-resource.spec.ts b/libs/ngrx-toolkit/src/lib/with-resource.spec.ts index 7507825..da42dbe 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource.spec.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource.spec.ts @@ -462,8 +462,8 @@ describe('withResource', () => { type _T1 = Assert>; } }); - // Named resource is not narrowing - it('removes undefined from the inferred value type after hasValue', () => { + + it('removes undefined from the inferred value type after hasValue for named resources', () => { const Store = signalStore( { providedIn: 'root' }, withResource( diff --git a/libs/ngrx-toolkit/src/lib/with-resource.ts b/libs/ngrx-toolkit/src/lib/with-resource.ts index b95accc..924a264 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource.ts @@ -60,9 +60,26 @@ export type NamedResourceResult< [Prop in keyof T as `${Prop & string}Snapshot`]: T[Prop]['snapshot']; }; methods: { - [Prop in keyof T as `${Prop & string}HasValue`]: () => this is Resource< - Exclude - >; + [Prop in keyof T as `${Prop & string}HasValue`]: < + R extends Record< + `${Prop & string}Value`, + Signal< + T[Prop]['value'] extends Signal + ? HasUndefinedErrorHandling extends true + ? S | undefined + : S + : never + > + >, + >( + this: R, + ) => this is Record< + `${Prop & string}Value`, + Signal< + Exclude ? S : never, undefined> + > + > & + R; } & { [Prop in keyof T as `_${Prop & string}Reload`]: () => boolean; }; From d15821669f50698b18bb0225d6779c06b241e44a Mon Sep 17 00:00:00 2001 From: Michael Small Date: Sat, 29 Aug 2026 10:17:58 -0500 Subject: [PATCH 4/7] chore: add tests for named resource `hasValue` --- .../src/lib/with-resource.spec.ts | 148 +++++++++++++++++- 1 file changed, 145 insertions(+), 3 deletions(-) diff --git a/libs/ngrx-toolkit/src/lib/with-resource.spec.ts b/libs/ngrx-toolkit/src/lib/with-resource.spec.ts index da42dbe..9d8bd12 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource.spec.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource.spec.ts @@ -463,8 +463,8 @@ describe('withResource', () => { } }); - it('removes undefined from the inferred value type after hasValue for named resources', () => { - const Store = signalStore( + it('removes undefined from the inferred value type after hasValue for named resources (no defaults)', () => { + const StoreNativeError = signalStore( { providedIn: 'root' }, withResource( () => { @@ -479,14 +479,156 @@ describe('withResource', () => { }, ), ); - const store = TestBed.inject(Store); + const store = TestBed.inject(StoreNativeError); + + const _valueNative = store.thingValue(); + type _TNative = Assert< + IsEqual + >; + if (store.thingHasValue()) { const _value = store.thingValue(); type _T1 = Assert>; } + + const StorePreviousValue = signalStore( + { providedIn: 'root' }, + withResource( + () => { + return { + thing: resource({ + loader: () => Promise.resolve(1), + }), + }; + }, + { + errorHandling: 'previous value', + }, + ), + ); + const storePreviousValue = TestBed.inject(StorePreviousValue); + + const _valuePrevious = storePreviousValue.thingValue(); + type _TPrev = Assert< + IsEqual + >; + + if (storePreviousValue.thingHasValue()) { + const _value = storePreviousValue.thingValue(); + type _T1 = Assert>; + } + + const StoreUndefinedValue = signalStore( + { providedIn: 'root' }, + withResource( + () => { + return { + thing: resource({ + loader: () => Promise.resolve(1), + }), + }; + }, + { + errorHandling: 'undefined value', + }, + ), + ); + const storeUndefinedValue = TestBed.inject(StoreUndefinedValue); + + const _valueUndefined = storeUndefinedValue.thingValue(); + type _TUndefined = Assert< + IsEqual + >; + + if (storeUndefinedValue.thingHasValue()) { + const _value = storeUndefinedValue.thingValue(); + type _T1 = Assert>; + } }); }); + it('removes undefined from the inferred value type after hasValue for named resources (defaults)', () => { + const StoreNativeError = signalStore( + { providedIn: 'root' }, + withResource( + () => { + return { + thing: resource({ + loader: () => Promise.resolve(1), + defaultValue: 0, + }), + }; + }, + { + errorHandling: 'native', + }, + ), + ); + const store = TestBed.inject(StoreNativeError); + + const _valueNative = store.thingValue(); + type _TNative = Assert>; + + if (store.thingHasValue()) { + const _value = store.thingValue(); + type _T1 = Assert>; + } + + const StorePreviousValue = signalStore( + { providedIn: 'root' }, + withResource( + () => { + return { + thing: resource({ + loader: () => Promise.resolve(1), + defaultValue: 0, + }), + }; + }, + { + errorHandling: 'previous value', + }, + ), + ); + const storePreviousValue = TestBed.inject(StorePreviousValue); + + const _valuePrevious = storePreviousValue.thingValue(); + type _TPrev = Assert>; + + if (storePreviousValue.thingHasValue()) { + const _value = storePreviousValue.thingValue(); + type _T1 = Assert>; + } + + const StoreUndefinedValue = signalStore( + { providedIn: 'root' }, + withResource( + () => { + return { + thing: resource({ + loader: () => Promise.resolve(1), + defaultValue: 0, + }), + }; + }, + { + errorHandling: 'undefined value', + }, + ), + ); + const storeUndefinedValue = TestBed.inject(StoreUndefinedValue); + + const _valueUndefined = storeUndefinedValue.thingValue(); + type _TUndefined = Assert< + IsEqual + >; + + if (storeUndefinedValue.thingHasValue()) { + const _value = storeUndefinedValue.thingValue(); + type _T1 = Assert>; + } + }); + describe.each(['previous value', 'native'] as const)( `Error Handling: %s`, (errorHandling) => { From feb4f866b94d0a5ffb0044bbcfd6ec44fcbe4c36 Mon Sep 17 00:00:00 2001 From: Michael Small Date: Sat, 29 Aug 2026 10:21:25 -0500 Subject: [PATCH 5/7] chore: update demo `withResource` example --- .../with-resource.component.html | 7 +++++- .../with-resource/with-resource.component.ts | 25 ------------------- 2 files changed, 6 insertions(+), 26 deletions(-) diff --git a/apps/demo/src/app/with-resource/with-resource.component.html b/apps/demo/src/app/with-resource/with-resource.component.html index 5b8d1f9..cf30156 100644 --- a/apps/demo/src/app/with-resource/with-resource.component.html +++ b/apps/demo/src/app/with-resource/with-resource.component.html @@ -1,4 +1,3 @@ -,

withResource

Single Resource
status: {{ store.status() }}
error: {{ store.error() | json }}
hasValue: {{ store.hasValue() }}
+@if (store.hasValue()) { +
value: {{ store.value() | json }}
+}

Named Resource

{{ store.listValue() | json }}
status: {{ store.listStatus() }}
error: {{ store.listError() | json }}
hasValue: {{ store.listHasValue() }}
+@if (store.listHasValue()) { +
value: {{ store.listValue() | json }}
+} diff --git a/apps/demo/src/app/with-resource/with-resource.component.ts b/apps/demo/src/app/with-resource/with-resource.component.ts index dfccbe3..d77d9ee 100644 --- a/apps/demo/src/app/with-resource/with-resource.component.ts +++ b/apps/demo/src/app/with-resource/with-resource.component.ts @@ -22,34 +22,9 @@ export const FlightStore = signalStore( @Component({ selector: 'demo-with-resource', imports: [JsonPipe], - // template: `, - //

withResource

- //
withResource doc page - - //

Single Resource

- //
value: {{ store.value() | json }}
- //
status: {{ store.status() }}
- //
error: {{ store.error() | json }}
- //
hasValue: {{ store.hasValue() }}
- - //

Named Resource

- //
{{ store.listValue() | json }}
- //
status: {{ store.listStatus() }}
- //
error: {{ store.listError() | json }}
- //
hasValue: {{ store.listHasValue() }}
`, templateUrl: './with-resource.component.html', providers: [FlightStore], }) export class WithResourceComponent { store = inject(FlightStore); - - constructor() { - if (this.store.hasValue()) { - const value = this.store.value(); - } - } } From c553a1ce5747013574b8c98393d2836279d88935 Mon Sep 17 00:00:00 2001 From: Rainer Hahnekamp Date: Sun, 30 Aug 2026 00:10:03 +0200 Subject: [PATCH 6/7] docs(with-resource): explain hasValue predicate typing --- libs/ngrx-toolkit/src/lib/with-resource.ts | 38 ++++++++++++++++++---- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/libs/ngrx-toolkit/src/lib/with-resource.ts b/libs/ngrx-toolkit/src/lib/with-resource.ts index 924a264..95cf6ce 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource.ts @@ -20,6 +20,32 @@ import { withProps, } from '@ngrx/signals'; +/** + * `hasValue` captures the original type of `this` as `Original` and narrows + * only the resource portion of that type. + * + * The intersection order is significant because Signals are callable. + * TypeScript treats intersections of callable types as overloads and uses the + * first matching call signature. The narrowed resource must therefore come + * before `Original`; otherwise, `value()` resolves to the original + * `T | undefined`. + * + * ```ts + * type A = { + * id: () => number | undefined; + * hasId: ( + * this: Original, + * ) => this is { id: () => number } & Original; + * }; + * + * function read(a: A) { + * if (a.hasId()) { + * const id = a.id(); // number + * } + * } + * ``` + */ + export type ResourceResult = { state: { value: T }; props: { @@ -29,9 +55,9 @@ export type ResourceResult = { snapshot: Signal>; }; methods: { - hasValue: >( - this: R, - ) => this is Resource> & R; + hasValue: >( + this: Original, + ) => this is Resource> & Original; _reload(): boolean; }; }; @@ -61,7 +87,7 @@ export type NamedResourceResult< }; methods: { [Prop in keyof T as `${Prop & string}HasValue`]: < - R extends Record< + Original extends Record< `${Prop & string}Value`, Signal< T[Prop]['value'] extends Signal @@ -72,14 +98,14 @@ export type NamedResourceResult< > >, >( - this: R, + this: Original, ) => this is Record< `${Prop & string}Value`, Signal< Exclude ? S : never, undefined> > > & - R; + Original; } & { [Prop in keyof T as `_${Prop & string}Reload`]: () => boolean; }; From a5f7a8ecb5afd453ea49598f5f387b12119d6b07 Mon Sep 17 00:00:00 2001 From: Michael Small Date: Sat, 29 Aug 2026 19:53:04 -0500 Subject: [PATCH 7/7] docs: remove `hasValue` warning from resource features --- docs/docs/with-entity-resources.md | 2 -- docs/docs/with-resource.md | 2 -- 2 files changed, 4 deletions(-) diff --git a/docs/docs/with-entity-resources.md b/docs/docs/with-entity-resources.md index a6eb300..a4253d8 100644 --- a/docs/docs/with-entity-resources.md +++ b/docs/docs/with-entity-resources.md @@ -6,8 +6,6 @@ title: withEntityResources() import { withEntityResources } from '@angular-architects/ngrx-toolkit'; ``` -> **⚠️ Important Note**: We have found some issues with `hasValue()` not narrowing correctly. If you have any insights or want to follow developments, please refer to our issue: ["bug(withResource and Mutations): hasValue() does not narrow the respective value signal #235"](https://github.com/angular-architects/ngrx-toolkit/issues/235) - `withEntityResources()` integrates Angular Resources that return arrays into NgRx SignalStore using the Entity helpers from `@ngrx/signals/entities`. > Note: This feature builds on [withResource()](./with-resource.md) and adds an entity view over array resources. diff --git a/docs/docs/with-resource.md b/docs/docs/with-resource.md index 0bc1158..f976e8e 100644 --- a/docs/docs/with-resource.md +++ b/docs/docs/with-resource.md @@ -8,8 +8,6 @@ import { withResource } from '@angular-architects/ngrx-toolkit'; > **⚠️ Important Note**: This extension is very likely to land in NgRx once Angular's `Resource` enters developer preview. The `withResource` extension provides early access to this functionality and will be maintained for compatibility until the official NgRx implementation is available. -> **⚠️ Important Note**: We have found some issues with `hasValue()` not narrowing correctly. If you have any insights or want to follow developments, please refer to our issue: ["bug(withResource and Mutations): hasValue() does not narrow the respective value signal #235"](https://github.com/angular-architects/ngrx-toolkit/issues/235) - `withResource()` is a feature in NgRx SignalStore that connects Angular's Resource API with the store. The idea: you can use a store to directly manage async data (like loading from an API), and `withResource()` helps you wire that in.