Skip to content

Commit c39cf88

Browse files
committed
Real initial commit
1 parent e85eff3 commit c39cf88

File tree

5 files changed

+326
-0
lines changed

5 files changed

+326
-0
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,5 @@ typings/
5959

6060
# next.js build output
6161
.next
62+
63+
package-lock.json

README.md

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
# Affection
2+
> Declarative side-effects
3+
4+
```sh
5+
npm install affection
6+
```
7+
8+
Affection is a library for describing side-effects as plain data and providing composition utilities.
9+
This project aims to improve on similar libraries by not using generators.
10+
11+
Generators make testing difficult in that:
12+
13+
- They can have internal state.
14+
- Each segment of the function cannot be tested in isolation.
15+
- Each segment of the function can only be reach after the segments before it.
16+
- Generators are awkward. Conversing with a generator with `next()` isn't as simple as function calling.
17+
- Composition of generators is harder than functions inherently.
18+
19+
So Affection is all about functions, with the goals:
20+
21+
- Improve testability through the use of pure functions.
22+
- Improve code reuse through la-a-carte composition of side-effects.
23+
24+
Let's see how we do.
25+
26+
## Examples
27+
28+
This first example does not use any composition.
29+
30+
```js
31+
import { run, call, callMethod } from 'affection'
32+
33+
const getJSON = url => [
34+
call(fetch, url),
35+
resp => [callMethod(resp, 'json')]
36+
]
37+
38+
async function main () {
39+
const payload = await run(getJSON('http://example.com'))
40+
console.log(payload)
41+
}
42+
```
43+
44+
This second example does the same as the first.
45+
Here we are using the composition utilities.
46+
47+
```js
48+
import { step, runStep, batchSteps, call, callMethod } from 'affection'
49+
50+
const fetchUrl = url => call(fetch, [url])
51+
const readJSON = resp => callMethod(resp, 'json')
52+
const getJSON = batchSteps([fetchUrl, readJSON].map(step))
53+
54+
async function main () {
55+
const payload = await runStep(getJSON, 'http://example.com')
56+
console.log(payload)
57+
}
58+
```
59+
60+
## Documentation
61+
The package contains the following:
62+
63+
##### Effects
64+
- [`call(func, args, context)`](#call)
65+
- [`callMethod(obj, method, args)`](#callmethod)
66+
- [`all(effects)`](#all)
67+
- [`race(effects)`](#race)
68+
- [`itself(value)`](#itself)
69+
70+
See [`defaultHandle`](#defaulthandle) for adding more.
71+
72+
##### Execution
73+
- [`run(plan[, handle])`](#run)
74+
75+
##### Composition
76+
- [`step(makeEffect)`](#step)
77+
- [`mapStep(step, transform)`](#mapstep)
78+
- [`batchStep(steps)`](#batchsteps)
79+
- [`runStep(step, input[, handle])`](#runstep)
80+
81+
### `call`
82+
> `call(func: function, args: Array<any>, context: any): Effect`
83+
84+
Describes a function call of `func.apply(context, args)`.
85+
86+
### `callMethod`
87+
> `callMethod(obj: any, method: String, args: Array<any>): Effect`
88+
89+
Describes a method call of `obj[method].apply(obj, args)`
90+
91+
### `all`
92+
> `all(effects: Array<Effect>): Effect`
93+
94+
Describes combining effects. Like `Promise.all`.
95+
96+
### `race`
97+
> `race(effects: Array<Effect>): Effect`
98+
99+
Describes racing effects. Like `Promise.race`.
100+
101+
### `itself`
102+
> `itself(value: any): Effect`
103+
104+
Describes a value. This is an identity function for Effects.
105+
106+
### `defaultHandle`
107+
> `defaultHandle(effect: Effect, handle: function): any`
108+
109+
Performs the action described by a particular effect.
110+
`defaultHandle` provides the handling for the effects included in Affection.
111+
To add more, create a new handle that wraps `defaultHandle` and pass that to `run`.
112+
113+
For example, say we want to add a timeout effect:
114+
115+
```js
116+
import { defaultHandle } from 'affection'
117+
118+
export function timeout (duration) {
119+
return { type: 'timeout', duration }
120+
}
121+
122+
export function myHandle (effect, handle) {
123+
if (effect.type === 'timeout') {
124+
return new Promise(resolve => setTimeout(resolve, effect.duration))
125+
}
126+
return defaultHandle(effect, handle)
127+
}
128+
129+
// Later...
130+
131+
async function main () {
132+
await run([timeout(1000)], myHandler)
133+
// Will have waited a second
134+
}
135+
```
136+
137+
### `run`
138+
> `run(plan: [Effect, function?], handle: function = defaultHandle): any`
139+
140+
Executes a plan.
141+
A plan is an array where the first element is an Effect to be handled using `handle` and the second element is a function to call with the result of the Effect.
142+
If the function is not provided, execution terminates and the result is returned.
143+
144+
### `step`
145+
> `step(makeEffect: any -> Effect): Step`
146+
147+
Creates a step.
148+
A step is a means of encapsulating an effect without needing a plan (as described by `run`).
149+
150+
This is hard to understand without an understanding of how `run` works.
151+
The `run` function is recursively executing plans until there is nothing more to do.
152+
A step is a way of saying, "Execute this effect; we might be done, might not."
153+
There could be 5 more effects to run or it's the end result; the step doesn't need to know.
154+
155+
This is for code reuse: effects should be decoupled from their consumers.
156+
157+
### `mapStep`
158+
> `mapStep(step: Step, transform: function): Step`
159+
160+
Creates a new step which will return the result of `transform` called with the input to the `step` `makeEffect` and the result of the Effect.
161+
162+
This is good for passing along context without mucking up simple steps.
163+
For example, we are building a dictionary of the most used word for each country.
164+
We want to retain the country we are querying about in the result.
165+
166+
```js
167+
const getMostUsedWordInCountry = country => call(MyAPI, country)
168+
const countryWordStep = step(getMostUsedWordInCountry)
169+
const getCountryWord = mapStep(countryWordStep, (result, country) => ({ country, word: result }))
170+
171+
runStep(getCountryWord, 'Canada').then(result => {
172+
console.log(result)
173+
// => { country: 'Canada', word: 'Sorry' }
174+
})
175+
```
176+
177+
### `batchSteps`
178+
> `batchSteps(steps: Array<Step>): Step`
179+
180+
Creates a new step which will call each step passing the result of first step to the next and so on.
181+
182+
### `runStep`
183+
> `runStep(step: Step, input: any, handle: function = defaultHandle): any`
184+
185+
Executes a `step` with a given `input`.
186+
Uses `run` so `handle` works in the same way.

index.js

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
function call (func, args, context) {
2+
return { type: 'call', func, args, context }
3+
}
4+
5+
function callMethod (obj, method, args) {
6+
return { type: 'callMethod', obj, method, args }
7+
}
8+
9+
function all (effects) {
10+
return { type: 'all', effects }
11+
}
12+
13+
function race (effects) {
14+
return { type: 'race', effects }
15+
}
16+
17+
function itself (value) {
18+
return { type: 'itself', value }
19+
}
20+
21+
function defaultHandle (effect, handle) {
22+
switch (effect.type) {
23+
case 'call':
24+
return effect.func.apply(effect.context, effect.args)
25+
case 'callMethod':
26+
return effect.obj[effect.method].apply(effect.obj, effect.args)
27+
case 'all':
28+
return Promise.all(effect.effects.map(handle))
29+
case 'race':
30+
return Promise.race(effect.effects.map(handle))
31+
case 'itself':
32+
return effect.value
33+
}
34+
}
35+
36+
function andThen (value, callback) {
37+
return value && typeof value.then === 'function'
38+
? value.then(callback)
39+
: callback(value)
40+
}
41+
42+
function run (plan, handle = defaultHandle) {
43+
return andThen(handle(plan[0], handle), function (nextValue) {
44+
return plan[1] ? run(plan[1](nextValue), handle) : nextValue
45+
})
46+
}
47+
48+
function step (makeEffect) {
49+
return function (next) {
50+
return function (input) {
51+
return [makeEffect(input), next]
52+
}
53+
}
54+
}
55+
56+
function mapStep (step, transform) {
57+
return function (next) {
58+
return function (input) {
59+
return step(function (output) {
60+
return next(transform(output, input))
61+
})(input)
62+
}
63+
}
64+
}
65+
66+
function batchSteps (steps) {
67+
return function (next) {
68+
return steps.concat(next).reduceRight(function (lastStep, previousStep) {
69+
return previousStep(lastStep)
70+
})
71+
}
72+
}
73+
74+
function runStep (step, input, handle) {
75+
return run(step()(input), handle)
76+
}
77+
78+
exports.call = call
79+
exports.callMethod = callMethod
80+
exports.all = all
81+
exports.race = race
82+
exports.itself = itself
83+
exports.defaultHandle = defaultHandle
84+
exports.run = run
85+
86+
exports.step = step
87+
exports.mapStep = mapStep
88+
exports.batchSteps = batchSteps
89+
exports.runStep = runStep

package.json

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"name": "affection",
3+
"description": "Declarative side-effects",
4+
"version": "0.0.1",
5+
"author": "Chris Andrejewski <[email protected]> (http://chrisandrejewski.com)",
6+
"bugs": {
7+
"url": "https://github.com/andrejewski/affection/issues"
8+
},
9+
"devDependencies": {
10+
"ava": "^0.25.0",
11+
"fixpack": "^2.3.1",
12+
"prettier": "^1.13.4",
13+
"standard": "^11.0.0"
14+
},
15+
"homepage": "https://github.com/andrejewski/affection#readme",
16+
"keywords": [
17+
"declarative",
18+
"effect",
19+
"side"
20+
],
21+
"license": "ISC",
22+
"main": "index.js",
23+
"repository": {
24+
"type": "git",
25+
"url": "git+https://github.com/andrejewski/affection.git"
26+
},
27+
"scripts": {
28+
"lint": "prettier {src,test}/**/*.js --write && fixpack && standard --fix",
29+
"test": "npm run lint && ava"
30+
}
31+
}

test/index.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import test from 'ava'
2+
import { run, itself, callMethod } from '../'
3+
4+
test('should return synchronous effects synchronously', t => {
5+
t.is(run([itself(4)]), 4)
6+
7+
t.is(
8+
run([
9+
callMethod(Math, 'min', [3, 4]),
10+
y => [callMethod(Math, 'max', [y, 2])]
11+
]),
12+
3
13+
)
14+
})
15+
16+
test('should return async effects asynchronously', async t => {
17+
t.is(await run([callMethod(Promise, 'resolve', [1])]), 1)
18+
})

0 commit comments

Comments
 (0)