Skip to content

Commit

Permalink
Merge pull request #673 from aryaemami59/typed-createSelector
Browse files Browse the repository at this point in the history
Introduce pre-typed `createSelector` via `createSelector.withTypes<RootState>()` method
  • Loading branch information
EskiMojo14 authored Jan 3, 2024
2 parents 6a44706 + bf43671 commit 7f6715a
Show file tree
Hide file tree
Showing 25 changed files with 644 additions and 105 deletions.
28 changes: 28 additions & 0 deletions docs/examples/createSelector/annotateResultFunction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { createSelector } from 'reselect'

interface Todo {
id: number
completed: boolean
}

interface Alert {
id: number
read: boolean
}

export interface RootState {
todos: Todo[]
alerts: Alert[]
}

export const createAppSelector = createSelector.withTypes<RootState>()

const selectTodoIds = createAppSelector(
// Type of `state` is set to `RootState`, no need to manually set the type
state => state.todos,
// ❌ Known limitation: Parameter types are not inferred in this scenario
// so you will have to manually annotate them.
// highlight-start
(todos: Todo[]) => todos.map(({ id }) => id)
// highlight-end
)
36 changes: 36 additions & 0 deletions docs/examples/createSelector/createAppSelector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import microMemoize from 'micro-memoize'
import { shallowEqual } from 'react-redux'
import { createSelectorCreator, lruMemoize } from 'reselect'

export interface RootState {
todos: { id: number; completed: boolean }[]
alerts: { id: number; read: boolean }[]
}

export const createAppSelector = createSelectorCreator({
memoize: lruMemoize,
argsMemoize: microMemoize,
memoizeOptions: {
maxSize: 10,
equalityCheck: shallowEqual,
resultEqualityCheck: shallowEqual
},
argsMemoizeOptions: {
isEqual: shallowEqual,
maxSize: 10
},
devModeChecks: {
identityFunctionCheck: 'never',
inputStabilityCheck: 'always'
}
}).withTypes<RootState>()

const selectReadAlerts = createAppSelector(
[
// Type of `state` is set to `RootState`, no need to manually set the type
// highlight-start
state => state.alerts
// highlight-end
],
alerts => alerts.filter(({ read }) => read)
)
18 changes: 18 additions & 0 deletions docs/examples/createSelector/withTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { createSelector } from 'reselect'

export interface RootState {
todos: { id: number; completed: boolean }[]
alerts: { id: number; read: boolean }[]
}

export const createAppSelector = createSelector.withTypes<RootState>()

const selectTodoIds = createAppSelector(
[
// Type of `state` is set to `RootState`, no need to manually set the type
// highlight-start
state => state.todos
// highlight-end
],
todos => todos.map(({ id }) => id)
)
64 changes: 56 additions & 8 deletions src/createSelectorCreator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,14 @@ import {
*
* @template MemoizeFunction - The type of the memoize function that is used to memoize the `resultFunc` inside `createSelector` (e.g., `lruMemoize` or `weakMapMemoize`).
* @template ArgsMemoizeFunction - The type of the optional memoize function that is used to memoize the arguments passed into the output selector generated by `createSelector` (e.g., `lruMemoize` or `weakMapMemoize`). If none is explicitly provided, `weakMapMemoize` will be used.
* @template StateType - The type of state that the selectors created with this selector creator will operate on.
*
* @public
*/
export interface CreateSelectorFunction<
MemoizeFunction extends UnknownMemoizer = typeof weakMapMemoize,
ArgsMemoizeFunction extends UnknownMemoizer = typeof weakMapMemoize
ArgsMemoizeFunction extends UnknownMemoizer = typeof weakMapMemoize,
StateType = any
> {
/**
* Creates a memoized selector function.
Expand All @@ -47,9 +49,9 @@ export interface CreateSelectorFunction<
* @template OverrideMemoizeFunction - The type of the optional `memoize` function that could be passed into the options object to override the original `memoize` function that was initially passed into `createSelectorCreator`.
* @template OverrideArgsMemoizeFunction - The type of the optional `argsMemoize` function that could be passed into the options object to override the original `argsMemoize` function that was initially passed into `createSelectorCreator`.
*
* @see {@link https://github.com/reduxjs/reselect#createselectorinputselectors--inputselectors-resultfunc-createselectoroptions createSelector}
* @see {@link https://reselect.js.org/api/createselector `createSelector`}
*/
<InputSelectors extends SelectorArray, Result>(
<InputSelectors extends SelectorArray<StateType>, Result>(
...createSelectorArgs: [
...inputSelectors: InputSelectors,
combiner: Combiner<InputSelectors, Result>
Expand All @@ -73,10 +75,10 @@ export interface CreateSelectorFunction<
* @template OverrideMemoizeFunction - The type of the optional `memoize` function that could be passed into the options object to override the original `memoize` function that was initially passed into `createSelectorCreator`.
* @template OverrideArgsMemoizeFunction - The type of the optional `argsMemoize` function that could be passed into the options object to override the original `argsMemoize` function that was initially passed into `createSelectorCreator`.
*
* @see {@link https://github.com/reduxjs/reselect#createselectorinputselectors--inputselectors-resultfunc-createselectoroptions createSelector}
* @see {@link https://reselect.js.org/api/createselector `createSelector`}
*/
<
InputSelectors extends SelectorArray,
InputSelectors extends SelectorArray<StateType>,
Result,
OverrideMemoizeFunction extends UnknownMemoizer = MemoizeFunction,
OverrideArgsMemoizeFunction extends UnknownMemoizer = ArgsMemoizeFunction
Expand Down Expand Up @@ -114,10 +116,10 @@ export interface CreateSelectorFunction<
* @template OverrideMemoizeFunction - The type of the optional `memoize` function that could be passed into the options object to override the original `memoize` function that was initially passed into `createSelectorCreator`.
* @template OverrideArgsMemoizeFunction - The type of the optional `argsMemoize` function that could be passed into the options object to override the original `argsMemoize` function that was initially passed into `createSelectorCreator`.
*
* @see {@link https://github.com/reduxjs/reselect#createselectorinputselectors--inputselectors-resultfunc-createselectoroptions createSelector}
* @see {@link https://reselect.js.org/api/createselector `createSelector`}
*/
<
InputSelectors extends SelectorArray,
InputSelectors extends SelectorArray<StateType>,
Result,
OverrideMemoizeFunction extends UnknownMemoizer = MemoizeFunction,
OverrideArgsMemoizeFunction extends UnknownMemoizer = ArgsMemoizeFunction
Expand All @@ -139,6 +141,46 @@ export interface CreateSelectorFunction<
OverrideArgsMemoizeFunction
> &
InterruptRecursion

/**
* Creates a "pre-typed" version of {@linkcode createSelector createSelector}
* where the `state` type is predefined.
*
* This allows you to set the `state` type once, eliminating the need to
* specify it with every {@linkcode createSelector createSelector} call.
*
* @returns A pre-typed `createSelector` with the state type already defined.
*
* @example
* ```ts
* import { createSelector } from 'reselect'
*
* export interface RootState {
* todos: { id: number; completed: boolean }[]
* alerts: { id: number; read: boolean }[]
* }
*
* export const createAppSelector = createSelector.withTypes<RootState>()
*
* const selectTodoIds = createAppSelector(
* [
* // Type of `state` is set to `RootState`, no need to manually set the type
* state => state.todos
* ],
* todos => todos.map(({ id }) => id)
* )
* ```
* @template OverrideStateType - The specific type of state used by all selectors created with this selector creator.
*
* @see {@link https://reselect.js.org/api/createselector#defining-a-pre-typed-createselector `createSelector.withTypes`}
*
* @since 5.0.2
*/
withTypes<OverrideStateType extends StateType>(): CreateSelectorFunction<
MemoizeFunction,
ArgsMemoizeFunction,
OverrideStateType
>
}

/**
Expand Down Expand Up @@ -226,7 +268,8 @@ export function createSelectorCreator<MemoizeFunction extends UnknownMemoizer>(
): CreateSelectorFunction<MemoizeFunction>

/**
* Creates a selector creator function with the specified memoization function and options for customizing memoization behavior.
* Creates a selector creator function with the specified memoization
* function and options for customizing memoization behavior.
*
* @param memoizeOrOptions - Either A `memoize` function or an `options` object containing the `memoize` function.
* @param memoizeOptionsFromArgs - Optional configuration options for the memoization function. These options are then passed to the memoize function as the second argument onwards.
Expand Down Expand Up @@ -426,6 +469,11 @@ export function createSelectorCreator<
OverrideArgsMemoizeFunction
>
}

Object.assign(createSelector, {
withTypes: () => createSelector
})

return createSelector as CreateSelectorFunction<
MemoizeFunction,
ArgsMemoizeFunction
Expand Down
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export type Selector<
*
* @public
*/
export type SelectorArray = readonly Selector[]
export type SelectorArray<State = any> = readonly Selector<State>[]

/**
* Extracts an array of all return types from all input selectors.
Expand Down
23 changes: 23 additions & 0 deletions test/createSelector.withTypes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { createSelector } from 'reselect'
import type { RootState } from './testUtils'
import { localTest } from './testUtils'

describe(createSelector.withTypes, () => {
const createTypedSelector = createSelector.withTypes<RootState>()

localTest('should return createSelector', ({ state }) => {
expect(createTypedSelector.withTypes).to.be.a('function')

expect(createTypedSelector.withTypes().withTypes).to.be.a('function')

expect(createTypedSelector).toBe(createSelector)

const selectTodoIds = createTypedSelector([state => state.todos], todos =>
todos.map(({ id }) => id)
)

expect(selectTodoIds).toBeMemoizedSelector()

expect(selectTodoIds(state)).to.be.an('array').that.is.not.empty
})
})
10 changes: 10 additions & 0 deletions test/customMatchers.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { Assertion, AsymmetricMatchersContaining } from 'vitest'

interface CustomMatchers<R = unknown> {
toBeMemoizedSelector(): R
}

declare module 'vitest' {
interface Assertion<T = any> extends CustomMatchers<T> {}
interface AsymmetricMatchersContaining extends CustomMatchers {}
}
37 changes: 2 additions & 35 deletions test/reselect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { RootState } from './testUtils'
import {
addTodo,
deepClone,
isMemoizedSelector,
localTest,
setEnvToProd,
toggleCompleted
Expand Down Expand Up @@ -479,43 +480,9 @@ describe('argsMemoize and memoize', () => {
const hasUndefinedValues = (object: object) => {
return Object.values(object).some(e => e == null)
}
const isMemoizedSelector = (selector: object) => {
return (
typeof selector === 'function' &&
'resultFunc' in selector &&
'memoizedResultFunc' in selector &&
'lastResult' in selector &&
'dependencies' in selector &&
'recomputations' in selector &&
'dependencyRecomputations' in selector &&
'resetRecomputations' in selector &&
'resetDependencyRecomputations' in selector &&
'memoize' in selector &&
'argsMemoize' in selector &&
typeof selector.resultFunc === 'function' &&
typeof selector.memoizedResultFunc === 'function' &&
typeof selector.lastResult === 'function' &&
Array.isArray(selector.dependencies) &&
typeof selector.recomputations === 'function' &&
typeof selector.dependencyRecomputations === 'function' &&
typeof selector.resetRecomputations === 'function' &&
typeof selector.resetDependencyRecomputations === 'function' &&
typeof selector.memoize === 'function' &&
typeof selector.argsMemoize === 'function' &&
selector.dependencies.length >= 1 &&
selector.dependencies.every(
(dependency): dependency is Function =>
typeof dependency === 'function'
) &&
!selector.lastResult.length &&
!selector.recomputations.length &&
!selector.resetRecomputations.length &&
typeof selector.recomputations() === 'number'
)
}
const isArrayOfFunctions = (array: any[]) =>
array.every(e => typeof e === 'function')
expect(selectorDefault).toSatisfy(isMemoizedSelector)
expect(selectorDefault).toBeMemoizedSelector()
expect(selectorDefault)
.to.be.a('function')
.that.has.all.keys(allFields)
Expand Down
12 changes: 12 additions & 0 deletions test/setup.vitest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { isMemoizedSelector } from './testUtils'

expect.extend({
toBeMemoizedSelector(received) {
const { isNot } = this

return {
pass: isMemoizedSelector(received),
message: () => `${received} is${isNot ? '' : ' not'} a memoized selector`
}
}
})
35 changes: 35 additions & 0 deletions test/testUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,3 +523,38 @@ export const setEnvToProd = () => {
process.env.NODE_ENV = originalEnv
}
}

export const isMemoizedSelector = (selector: object) => {
return (
typeof selector === 'function' &&
'resultFunc' in selector &&
'memoizedResultFunc' in selector &&
'lastResult' in selector &&
'dependencies' in selector &&
'recomputations' in selector &&
'dependencyRecomputations' in selector &&
'resetRecomputations' in selector &&
'resetDependencyRecomputations' in selector &&
'memoize' in selector &&
'argsMemoize' in selector &&
typeof selector.resultFunc === 'function' &&
typeof selector.memoizedResultFunc === 'function' &&
typeof selector.lastResult === 'function' &&
Array.isArray(selector.dependencies) &&
typeof selector.recomputations === 'function' &&
typeof selector.dependencyRecomputations === 'function' &&
typeof selector.resetRecomputations === 'function' &&
typeof selector.resetDependencyRecomputations === 'function' &&
typeof selector.memoize === 'function' &&
typeof selector.argsMemoize === 'function' &&
selector.dependencies.length >= 1 &&
selector.dependencies.every(
(dependency): dependency is Function => typeof dependency === 'function'
) &&
!selector.lastResult.length &&
!selector.recomputations.length &&
!selector.resetRecomputations.length &&
typeof selector.recomputations() === 'number' &&
typeof selector.dependencyRecomputations() === 'number'
)
}
6 changes: 2 additions & 4 deletions test/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"target": "ESNext",
"jsx": "react",
"baseUrl": ".",
"rootDir": ".",
"rootDir": "../",
"skipLibCheck": true,
"noImplicitReturns": false,
"noUnusedLocals": false,
Expand All @@ -21,7 +21,5 @@
"@internal/*": ["../src/*"]
}
},
"include": [
"**/*.ts*",
]
"include": ["**/*.ts*"]
}
Loading

0 comments on commit 7f6715a

Please sign in to comment.