Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions modules/component-store/spec/types/component-store.types.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,5 +273,61 @@ describe('ComponentStore types', () => {
);
});
});

describe('catches excess properties', () => {
it('when extra property is returned with spread', () => {
expectSnippet(
`componentStore.updater((state, v: string) => ({...state, extraProp: 'bad'}))('test');`
).toFail(/Remove excess properties/);
});

it('when extra property is returned with explicit object', () => {
expectSnippet(
`componentStore.updater((state, v: string) => ({ prop: v, prop2: state.prop2, extraProp: 'bad' }))('test');`
).toFail(/Remove excess properties/);
});

it('when extra property is returned from void updater', () => {
expectSnippet(
`componentStore.updater((state) => ({...state, extraProp: true}))();`
).toFail(/Remove excess properties/);
});

it('when required property is missing', () => {
expectSnippet(
`componentStore.updater((state, v: string) => ({ prop: v }))('test');`
).toFail(/is missing in type/);
});

it('when property has wrong type', () => {
expectSnippet(
`componentStore.updater((state, v: string) => ({...state, prop: 123}))('test');`
).toFail(/not assignable to type/);
});

it('allows spread with override', () => {
expectSnippet(
`const sub = componentStore.updater((state, v: string) => ({...state, prop: v}))('test');`
).toInfer('sub', 'Subscription');
});

it('allows full explicit return matching all state keys', () => {
expectSnippet(
`const sub = componentStore.updater((state, v: string) => ({ prop: v, prop2: state.prop2 }))('test');`
).toInfer('sub', 'Subscription');
});

it('allows void updater with spread return', () => {
expectSnippet(
`const v = componentStore.updater((state) => ({...state, prop: 'updated'}))();`
).toInfer('v', 'void');
});

it('allows direct state return', () => {
expectSnippet(
`const v = componentStore.updater((state) => state)();`
).toInfer('v', 'void');
});
});
});
});
23 changes: 23 additions & 0 deletions modules/component-store/spec/types/regression.types.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,27 @@ describe('regression component-store', () => {
`;
expectSnippet(effectTest).toSucceed();
});

describe('updater exact return type', () => {
it('should work with state containing optional properties', () => {
expectSnippet(`
const store = new ComponentStore<{ req: string; opt?: number }>({ req: 'a' });
store.updater((state) => ({ req: 'b' }))();
`).toSucceed();
});

it('should work with state containing index signature', () => {
expectSnippet(`
const store = new ComponentStore<{ [key: string]: number }>({});
store.updater((state, v: number) => ({...state, newKey: v}))(5);
`).toSucceed();
});

it('should catch excess properties with concrete state type', () => {
expectSnippet(`
const store = new ComponentStore<{ name: string }>({ name: 'test' });
store.updater((state, v: string) => ({...state, name: v, extra: true}))('test');
`).toFail(/Remove excess properties/);
});
});
});
21 changes: 17 additions & 4 deletions modules/component-store/src/component-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ import {
import { isOnStateInitDefined, isOnStoreInitDefined } from './lifecycle_hooks';
import { toSignal } from '@angular/core/rxjs-interop';

const excessPropertiesAreNotAllowedMsg =
'updater callback return type must exactly match the state type. Remove excess properties.';
type ExcessPropertiesAreNotAllowed = typeof excessPropertiesAreNotAllowedMsg;

export interface SelectConfig<T = unknown> {
debounce?: boolean;
equal?: ValueEqualityFn<T>;
Expand Down Expand Up @@ -132,7 +136,17 @@ export class ComponentStore<T extends object> implements OnDestroy {
ReturnType = OriginType extends void
? () => void
: (observableOrValue: ValueType | Observable<ValueType>) => Subscription,
>(updaterFn: (state: T, value: OriginType) => T): ReturnType {
// Captures the actual return type to enforce exact state shape
R extends T = T,
>(
updaterFn: (
state: T,
value: OriginType
) => R &
(Exclude<keyof R, keyof T> extends never
? unknown
: ExcessPropertiesAreNotAllowed)
): ReturnType {
return ((
observableOrValue?: OriginType | Observable<OriginType>
): Subscription => {
Expand Down Expand Up @@ -379,9 +393,8 @@ export class ComponentStore<T extends object> implements OnDestroy {
// This type quickly became part of effect 'API'
ProvidedType = void,
// The actual origin$ type, which could be unknown, when not specified
OriginType extends
| Observable<ProvidedType>
| unknown = Observable<ProvidedType>,
OriginType extends Observable<ProvidedType> | unknown =
Observable<ProvidedType>,
// Unwrapped actual type of the origin$ Observable, after default was applied
ObservableType = OriginType extends Observable<infer A> ? A : never,
// Return either an optional callback or a function requiring specific types as inputs
Expand Down

This file was deleted.

Loading
Loading