Skip to content

Commit b54b9b6

Browse files
fix(component-store): adjust updater to accept partials (#2765)
Closes #2754
1 parent 207da44 commit b54b9b6

File tree

2 files changed

+190
-41
lines changed

2 files changed

+190
-41
lines changed

modules/component-store/spec/types/component-store.types.spec.ts

Lines changed: 174 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ describe('ComponentStore types', () => {
99
import { of, EMPTY, Observable } from 'rxjs';
1010
import { concatMap } from 'rxjs/operators';
1111
12+
interface Obj {
13+
prop: string;
14+
}
15+
1216
const number$: Observable<number> = of(5);
1317
const string$: Observable<string> = of('string');
1418
@@ -20,76 +24,88 @@ describe('ComponentStore types', () => {
2024

2125
describe('infers Subscription', () => {
2226
it('when argument type is specified and a variable with corresponding type is passed', () => {
23-
expectSnippet(
24-
`const eff = componentStore.effect((e: Observable<string>) => number$)('string');`
25-
).toInfer('eff', 'Subscription');
27+
const effectTest = `const sub = componentStore.effect((e: Observable<string>) => number$)('string');`;
28+
expectSnippet(effectTest).toSucceed();
29+
expectSnippet(effectTest).toInfer('sub', 'Subscription');
2630
});
2731

2832
it(
2933
'when argument type is specified, returns EMPTY and ' +
3034
'a variable with corresponding type is passed',
3135
() => {
32-
expectSnippet(
33-
`const eff = componentStore.effect((e: Observable<string>) => EMPTY)('string');`
34-
).toInfer('eff', 'Subscription');
36+
const effectTest = `const sub = componentStore.effect((e: Observable<string>) => EMPTY)('string');`;
37+
expectSnippet(effectTest).toSucceed();
38+
expectSnippet(effectTest).toInfer('sub', 'Subscription');
3539
}
3640
);
3741

3842
it('when argument type is specified and an Observable with corresponding type is passed', () => {
39-
expectSnippet(
40-
`const eff = componentStore.effect((e: Observable<string>) => EMPTY)(string$);`
41-
).toInfer('eff', 'Subscription');
43+
const effectTest = `const sub = componentStore.effect((e: Observable<string>) => EMPTY)(string$);`;
44+
expectSnippet(effectTest).toSucceed();
45+
expectSnippet(effectTest).toInfer('sub', 'Subscription');
4246
});
4347

4448
it('when argument type is specified as Observable<unknown> and any type is passed', () => {
45-
expectSnippet(
46-
`const eff = componentStore.effect((e: Observable<unknown>) => EMPTY)(5);`
47-
).toInfer('eff', 'Subscription');
49+
const effectTest = `const sub = componentStore.effect((e: Observable<unknown>) => EMPTY)(5);`;
50+
expectSnippet(effectTest).toSucceed();
51+
expectSnippet(effectTest).toInfer('sub', 'Subscription');
4852
});
4953

5054
it('when generic type is specified and a variable with corresponding type is passed', () => {
51-
expectSnippet(
52-
`const eff = componentStore.effect<string>((e) => number$)('string');`
53-
).toInfer('eff', 'Subscription');
55+
const effectTest = `const sub = componentStore.effect<string>((e) => number$)('string');`;
56+
expectSnippet(effectTest).toSucceed();
57+
expectSnippet(effectTest).toInfer('sub', 'Subscription');
5458
});
5559

5660
it('when generic type is specified as unknown and a variable with any type is passed', () => {
57-
expectSnippet(
58-
`const eff = componentStore.effect<unknown>((e) => number$)('string');`
59-
).toInfer('eff', 'Subscription');
61+
const effectTest = `const sub = componentStore.effect<unknown>((e) => number$)('string');`;
62+
expectSnippet(effectTest).toSucceed();
63+
expectSnippet(effectTest).toInfer('sub', 'Subscription');
6064
});
6165

6266
it('when generic type is specified as unknown and origin can still be piped', () => {
63-
expectSnippet(
64-
`const eff = componentStore.effect<unknown>((e) => e.pipe(concatMap(() => of())))('string');`
65-
).toInfer('eff', 'Subscription');
67+
const effectTest = `const sub = componentStore.effect<unknown>((e) => e.pipe(concatMap(() => of())))('string');`;
68+
expectSnippet(effectTest).toSucceed();
69+
expectSnippet(effectTest).toInfer('sub', 'Subscription');
6670
});
6771

6872
it('when generic type is specified as unknown and origin can still be piped', () => {
6973
expectSnippet(
70-
`const eff = componentStore.effect<unknown>((e) => e.pipe(concatMap(() => of())))('string');`
71-
).toInfer('eff', 'Subscription');
74+
`const sub = componentStore.effect<unknown>((e) => e.pipe(concatMap(() => of())))('string');`
75+
).toInfer('sub', 'Subscription');
76+
});
77+
78+
it('when argument type is an interface and a variable with corresponding type is passed', () => {
79+
const effectTest = `const sub = componentStore.effect((e: Observable<Obj>) => number$)({prop: 'string'});`;
80+
expectSnippet(effectTest).toSucceed();
81+
expectSnippet(effectTest).toInfer('sub', 'Subscription');
82+
});
83+
84+
it('when argument type is a partial interface and a variable with corresponding type is passed', () => {
85+
const effectTest = `const sub = componentStore.effect((e: Observable<Partial<Obj>>) => number$)({prop: 'string'});`;
86+
expectSnippet(effectTest).toSucceed();
87+
expectSnippet(effectTest).toInfer('sub', 'Subscription');
7288
});
7389
});
7490

7591
describe('infers void', () => {
7692
it('when argument type is specified as Observable<void> and nothing is passed', () => {
77-
expectSnippet(
78-
`const eff = componentStore.effect((e: Observable<void>) => string$)();`
79-
).toInfer('eff', 'void');
93+
const effectTest = `const v = componentStore.effect((e: Observable<void>) => string$)();`;
94+
expectSnippet(effectTest).toSucceed();
95+
expectSnippet(effectTest).toInfer('v', 'void');
8096
});
8197

8298
it('when type is not specified and origin can still be piped', () => {
83-
expectSnippet(
84-
// treated as Observable<void> 👇
85-
`const eff = componentStore.effect((e) => e.pipe(concatMap(() => of())))();`
86-
).toInfer('eff', 'void');
99+
// treated as Observable<void> 👇
100+
const effectTest = `const v = componentStore.effect((e) => e.pipe(concatMap(() => of())))();`;
101+
expectSnippet(effectTest).toSucceed();
102+
expectSnippet(effectTest).toInfer('v', 'void');
87103
});
88104

89105
it('when generic type is specified as void and origin can still be piped', () => {
90-
expectSnippet(
91-
`const eff = componentStore.effect<void>((e) => e.pipe(concatMap(() => number$)))();`
92-
).toInfer('eff', 'void');
106+
const effectTest = `const v = componentStore.effect<void>((e) => e.pipe(concatMap(() => number$)))();`;
107+
expectSnippet(effectTest).toSucceed();
108+
expectSnippet(effectTest).toInfer('v', 'void');
93109
});
94110
});
95111

@@ -144,7 +160,7 @@ describe('ComponentStore types', () => {
144160

145161
it('when type is not specified and anything is passed', () => {
146162
expectSnippet(
147-
`const eff = componentStore.effect((e) => EMPTY)('string');`
163+
`const sub = componentStore.effect((e) => EMPTY)('string');`
148164
).toFail(/Expected 0 arguments, but got 1/);
149165
});
150166

@@ -155,4 +171,128 @@ describe('ComponentStore types', () => {
155171
});
156172
});
157173
});
174+
175+
describe('updater', () => {
176+
const expectSnippet = expecter(
177+
(code) => `
178+
import { ComponentStore } from '@ngrx/component-store';
179+
import { of, EMPTY, Observable } from 'rxjs';
180+
import { concatMap } from 'rxjs/operators';
181+
182+
export enum LoadingState {
183+
INIT = 'INIT',
184+
LOADING = 'LOADING',
185+
LOADED = 'LOADED',
186+
ERROR = 'ERROR',
187+
}
188+
189+
interface Obj {
190+
prop: string;
191+
}
192+
193+
const number$: Observable<number> = of(5);
194+
const string$: Observable<string> = of('string');
195+
196+
const componentStore = new ComponentStore({ prop: 'init', prop2: 'yeah!'});
197+
${code}
198+
`,
199+
compilerOptions()
200+
);
201+
202+
describe('infers Subscription', () => {
203+
it('when argument type is specified and a variable with corresponding type is passed', () => {
204+
const updaterTest = `const sub = componentStore.updater((state, v: string) => ({...state}))('string');`;
205+
expectSnippet(updaterTest).toSucceed();
206+
expectSnippet(updaterTest).toInfer('sub', 'Subscription');
207+
});
208+
209+
it('when argument type is specified and an Observable with corresponding type is passed', () => {
210+
const updaterTest = `const sub = componentStore.updater((state, v: string) => ({...state}))(string$);`;
211+
expectSnippet(updaterTest).toSucceed();
212+
expectSnippet(updaterTest).toInfer('sub', 'Subscription');
213+
});
214+
215+
it('when argument type is an interface and a variable with corresponding type is passed', () => {
216+
const updaterTest = `const sub = componentStore.updater((state, v: Obj) => ({...state}))({prop: 'obj'});`;
217+
expectSnippet(updaterTest).toSucceed();
218+
expectSnippet(updaterTest).toInfer('sub', 'Subscription');
219+
});
220+
221+
it('when argument type is an partial interface and a variable with corresponding type is passed', () => {
222+
const updaterTest = `const sub = componentStore.updater((state, v: Partial<Obj>) => ({...state}))({prop: 'obj'});`;
223+
expectSnippet(updaterTest).toSucceed();
224+
expectSnippet(updaterTest).toInfer('sub', 'Subscription');
225+
});
226+
227+
it('when argument type is an enum and a variable with corresponding type is passed', () => {
228+
const updaterTest = `const sub = componentStore.updater((state, v: LoadingState) => ({...state}))(LoadingState.LOADED);`;
229+
expectSnippet(updaterTest).toSucceed();
230+
expectSnippet(updaterTest).toInfer('sub', 'Subscription');
231+
});
232+
233+
it('when argument type is a union and a variable with corresponding type is passed', () => {
234+
const updaterTest = `const sub = componentStore.updater((state, v: string|number) => ({...state}))(5);`;
235+
expectSnippet(updaterTest).toSucceed();
236+
expectSnippet(updaterTest).toInfer('sub', 'Subscription');
237+
});
238+
239+
it('when argument type is an intersection and a variable with corresponding type is passed', () => {
240+
const updaterTest = `const sub = componentStore.updater((state, v: {p: string} & {p2: number}) => ({...state}))({p: 's', p2: 3});`;
241+
expectSnippet(updaterTest).toSucceed();
242+
expectSnippet(updaterTest).toInfer('sub', 'Subscription');
243+
});
244+
245+
it('when argument type is unknown and any variable is passed', () => {
246+
const updaterTest = `const sub = componentStore.updater((state, v: unknown) => ({...state}))({anything: 'works'});`;
247+
expectSnippet(updaterTest).toSucceed();
248+
expectSnippet(updaterTest).toInfer('sub', 'Subscription');
249+
});
250+
251+
it('when generic type is specified and any variable is passed', () => {
252+
const updaterTest = `const sub = componentStore.updater<string>((state, v) => ({...state}))('works');`;
253+
expectSnippet(updaterTest).toSucceed();
254+
expectSnippet(updaterTest).toInfer('sub', 'Subscription');
255+
});
256+
257+
it('when type is not specified and nothing is passed', () => {
258+
const updaterTest = `const v = componentStore.updater((state) => ({...state}))();`;
259+
expectSnippet(updaterTest).toSucceed();
260+
expectSnippet(updaterTest).toInfer('v', 'void');
261+
});
262+
263+
it('when type void is specified and nothing is passed', () => {
264+
const updaterTest = `const v = componentStore.updater<void>((state) => ({...state}))();`;
265+
expectSnippet(updaterTest).toSucceed();
266+
expectSnippet(updaterTest).toInfer('v', 'void');
267+
});
268+
});
269+
270+
describe('catches improper usage', () => {
271+
it('when type is specified and argument is not passed', () => {
272+
expectSnippet(
273+
`const sub = componentStore.updater((state, v: string) => ({...state}))();`
274+
).toFail(/Expected 1 arguments, but got 0/);
275+
});
276+
277+
it('when argument type is unknown and nothing is passed', () => {
278+
expectSnippet(
279+
`const sub = componentStore.updater((state, v: unknown) => ({...state}))();`
280+
).toFail(/Expected 1 arguments, but got 0/);
281+
});
282+
283+
it('when no argument is expected but one is passed', () => {
284+
expectSnippet(
285+
`const sub = componentStore.updater((state) => ({...state}))('string');`
286+
).toFail(/Expected 0 arguments, but got 1/);
287+
});
288+
289+
it('when type is specified and Observable argument of incorrect type is passed', () => {
290+
expectSnippet(
291+
`const sub = componentStore.updater((state, v: string) => ({...state}))(number$);`
292+
).toFail(
293+
/Argument of type 'Observable<number>' is not assignable to parameter of type 'string \| Observable<string>'/
294+
);
295+
});
296+
});
297+
});
158298
});

modules/component-store/src/component-store.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,21 @@ export class ComponentStore<T extends object> implements OnDestroy {
7979
* second argument to `updaterFn`. Every time this function is called
8080
* subscribers will be notified of the state change.
8181
*/
82-
updater<V>(
83-
updaterFn: (state: T, value: V) => T
84-
): unknown extends V ? () => void : (t: V | Observable<V>) => Subscription {
85-
return ((observableOrValue?: V | Observable<V>): Subscription => {
82+
updater<
83+
// Allow to force-provide the type
84+
ProvidedType = void,
85+
// This type is derived from the `value` property, defaulting to void if it's missing
86+
OriginType = ProvidedType,
87+
// The Value type is assigned from the Origin
88+
ValueType = OriginType,
89+
// Return either an empty callback or a function requiring specific types as inputs
90+
ReturnType = OriginType extends void
91+
? () => void
92+
: (observableOrValue: ValueType | Observable<ValueType>) => Subscription
93+
>(updaterFn: (state: T, value: OriginType) => T): ReturnType {
94+
return (((
95+
observableOrValue?: OriginType | Observable<OriginType>
96+
): Subscription => {
8697
let initializationError: Error | undefined;
8798
// We can receive either the value or an observable. In case it's a
8899
// simple value, we'll wrap it with `of` operator to turn it into
@@ -118,9 +129,7 @@ export class ComponentStore<T extends object> implements OnDestroy {
118129
throw /** @type {!Error} */ (initializationError);
119130
}
120131
return subscription;
121-
}) as unknown extends V
122-
? () => void
123-
: (t: V | Observable<V>) => Subscription;
132+
}) as unknown) as ReturnType;
124133
}
125134

126135
/**

0 commit comments

Comments
 (0)