Skip to content

Commit b8d5269

Browse files
surajy93JeanMeche
authored andcommitted
fix(core): reflect absence of param in the loader/stream typings
When the params is undefined or returns undefined, the param of the loader/stream gets the type `never`. fixes #68167
1 parent a89b565 commit b8d5269

File tree

8 files changed

+256
-24
lines changed

8 files changed

+256
-24
lines changed

goldens/public-api/core/index.api.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1637,12 +1637,16 @@ export interface Resource<T> {
16371637
}
16381638

16391639
// @public
1640-
export function resource<T, R>(options: ResourceOptions<T, R> & {
1640+
export function resource<T, R = null>(options: ResourceOptions<T, R> & {
16411641
defaultValue: NoInfer<T>;
1642-
}): ResourceRef<T>;
1642+
} & ([R] extends [null] ? {} : {
1643+
params: (ctx: ResourceParamsContext) => R;
1644+
})): ResourceRef<T>;
16431645

16441646
// @public
1645-
export function resource<T, R>(options: ResourceOptions<T, R>): ResourceRef<T | undefined>;
1647+
export function resource<T, R = null>(options: ResourceOptions<T, R> & ([R] extends [null] ? {} : {
1648+
params: (ctx: ResourceParamsContext) => R;
1649+
})): ResourceRef<T | undefined>;
16461650

16471651
// @public
16481652
export class ResourceDependencyError extends Error {

goldens/public-api/core/rxjs-interop/index.api.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,16 @@ export function outputToObservable<T>(ref: OutputRef<T>): Observable<T>;
1919
export function pendingUntilEvent<T>(injector?: Injector): MonoTypeOperatorFunction<T>;
2020

2121
// @public
22-
export function rxResource<T, R>(opts: RxResourceOptions<T, R> & {
22+
export function rxResource<T, R = null>(opts: RxResourceOptions<T, R> & {
2323
defaultValue: NoInfer<T>;
24-
}): ResourceRef<T>;
24+
} & ([R] extends [null] ? {} : {
25+
params: (ctx: ResourceParamsContext) => R;
26+
})): ResourceRef<T>;
2527

2628
// @public
27-
export function rxResource<T, R>(opts: RxResourceOptions<T, R>): ResourceRef<T | undefined>;
29+
export function rxResource<T, R = null>(opts: RxResourceOptions<T, R> & ([R] extends [null] ? {} : {
30+
params: (ctx: ResourceParamsContext) => R;
31+
})): ResourceRef<T | undefined>;
2832

2933
// @public
3034
export interface RxResourceOptions<T, R> extends BaseResourceOptions<T, R> {

packages/core/rxjs-interop/src/rx_resource.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
BaseResourceOptions,
1313
resource,
1414
ResourceLoaderParams,
15+
ResourceParamsContext,
1516
ResourceRef,
1617
ResourceStreamItem,
1718
Signal,
@@ -38,8 +39,10 @@ export interface RxResourceOptions<T, R> extends BaseResourceOptions<T, R> {
3839
*
3940
* @experimental
4041
*/
41-
export function rxResource<T, R>(
42-
opts: RxResourceOptions<T, R> & {defaultValue: NoInfer<T>},
42+
export function rxResource<T, R = null>(
43+
opts: RxResourceOptions<T, R> & {defaultValue: NoInfer<T>} & ([R] extends [null]
44+
? {}
45+
: {params: (ctx: ResourceParamsContext) => R}),
4346
): ResourceRef<T>;
4447

4548
/**
@@ -48,7 +51,10 @@ export function rxResource<T, R>(
4851
*
4952
* @experimental
5053
*/
51-
export function rxResource<T, R>(opts: RxResourceOptions<T, R>): ResourceRef<T | undefined>;
54+
export function rxResource<T, R = null>(
55+
opts: RxResourceOptions<T, R> &
56+
([R] extends [null] ? {} : {params: (ctx: ResourceParamsContext) => R}),
57+
): ResourceRef<T | undefined>;
5258
export function rxResource<T, R>(opts: RxResourceOptions<T, R>): ResourceRef<T | undefined> {
5359
if (ngDevMode && !opts?.injector) {
5460
assertInInjectionContext(rxResource);

packages/core/rxjs-interop/test/rx_resource_spec.ts

Lines changed: 125 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {of, Observable, BehaviorSubject, throwError} from 'rxjs';
10-
import {TestBed} from '../../testing';
119
import {timeout} from '@angular/private/testing';
12-
import {ApplicationRef, Injector, signal} from '../../src/core';
10+
import {BehaviorSubject, Observable, of, throwError} from 'rxjs';
11+
import {ApplicationRef, Injector, ResourceRef, signal} from '../../src/core';
12+
import {TestBed} from '../../testing';
1313
import {rxResource} from '../src';
1414

1515
describe('rxResource()', () => {
@@ -116,6 +116,128 @@ describe('rxResource()', () => {
116116
});
117117
});
118118

119+
describe('types', () => {
120+
it('should type stream params as null when params option is omitted', () => {
121+
rxResource({
122+
stream: ({params}) => {
123+
const _null: null = params;
124+
return of('');
125+
},
126+
injector: TestBed.inject(Injector),
127+
});
128+
});
129+
130+
it('should type stream params correctly when params is provided', () => {
131+
rxResource({
132+
params: () => 'foo',
133+
stream: ({params}) => {
134+
const _str: string = params;
135+
return of('');
136+
},
137+
injector: TestBed.inject(Injector),
138+
});
139+
});
140+
141+
it('should type stream params as null with explicit single generic', () => {
142+
rxResource<string>({
143+
stream: ({params}) => {
144+
const _null: null = params;
145+
return of('');
146+
},
147+
injector: TestBed.inject(Injector),
148+
});
149+
});
150+
it('should exclude undefined from stream params when params can return undefined', () => {
151+
const condition = signal(true);
152+
rxResource({
153+
params: () => (condition() ? 'foo' : undefined),
154+
stream: ({params}) => {
155+
const _str: string = params;
156+
return of('');
157+
},
158+
injector: TestBed.inject(Injector),
159+
});
160+
});
161+
162+
it('should error when two explicit generics are provided but params is absent', () => {
163+
// @ts-expect-error: params is required when the second generic is not null
164+
rxResource<string, string>({
165+
stream: ({params}) => {
166+
const _str: string = params;
167+
return of('');
168+
},
169+
injector: TestBed.inject(Injector),
170+
});
171+
});
172+
173+
it('should narrow hasValue() when the value can be undefined', () => {
174+
const result: ResourceRef<number | undefined> = rxResource({
175+
params: () => 1,
176+
stream: ({params}) => of(params),
177+
injector: TestBed.inject(Injector),
178+
});
179+
if (result.hasValue()) {
180+
const _value: number = result.value();
181+
} else if (result.isLoading()) {
182+
// @ts-expect-error
183+
const _value: number = result.value();
184+
} else if (result.error()) {
185+
}
186+
const readonly = result.asReadonly();
187+
if (readonly.hasValue()) {
188+
const _value: number = readonly.value();
189+
} else if (readonly.isLoading()) {
190+
// @ts-expect-error
191+
const _value: number = readonly.value();
192+
} else if (readonly.error()) {
193+
}
194+
});
195+
196+
it('should not narrow hasValue() when a default value is provided', () => {
197+
const result: ResourceRef<number> = rxResource({
198+
params: () => 1,
199+
stream: ({params}) => of(params),
200+
injector: TestBed.inject(Injector),
201+
defaultValue: 0,
202+
});
203+
if (result.hasValue()) {
204+
const _value: number = result.value();
205+
} else if (result.isLoading()) {
206+
const _value: number = result.value();
207+
} else if (result.error()) {
208+
}
209+
const readonly = result.asReadonly();
210+
if (readonly.hasValue()) {
211+
const _value: number = readonly.value();
212+
} else if (readonly.isLoading()) {
213+
const _value: number = readonly.value();
214+
} else if (readonly.error()) {
215+
}
216+
});
217+
218+
it('should not narrow hasValue() when the resource type is unknown', () => {
219+
const result: ResourceRef<unknown> = rxResource({
220+
params: () => 1 as unknown,
221+
stream: ({params}) => of(params),
222+
injector: TestBed.inject(Injector),
223+
defaultValue: 0,
224+
});
225+
if (result.hasValue()) {
226+
const _value: unknown = result.value();
227+
} else if (result.isLoading()) {
228+
const _value: unknown = result.value();
229+
} else if (result.error()) {
230+
}
231+
const readonly = result.asReadonly();
232+
if (readonly.hasValue()) {
233+
const _value: unknown = readonly.value();
234+
} else if (readonly.isLoading()) {
235+
const _value: unknown = readonly.value();
236+
} else if (readonly.error()) {
237+
}
238+
});
239+
});
240+
119241
async function waitFor(fn: () => boolean): Promise<void> {
120242
while (!fn()) {
121243
await timeout(1);

packages/core/src/resource/resource.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,10 @@ import {linkedSignal} from '../render3/reactivity/linked_signal';
4747
*
4848
* @experimental 19.0
4949
*/
50-
export function resource<T, R>(
51-
options: ResourceOptions<T, R> & {defaultValue: NoInfer<T>},
50+
export function resource<T, R = null>(
51+
options: ResourceOptions<T, R> & {defaultValue: NoInfer<T>} & ([R] extends [null]
52+
? {}
53+
: {params: (ctx: ResourceParamsContext) => R}),
5254
): ResourceRef<T>;
5355

5456
/**
@@ -62,8 +64,13 @@ export function resource<T, R>(
6264
* @experimental 19.0
6365
* @see [Async reactivity with resources](guide/signals/resource)
6466
*/
65-
export function resource<T, R>(options: ResourceOptions<T, R>): ResourceRef<T | undefined>;
66-
export function resource<T, R>(options: ResourceOptions<T, R>): ResourceRef<T | undefined> {
67+
export function resource<T, R = null>(
68+
options: ResourceOptions<T, R> &
69+
([R] extends [null] ? {} : {params: (ctx: ResourceParamsContext) => R}),
70+
): ResourceRef<T | undefined>;
71+
export function resource<T, R>(
72+
options: ResourceOptions<T, R> | ResourceOptions<T, never>,
73+
): ResourceRef<T | undefined> {
6774
if (ngDevMode && !options?.injector) {
6875
assertInInjectionContext(resource);
6976
}
@@ -74,7 +81,7 @@ export function resource<T, R>(options: ResourceOptions<T, R>): ResourceRef<T |
7481
const params = options.params ?? oldNameForParams ?? (() => null!);
7582
return new ResourceImpl<T | undefined, R>(
7683
params,
77-
getLoader(options),
84+
getLoader(options as ResourceOptions<T, R>),
7885
options.defaultValue,
7986
options.equal ? wrapEqualityFn(options.equal) : undefined,
8087
options.debugName,
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {BaseResourceOptions, ResourceParamsContext} from './api';
10+
11+
export type ResourceOptionsWithRequiredParams<T, R> = Omit<BaseResourceOptions<T, R>, 'params'> & {
12+
params: (ctx: ResourceParamsContext) => R;
13+
};
14+
15+
export type ResourceOptionsWithoutParams<T> = Omit<BaseResourceOptions<T, never>, 'params'> & {
16+
params?: undefined;
17+
};
18+
19+
export type ResourceOptionsWithOptionalParams<T, R> = Omit<BaseResourceOptions<T, R>, 'params'> & {
20+
params?: (ctx: ResourceParamsContext) => R;
21+
};

packages/core/test/resource/params_status_spec.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import {ApplicationRef, Injector, resource, ResourceParamsStatus, signal} from '../../src/core';
1010
import {TestBed} from '../../testing';
1111

12-
function throwStatusAndErrors<T>(source: () => T | ResourceParamsStatus | Error): () => T {
12+
function throwStatusAndErrors<T = any>(source: () => T | ResourceParamsStatus | Error): () => T {
1313
return () => {
1414
const value = source();
1515
if (value instanceof Error) throw value;
@@ -23,7 +23,7 @@ describe('resource with ResourceParamsStatus', () => {
2323
const s = signal<string | ResourceParamsStatus>('foo');
2424
const res = await act(() =>
2525
resource({
26-
params: throwStatusAndErrors(s),
26+
params: throwStatusAndErrors(s) as () => string,
2727
loader: async ({params}) => {
2828
return params;
2929
},
@@ -45,7 +45,7 @@ describe('resource with ResourceParamsStatus', () => {
4545
let loadCount = 0;
4646
const res = await act(() =>
4747
resource({
48-
params: throwStatusAndErrors(s),
48+
params: throwStatusAndErrors(s) as () => string,
4949
loader: async ({params}) => {
5050
loadCount++;
5151
return params as string;
@@ -69,7 +69,7 @@ describe('resource with ResourceParamsStatus', () => {
6969
const s = signal<string | Error>('foo');
7070
const res = await act(() =>
7171
resource({
72-
params: throwStatusAndErrors(s),
72+
params: throwStatusAndErrors(s) as () => string,
7373
loader: async ({params}) => params as string,
7474
injector: TestBed.inject(Injector),
7575
}),
@@ -90,7 +90,7 @@ describe('resource with ResourceParamsStatus', () => {
9090
let loadCount = 0;
9191
const res = await act(() =>
9292
resource({
93-
params: throwStatusAndErrors(s),
93+
params: throwStatusAndErrors(s) as () => string,
9494
loader: async ({params}) => {
9595
loadCount++;
9696
return params;

0 commit comments

Comments
 (0)