Skip to content

Commit

Permalink
feat: expose compose function
Browse files Browse the repository at this point in the history
  • Loading branch information
mylesj committed May 3, 2022
1 parent 53bef5d commit 7116419
Show file tree
Hide file tree
Showing 8 changed files with 153 additions and 0 deletions.
75 changes: 75 additions & 0 deletions src/compose/compose.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { compose } from './compose'

type Any = number | string
type Arbitrary = {
[key in string]: Any | Any[] | Record<string, Any>
}

describe(compose.name, () => {
it('should return a thunk', () => {
expect(compose()).toEqual(expect.any(Function))
})

it('should return the original object when there are no tasks', async () => {
const input = {}
expect(await compose()(input)).toBe(input)
})

it('should pass all arguments through to the composed tasks', async () => {
const task = jest.fn()
const input = {}
await compose(task)(input, 1, 2, 3)
expect(task).toHaveBeenCalledWith(input, 1, 2, 3)
})

it('should handle basic composition of regular functions', async () => {
const reduce = compose<Arbitrary>(() => (draft) => {
draft.a = 1
})
expect(await reduce({})).toStrictEqual({
a: 1,
})
})

it('should handle composition of async functions', async () => {
const reduce = compose<Arbitrary>(async () => {
await delay(100)
return (draft) => {
draft.a = 1
}
})

const runner = reduce({})
jest.runAllTimers()
expect(await runner).toStrictEqual({
a: 1,
})
})

it('should always resolve new state in the order of composition', async () => {
const reduce = compose<number[]>(
async () => {
await delay(200)
return (draft) => {
draft.push(1)
}
},
async () => {
await delay(300)
return (draft) => {
draft.push(2)
}
},
async () => {
await delay(100)
return (draft) => {
draft.push(3)
}
}
)

const runner = reduce([])
jest.runAllTimers()
expect(await runner).toStrictEqual([1, 2, 3])
})
})
26 changes: 26 additions & 0 deletions src/compose/compose.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { produce } from 'immer'

import { AnyArray, ComposeTask } from '~/types'

import { isFunction } from '~/util'

export const compose =
<State, Args extends AnyArray = unknown[]>(
...tasks: ComposeTask<State, Args>[]
) =>
async (state: State, ...args: Args[]): Promise<State> => {
const recipes = await Promise.all(
tasks.map((task) => task(state, ...args))
)

let newState = state
for (const recipe of recipes) {
if (recipe === undefined || !isFunction(recipe)) {
continue
}

newState = await produce(newState, recipe)
}

return newState
}
1 change: 1 addition & 0 deletions src/compose/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { compose } from './compose'
1 change: 1 addition & 0 deletions src/module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { compose } from './compose'
18 changes: 18 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Draft } from 'immer'

export { Draft } from 'immer'

export type AnyObject = Record<string, unknown>
export type AnyArray = unknown[]

export type ThunkRecipe<State> = (draft: Draft<State>) => Draft<State> | void
export type ComposeTask<State, Args extends AnyArray = unknown[]> = (
state: State,
...args: Args[]
) => Promise<ThunkRecipe<State>> | ThunkRecipe<State> | void

export interface Compose {
<State, Args extends AnyArray = unknown[]>(
...tasks: ComposeTask<State, Args>[]
): Promise<(state: State, ...args: Args) => Promise<State>>
}
1 change: 1 addition & 0 deletions src/util/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './is'
30 changes: 30 additions & 0 deletions src/util/is.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { isFunction } from './is'

const arrowFn = () => {
/* noop */
}
const asyncArrowFn = async () => {
/* noop */
}
function expressionFn() {
/* noop */
}
async function asyncExpressionFn() {
/* noop */
}

describe(isFunction.name, () => {
it.each`
input | expected
${undefined} | ${false}
${null} | ${false}
${1} | ${false}
${{}} | ${false}
${arrowFn} | ${true}
${expressionFn} | ${true}
${asyncArrowFn} | ${true}
${asyncExpressionFn} | ${true}
`('should return $expected for $input', ({ input, expected }) => {
expect(isFunction(input)).toBe(expected)
})
})
1 change: 1 addition & 0 deletions src/util/is.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const isFunction = (fn: unknown): boolean => typeof fn === 'function'

0 comments on commit 7116419

Please sign in to comment.