Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(store): improve selector decorator types (#1929) #2042

Merged
merged 7 commits into from
Apr 6, 2024
13 changes: 7 additions & 6 deletions packages/store/src/decorators/selector/selector.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { throwSelectorDecoratorError } from '../../configs/messages.config';
import { ɵSelectorDef } from '../../selectors';
import { createSelector } from '../../selectors/create-selector';
import { SelectorSpec, SelectorType } from './symbols';
import { SelectorDefTuple, SelectorType } from './symbols';

/**
* Decorator for creating a state selector for the current state.
Expand All @@ -11,14 +10,16 @@ export function Selector(): SelectorType<unknown>;
/**
* Decorator for creating a state selector from the provided selectors (and optionally the container State, depending on the applicable Selector Options).
*/
export function Selector<T extends ɵSelectorDef<any>>(selectors: T[]): SelectorType<T>;
export function Selector<T extends SelectorDefTuple>(selectors: T): SelectorType<T>;

export function Selector<T extends ɵSelectorDef<any>>(selectors?: T[]): SelectorType<T> {
export function Selector<T extends SelectorDefTuple = []>(
selectors?: T
): SelectorType<unknown> | SelectorType<T> {
return <U>(
target: any,
key: string | symbol,
descriptor: TypedPropertyDescriptor<SelectorSpec<T, U>>
): TypedPropertyDescriptor<SelectorSpec<T, U>> | void => {
descriptor?: TypedPropertyDescriptor<(...states: any[]) => U>
): TypedPropertyDescriptor<(...states: any[]) => U> | void => {
descriptor ||= Object.getOwnPropertyDescriptor(target, key)!;

const originalFn = descriptor?.value;
Expand Down
119 changes: 106 additions & 13 deletions packages/store/src/decorators/selector/symbols.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,106 @@
import { StateToken, ɵExtractTokenType } from '@ngxs/store/internals';

export type SelectorSpec<T, U> = [T] extends [never]
? (...states: any[]) => any
: T extends StateToken<any>
? (state: ɵExtractTokenType<T>) => U
: (...states: any[]) => any;

export type SelectorType<T> = <U>(
target: any,
key: string | symbol,
descriptor: TypedPropertyDescriptor<SelectorSpec<T, U>>
) => TypedPropertyDescriptor<SelectorSpec<T, U>> | void;
import { ɵSelectorDef, ɵSelectorReturnType } from '../../selectors';

/**
* Defines a tuple of selector functions, state tokens, and state classes that a selector decorated
* by `@Selector()` can depend on.
*/
export type SelectorDefTuple = ɵSelectorDef<any>[] | [ɵSelectorDef<any>];

type UnknownToAny<T> = unknown extends T ? any : T;
type EnsureArray<T> = T extends any[] ? T : never;

/**
* Given a tuple of selector functions, state tokens, state classes, etc., returns a tuple of what
* a dependent selector would expect to receive for that parent as an argument when called.
*
* For example, if the first element in `ParentsTuple` is a selector function that returns a
* `number`, then the first element of the result tuple will be `number`. If the second element
* in `ParentsTuple` is a state class with model `{ name: string }`, then the second element of
* the result tuple will be `{ name: string }`.
*/
type SelectorReturnTypeList<ParentsTuple extends SelectorDefTuple> = EnsureArray<{
[ParentsTupleIndex in keyof ParentsTuple]: ParentsTuple[ParentsTupleIndex] extends ɵSelectorDef<any>
? UnknownToAny<ɵSelectorReturnType<ParentsTuple[ParentsTupleIndex]>>
: never;
}>;

/**
* Defines a selector function matching a given argument list of parent selectors/states/tokens
* and a given return type.
*/
export type SelectorSpec<ParentsTuple, Return> = ParentsTuple extends []
? () => any
: ParentsTuple extends SelectorDefTuple
? (...states: SelectorReturnTypeList<ParentsTuple>) => Return
: () => any;

/**
* Defines a selector function matching `SelectorSpec<ParentsTuple, Return>` but with the assumption that the
* container state has been injected as the first argument.
*/
type SelectorSpecWithInjectedState<ParentsTuple, Return> = SelectorSpec<
ParentsTuple extends SelectorDefTuple ? [any, ...ParentsTuple] : [any],
ParentsTuple extends SelectorDefTuple ? Return : any
>;

/**
* Defines a property descriptor for the `@Selector` decorator that decorates a function with
* parent selectors/states/tokens `ParentsTuple` and return type `Return`.
*/
type DescriptorWithNoInjectedState<ParentsTuple, Return> = TypedPropertyDescriptor<
SelectorSpec<ParentsTuple, Return>
>;

/**
* Same as `DescriptorWithNoInjectedState` but with state injected as the first argument.
*/
type DescriptorWithInjectedState<ParentsTuple, Return> = TypedPropertyDescriptor<
SelectorSpecWithInjectedState<ParentsTuple, Return>
>;

type DecoratorArgs<Descriptor> = [target: any, key: string | symbol, descriptor?: Descriptor];

/**
* Defines the return type of a call to `@Selector` when there is no argument given
* (e.g. `@Selector()` counts, but `@Selector([])` does not)
*
* This result is a decorator that can only be used to decorate a function with no arguments or a
* single argument that is the container state.
*/
type SelectorTypeNoDecoratorArgs = {
<Return>(
...args: DecoratorArgs<DescriptorWithNoInjectedState<unknown, Return>>
): void | DescriptorWithNoInjectedState<unknown, Return>;
<Return>(
...args: DecoratorArgs<DescriptorWithInjectedState<unknown, Return>>
): void | DescriptorWithInjectedState<unknown, Return>;
};

/**
* Defines the return type of a call to `@Selector` when there is an argument given.
* (e.g. `@Selector([])` counts, but `@Selector()` does not)
*
* This result is a decorator that can only be used to decorate a function with an argument list
* matching the results of the tuple of parents `ParentsTuple`.
*/
type SelectorTypeWithDecoratorArgs<ParentsTuple> = {
<Return>(
...args: DecoratorArgs<DescriptorWithNoInjectedState<ParentsTuple, Return>>
): void | DescriptorWithNoInjectedState<ParentsTuple, Return>;
/**
* @deprecated
* Read the deprecation notice at this link: https://ngxs.io/deprecations/inject-container-state-deprecation.md.
*/
<Return>(
...args: DecoratorArgs<DescriptorWithInjectedState<ParentsTuple, Return>>
): void | DescriptorWithInjectedState<ParentsTuple, Return>;
};

/**
* Defines the return type of a call to `@Selector`. This result is a decorator that can only be
* used to decorate a function with an argument list matching `ParentsTuple`, the results of the
* tuple of parent selectors/state tokens/state classes.
*/
export type SelectorType<ParentsTuple> = unknown extends ParentsTuple
? SelectorTypeNoDecoratorArgs
: SelectorTypeWithDecoratorArgs<ParentsTuple>;
6 changes: 3 additions & 3 deletions packages/store/tests/selector.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ describe('Selector', () => {
}

@Selector([MyStateV4])
static invalid() {
static invalid(_: MyStateModel) {
throw new Error('This is a forced error');
}
}
Expand Down Expand Up @@ -423,7 +423,7 @@ describe('Selector', () => {
}

@Selector([MyStateV4])
static invalid() {
static invalid(_: MyStateModel) {
throw new Error('This is a forced error');
}
}
Expand Down Expand Up @@ -495,7 +495,7 @@ describe('Selector', () => {

@Selector([MyStateV3])
@SelectorOptions({ suppressErrors: false })
static invalid() {
static invalid(_: MyStateModel) {
throw new Error('This is a forced error');
}
}
Expand Down
66 changes: 65 additions & 1 deletion packages/store/types/tests/selection.lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ describe('[TEST]: Action Types', () => {
it('should be correct type in selector/select decorator', () => {
class Any {}

Selector(); // $ExpectType SelectorType<unknown>
Selector(); // $ExpectType SelectorTypeNoDecoratorArgs
assertType(() => Selector([{ foo: 'bar' }])); // $ExpectError
assertType(() => Selector({})); // $ExpectError

Expand Down Expand Up @@ -108,6 +108,10 @@ describe('[TEST]: Action Types', () => {
@State<object[]>({ name: 'themePark' })
class ThemePark {}

const numberSelector = (): number => 3;
const stringSelector = (): string => 'a';
const nullableStringSelector = (): string | null => null;

class MyPandaState {
@Selector() static pandas0 = (state: string[]): string[] => state; // $ExpectError

Expand Down Expand Up @@ -135,6 +139,66 @@ describe('[TEST]: Action Types', () => {
static pandas5(state: string[]): string[] {
return state;
}

// Injected state - selector with no matching argument
@Selector([numberSelector]) // $ExpectError
static pandas6(state: string[]): number {
return state.length;
}

// Injected state - argument with no matching selector
@Selector([numberSelector]) // $ExpectError
static pandas7(state: string[], other: number, other2: string): number {
return state.length + other + other2.length;
}

// Injected state - type mismatch (swapped string/number)
@Selector([numberSelector, stringSelector]) // $ExpectError
static pandas8(state: string[], other: string, other2: number): number {
return state.length + other.length + other2;
}

// Injected state - unhandled nullability
@Selector([numberSelector, nullableStringSelector]) // $ExpectError
static pandas9(state: string[], other: number, other2: string): number {
return state.length + other + other2.length;
}

// Injected state - correct arguments
@Selector([numberSelector, stringSelector]) // $ExpectType (state: string[], other: number, other2: string) => number
static pandas10(state: string[], other: number, other2: string): number {
return state.length + other + other2.length;
}

// No injected state - selector with no matching argument
@Selector([numberSelector]) // $ExpectError
static pandas11(): number {
return 0;
}

// No injected state - argument with no matching selector
@Selector([numberSelector]) // $ExpectError
static pandas12(other: number, other2: string): number {
return other + other2.length;
}

// No injected state - type mismatch (swapped string/number)
@Selector([numberSelector, stringSelector]) // $ExpectError
static pandas13(other: string, other2: number): number {
return other.length + other2;
}

// No injected state - unhandled nullability
@Selector([numberSelector, nullableStringSelector]) // $ExpectError
static pandas14(other: number, other2: string): number {
return other + other2.length;
}

// No injected state - correct arguments
@Selector([numberSelector, stringSelector]) // $ExpectType (other: number, other2: string) => number
static pandas15(other: number, other2: string): number {
return other + other2.length;
}
}
});

Expand Down
2 changes: 1 addition & 1 deletion packages/store/types/tests/state-token.lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ describe('[TEST]: StateToken', () => {
return state;
}

@Selector([TodoListState, TODO_LIST_TOKEN]) // (state: string[], other: number) => number
@Selector([TodoListState, TODO_LIST_TOKEN]) // $ExpectError
static todosV4(state: string[], other: number[]): number {
return state.length + other.length;
}
Expand Down
Loading