Skip to content

Commit 7a1022a

Browse files
committed
Add operation chains
1 parent e899fcb commit 7a1022a

File tree

5 files changed

+272
-0
lines changed

5 files changed

+272
-0
lines changed

chain/README.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Chain
2+
3+
There are some use-cases for Promise for which there is not a 1:1 analogue in
4+
Effection. One of these is the "promise chaining" behavior of the `Promise`
5+
constructor itself using `then()`, `catch()`, and `finally()`.
6+
7+
The chain package accomplishes this in a very similar way:
8+
9+
```ts
10+
import { Chain } from "@effectionx/chain";
11+
12+
await main(function* () {
13+
let chain = new Chain<number>((resolve) => {
14+
resolve(10);
15+
});
16+
17+
let result = yield* chain.then(function* (value) {
18+
return value * 2;
19+
});
20+
21+
console.log(result); //=> 20;
22+
});
23+
```
24+
25+
Another is to share a promise in multiple places. For example this async data
26+
call is used and re-used:
27+
28+
```ts
29+
class Foo {
30+
data: Promise<number>;
31+
32+
constructor() {
33+
this.data = (async () => 5)();
34+
}
35+
36+
async getData() {
37+
return await this.data;
38+
}
39+
}
40+
41+
const foo = new Foo();
42+
console.log(await foo.getData());
43+
```
44+
45+
This can be accomplished with Chain like so:
46+
47+
```ts
48+
class Foo {
49+
data: Promise<number>;
50+
51+
constructor() {
52+
let operation = (function* () {
53+
return 5;
54+
})();
55+
this.data = Chain.from(operation);
56+
}
57+
58+
*getData() {
59+
return yield* this.data;
60+
}
61+
}
62+
63+
const foo = new Foo();
64+
console.log(yield * foo.getData());
65+
```
66+
67+
---
68+
69+
```ts
70+
import { Chain } from "@effectionx/chain";
71+
72+
await main(function* () {
73+
let chain = new Chain<number>((resolve) => {
74+
resolve(10);
75+
});
76+
77+
let result = yield* chain.then(function* (value) {
78+
return value * 2;
79+
});
80+
81+
console.log(result); //=> 20;
82+
});
83+
```

chain/chain.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { describe, it } from "@effectionx/bdd";
2+
import { expect } from "@std/expect";
3+
import type { Operation } from "effection";
4+
5+
import { Chain } from "./mod.ts";
6+
7+
describe("chain", () => {
8+
it("can continunue", function* () {
9+
let result = yield* new Chain<string>((resolve) => {
10+
resolve("Hello");
11+
}).then(function* (message) {
12+
return `${message} World!`;
13+
});
14+
expect(result).toEqual("Hello World!");
15+
});
16+
it("can catch", function* () {
17+
let result = yield* new Chain<string>((_, reject) => {
18+
reject(new Error("boom!"));
19+
}).catch(function* (e) {
20+
return (e as Error).message;
21+
});
22+
23+
expect(result).toEqual("boom!");
24+
});
25+
26+
it("can have a finally", function* () {
27+
let didExecuteFinally = false;
28+
expect.assertions(1);
29+
try {
30+
yield* new Chain<string>((_, reject) => {
31+
reject(new Error("boom!"));
32+
}).finally(function* () {
33+
didExecuteFinally = true;
34+
});
35+
throw new Error(`expected chain to reject`);
36+
} catch (_) {
37+
expect(didExecuteFinally).toEqual(true);
38+
}
39+
});
40+
41+
it("can chain off of an existing operation", function* () {
42+
function* twice(num: number): Operation<number> {
43+
return num * 2;
44+
}
45+
46+
let chain = Chain.from(twice(5)).then(function* (num) {
47+
return num * 2;
48+
});
49+
50+
expect(yield* chain).toEqual(20);
51+
52+
// make sure it works twice
53+
expect(yield* chain).toEqual(20);
54+
});
55+
});

chain/deno.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "@effectionx/chain",
3+
"version": "0.1.0",
4+
"exports": "./mod.ts",
5+
"license": "MIT",
6+
"imports": {
7+
"effection": "npm:effection@^3",
8+
"@std/expect": "jsr:@std/expect@^1",
9+
"@effectionx/bdd": "jsr:@effectionx/bdd@^0.3.0"
10+
}
11+
}

chain/mod.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import type { Operation, WithResolvers } from "effection";
2+
import { call, withResolvers } from "effection";
3+
4+
/**
5+
* Resolve a Chain
6+
*/
7+
export type Resolve<T> = WithResolvers<T>["resolve"];
8+
9+
/**
10+
* Reject a chain
11+
*/
12+
export type Reject = WithResolvers<unknown>["reject"];
13+
14+
/**
15+
* Represent the eventual completion of an [Operation] in a fashion
16+
* that mirrors the * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise Promise}
17+
* implementation
18+
*/
19+
export class Chain<T> implements From<T> {
20+
private resolvers = withResolvers<T>();
21+
22+
/**
23+
* Create a chain from any operation.
24+
*/
25+
static from = from;
26+
27+
constructor(compute: (resolve: Resolve<T>, reject: Reject) => void) {
28+
let { resolve, reject } = this.resolvers;
29+
compute(resolve, reject);
30+
}
31+
32+
then<B>(fn: (value: T) => Operation<B>): From<B> {
33+
let { operation } = this.resolvers;
34+
return from(operation).then(fn);
35+
}
36+
37+
catch<B>(fn: (error: unknown | Error) => Operation<B>): From<T | B> {
38+
let { operation } = this.resolvers;
39+
return from(operation).catch(fn);
40+
}
41+
42+
finally(fn: () => Operation<void>): From<T> {
43+
let { operation } = this.resolvers;
44+
return from(operation).finally(fn);
45+
}
46+
47+
[Symbol.iterator](): ReturnType<From<T>[typeof Symbol.iterator]> {
48+
return this.resolvers.operation[Symbol.iterator]();
49+
}
50+
}
51+
52+
export interface From<A> extends Operation<A> {
53+
/**
54+
* Create a new chain that will resolve to the current chain's value after
55+
* applying `fn`;
56+
*
57+
* @param `fn` - is applied to the source operation's result to create the chained operation's result
58+
*
59+
* @returns a new {Chain} representing this application
60+
*/
61+
then<B>(fn: (value: A) => Operation<B>): From<B>;
62+
63+
/**
64+
* Create a new chain that will resolve to the original chain's
65+
* value, or the result of `fn` in the event that the current chain
66+
* rejects. applying `fn`;
67+
*
68+
* @param `fn` - applied when the current chain rejects and becomes the result of chain
69+
*
70+
* @returns a new {Chain} representing the potentially caught rejection
71+
*/
72+
catch<B>(fn: (error: unknown | Error) => Operation<B>): From<A | B>;
73+
74+
/**
75+
* Create a new {Chain} that behaves exactly like the original chain, except that operation specified with
76+
* `fn` will run in all cases.
77+
*
78+
* @param `fn` - a function returning an operation that is always
79+
* evaluate just before the current chain yields its value.
80+
*/
81+
finally(fn: () => Operation<void>): From<A>;
82+
}
83+
84+
function from<T>(source: Operation<T>): From<T> {
85+
let resolvers: WithResolvers<T> | undefined = undefined;
86+
let chain: From<T> = {
87+
*[Symbol.iterator]() {
88+
if (!resolvers) {
89+
resolvers = withResolvers<T>();
90+
try {
91+
resolvers.resolve(yield* source);
92+
} catch (e) {
93+
resolvers.reject(e as Error);
94+
}
95+
}
96+
return yield* resolvers.operation;
97+
},
98+
then: (fn) =>
99+
from(call(function* () {
100+
return yield* fn(yield* chain);
101+
})),
102+
103+
catch: (fn) =>
104+
from(call(function* () {
105+
try {
106+
return yield* chain;
107+
} catch (e) {
108+
return yield* fn(e);
109+
}
110+
})),
111+
112+
finally: (fn) =>
113+
from(call(function* () {
114+
try {
115+
return yield* chain;
116+
} finally {
117+
yield* fn();
118+
}
119+
})),
120+
};
121+
return chain;
122+
}

deno.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
]
2828
},
2929
"workspace": [
30+
"./chain",
3031
"./context-api",
3132
"./deno-deploy",
3233
"./bdd",

0 commit comments

Comments
 (0)