pure-effect 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(npx mocha *)",
5
+ "Bash(npm test *)",
6
+ "Bash(git *)",
7
+ "Bash(node *)",
8
+ "Bash(npx tsc *)"
9
+ ]
10
+ }
11
+ }
Binary file
package/.prettierrc ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "tabWidth": 4,
3
+ "useTabs": false,
4
+ "singleQuote": true,
5
+ "semi": true,
6
+ "trailingComma": "none",
7
+ "jsxSingleQuote": true,
8
+ "printWidth": 120,
9
+ "arrowParens": "always",
10
+ "endOfLine": "lf"
11
+ }
package/CLAUDE.md ADDED
@@ -0,0 +1,63 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Commands
6
+
7
+ ```bash
8
+ npm test # run all tests
9
+ npx mocha test/all.js --grep "pattern" # run a single test by name
10
+ ```
11
+
12
+ No build or lint step — the library ships as plain ES modules with no transpilation.
13
+
14
+ ## Architecture
15
+
16
+ **pure-effect** is a zero-dependency effect system for JavaScript implementing the "Functional Core, Imperative Shell" pattern. Business logic returns plain data structures instead of executing side effects, enabling testing without mocks.
17
+
18
+ ### Core abstractions (all in `index.js`)
19
+
20
+ | Export | Shape | Purpose |
21
+ | ------------------------------ | ------------------------------------------ | -------------------------------------------------------------------------------------- |
22
+ | `Success(value)` | `{ type: 'Success', value }` | Wraps a successful result |
23
+ | `Failure(error, initialInput)` | `{ type: 'Failure', error, initialInput }` | Short-circuits the pipeline |
24
+ | `Command(cmdFn, nextFn, meta)` | `{ type: 'Command', cmd, next, meta }` | Defers a side effect for the interpreter |
25
+ | `Ask(nextFn)` | `{ type: 'Ask', next }` | Reads the `context` passed to `runEffect`; passes it to `nextFn` |
26
+ | `effectPipe(...fns)` | — | Composes functions into a sequential pipeline via `chain` |
27
+ | `runEffect(effect, context)` | async | Interpreter: traverses the effect tree, executes Commands; resolves `Ask` with context |
28
+ | `configureEffect(options)` | — | Injects telemetry hooks (`onStep`, `onRun`, `onBeforeCommand`) |
29
+
30
+ ### Data flow
31
+
32
+ ```
33
+ effectPipe(f1, f2, f3)(input)
34
+ → returns tree of Success / Failure / Command / Ask values
35
+ → f1 runs eagerly here
36
+
37
+ runEffect(tree, context)
38
+ → executes Commands async, passes results into next(result), repeats
39
+ → resolves Ask by calling next(context), continues
40
+ → resolves to final Success or Failure
41
+ ```
42
+
43
+ The `chain` combinator (internal) drives composition: `Success` passes its value to the next function, `Failure` short-circuits, `Command` defers execution, `Ask` wraps its continuation so the chain propagates through it. `runEffect` loops through the tree with a `while` loop rather than recursion.
44
+
45
+ `configureEffect` hooks:
46
+
47
+ - `onStep` — fires on every Command execution; wraps the `cmd` call (use for per-command tracing)
48
+ - `onRun` — fires once per `runEffect` call; wraps the whole workflow (use for top-level spans); receives `context.flowName` as the third argument
49
+ - `onBeforeCommand` — intercepts each Command before execution; receives the Command and the `context` passed to `runEffect`
50
+
51
+ ### TypeScript
52
+
53
+ Full generic type declarations are in `index.d.ts` and referenced via the `types` field in `package.json`. Type-level tests live in `test/types.test-d.ts` and run via `tsd` as part of `npm test`.
54
+
55
+ ### Observability
56
+
57
+ `opentelemetry-example.js` shows how to wire OpenTelemetry spans into `configureEffect`'s hooks — it is reference code, not part of the library.
58
+
59
+ ### Tests
60
+
61
+ `test/all.js` contains all runtime tests and uses a user-registration domain as the running example. Tests assert on the _returned data structures_ (Commands, Failures) rather than on side effects, which is the core usage pattern to preserve.
62
+
63
+ `test/types.test-d.ts` contains type-level tests using `tsd`, verifying that generic type parameters flow correctly through `effectPipe` and `runEffect`.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  It implements the "Functional Core, Imperative Shell" pattern, allowing you to decouple your business logic from external side effects like database calls or API requests. Instead of executing side effects immediately, your functions return Commands which are executed later by an interpreter.
6
6
 
7
- **Pure Effect** comes with JSDoc type annotations, so it can be used with TypeScript as well.
7
+ **Pure Effect** ships with JSDoc type annotations for JavaScript users and a bundled declaration file with full generic types for TypeScript users.
8
8
 
9
9
  ## Installation
10
10
 
@@ -95,6 +95,49 @@ assert.equal(step2.cmd.name, 'cmdSaveUser');
95
95
  // ✅ We verified the *intent* of the code without touching a real DB.
96
96
  ```
97
97
 
98
+ ## Passing Runtime Context
99
+
100
+ Some values come from the framework layer such as the authenticated tenant, a request trace ID, environment config rather than from the data being processed. `Ask` lets a pipeline step read the `context` object passed to `runEffect` without touching the function signatures around it.
101
+
102
+ In the example below, `ctx.tenant` is identified from the subdomain or JWT by the router. The domain layer never needs it as a parameter; it just asks for it when needed:
103
+
104
+ ```js
105
+ import { Success, Failure, Command, Ask, effectPipe, runEffect } from 'pure-effect';
106
+
107
+ const findProduct = (productId) =>
108
+ Ask((ctx) =>
109
+ Command(
110
+ () => db[ctx.tenant].findProduct(productId),
111
+ (product) => (product ? Success(product) : Failure('Product not found.'))
112
+ )
113
+ );
114
+
115
+ const reserveStock = (product) =>
116
+ Ask((ctx) =>
117
+ Command(
118
+ () => db[ctx.tenant].reserveStock(product.id),
119
+ (reserved) => Success({ product, reserved })
120
+ )
121
+ );
122
+
123
+ const checkoutFlow = (productId) =>
124
+ effectPipe(
125
+ () => findProduct(productId),
126
+ ({ product }) => reserveStock(product)
127
+ )(productId);
128
+ ```
129
+
130
+ The router identifies the tenant and passes it as context. `checkoutFlow` never needs a tenant parameter:
131
+
132
+ ```js
133
+ app.post('/checkout', async (req, res) => {
134
+ const result = await runEffect(checkoutFlow(req.body.productId), {
135
+ tenant: req.subdomains[0] // e.g. 'acme' from acme.myapp.com
136
+ });
137
+ res.json(result);
138
+ });
139
+ ```
140
+
98
141
  ## API Reference
99
142
 
100
143
  ### `Success(value)`
@@ -109,9 +152,25 @@ Returns an object `{ type: 'Failure', error, initialInput }`. Represents a faile
109
152
 
110
153
  Returns an object `{ type: 'Command', cmd, next, meta }`.
111
154
 
112
- - `cmdFn`: A function (sync or async) that performs the side effect.
113
- - `nextFn`: A function that receives the result of `cmdFn` and returns the next Effect (Success, Failure, or another Command).
114
- - `meta`: Optional metadata.
155
+ - `cmdFn`: A function (sync or async) that performs the side effect.
156
+ - `nextFn`: A function that receives the result of `cmdFn` and returns the next Effect (Success, Failure, or another Command).
157
+ - `meta`: Optional metadata.
158
+
159
+ ### `Ask(nextFn)`
160
+
161
+ Returns an object `{ type: 'Ask', next }`. Reads the `context` passed to `runEffect` and passes it to `nextFn`, which returns the next Effect. Works at any point in the pipeline, before or after `Command`s.
162
+
163
+ ```js
164
+ const findProduct = (productId) =>
165
+ Ask((ctx) =>
166
+ Command(
167
+ () => db[ctx.tenant].findProduct(productId),
168
+ (product) => (product ? Success(product) : Failure('Product not found.'))
169
+ )
170
+ );
171
+ ```
172
+
173
+ See [Passing Runtime Context](#passing-runtime-context) for a full example.
115
174
 
116
175
  ### `effectPipe(...functions)`
117
176
 
@@ -119,7 +178,12 @@ A combinator that runs functions in sequence. It automatically handles unpacking
119
178
 
120
179
  ### `runEffect(effect, context = {})`
121
180
 
122
- The interpreter. It takes an `effect` object, executes any nested Commands recursively using `async/await`, and returns the final `Success` or `Failure`. The optional `context` object is _only_ passed to the command interceptor configured via the `onBeforeCommand` option in `configureEffect` (see below). Additionally, `context.flowName` may be used for naming workflows in telemetry.
181
+ The interpreter. It takes an `effect` object, executes any nested Commands using `async/await`, and returns the final `Success` or `Failure`. The optional `context` object is available to:
182
+
183
+ - `Ask` continuations — use `Ask` to read context inside pipeline steps
184
+ - The `onBeforeCommand` interceptor (see `configureEffect` below)
185
+
186
+ `context.flowName` may be used for naming workflows in telemetry.
123
187
 
124
188
  ---
125
189
 
@@ -129,21 +193,19 @@ A configuration function that injects observability, tracing, or logging interce
129
193
 
130
194
  `configureEffect` also accepts `onBeforeCommand`, which can be used to intercept each `Command` and the context passed to `runEffect` before execution.
131
195
 
132
- - `onRun (effect, pipeline, flowName)`
133
- Fires once per `runEffect` call. It wraps the entire workflow execution.
134
-
135
- - `effect`: The initial state of the effect tree (useful for extracting `initialInput`).
136
- - `pipeline`: The actual interpreter. You must `await pipeline()` inside this callback to run the logic.
137
- - `flowName`: The optional name of the workflow passed to `runEffect`.
138
-
139
- - `onStep (name, type, op)`
140
- Fires every time a `Command` is executed.
141
-
142
- - `name`: The name of the command function (e.g., `cmdFindUser`).
143
- - `type`: Effect type.
144
- - `op`: The actual side-effect function. You must `await op()` inside this callback and return its result.
145
-
146
- - `onBeforeCommand (command, context)`
147
- Fires before a `Command` is executed. Ideal for inspecting metadata and context. If you throw, the pipeline stops immediately.
148
- - `command`: The `Command` object.
149
- - `context`: The context object passed to `runEffect`, if any.
196
+ - `onRun (effect, pipeline, flowName)`
197
+ Fires once per `runEffect` call. It wraps the entire workflow execution.
198
+ - `effect`: The initial state of the effect tree (useful for extracting `initialInput`).
199
+ - `pipeline`: The actual interpreter. You must `await pipeline()` inside this callback to run the logic.
200
+ - `flowName`: The optional name of the workflow passed to `runEffect`.
201
+
202
+ - `onStep (name, type, op)`
203
+ Fires every time a `Command` is executed.
204
+ - `name`: The name of the command function (e.g., `cmdFindUser`).
205
+ - `type`: Effect type.
206
+ - `op`: The actual side-effect function. You must `await op()` inside this callback and return its result.
207
+
208
+ - `onBeforeCommand (command, context)`
209
+ Fires before a `Command` is executed. Ideal for inspecting metadata and context. If you throw, the pipeline stops immediately.
210
+ - `command`: The `Command` object.
211
+ - `context`: The context object passed to `runEffect`, if any.
package/index.d.ts ADDED
@@ -0,0 +1,120 @@
1
+ export type SuccessState<T> = {
2
+ type: 'Success';
3
+ value: T;
4
+ initialInput?: unknown;
5
+ };
6
+
7
+ export type FailureState<E = unknown> = {
8
+ type: 'Failure';
9
+ error: E;
10
+ initialInput?: unknown;
11
+ };
12
+
13
+ export type CommandState<R, T, E = unknown> = {
14
+ type: 'Command';
15
+ cmd: () => Promise<R> | R;
16
+ next: (result: R) => Effect<T, E>;
17
+ meta?: unknown;
18
+ initialInput?: unknown;
19
+ };
20
+
21
+ export type AskState<T, E = unknown> = {
22
+ type: 'Ask';
23
+ next: (context: unknown) => Effect<T, E>;
24
+ initialInput?: unknown;
25
+ };
26
+
27
+ export type Effect<T, E = unknown> = SuccessState<T> | FailureState<E> | CommandState<any, T, E> | AskState<T, E>;
28
+
29
+ export declare function Success<T>(value: T): SuccessState<T>;
30
+
31
+ export declare function Failure<E = unknown>(error: E, initialInput?: unknown): FailureState<E>;
32
+
33
+ export declare function Command<R, T, E = unknown>(
34
+ cmd: () => Promise<R> | R,
35
+ next: (result: R) => Effect<T, E>,
36
+ meta?: unknown
37
+ ): CommandState<R, T, E>;
38
+
39
+ export declare function Ask<T, E = unknown>(next: (context: unknown) => Effect<T, E>): AskState<T, E>;
40
+
41
+ export declare function effectPipe<A, B, E = unknown>(f1: (a: A) => Effect<B, E>): (start: A) => Effect<B, E>;
42
+
43
+ export declare function effectPipe<A, B, C, E = unknown>(
44
+ f1: (a: A) => Effect<B, E>,
45
+ f2: (b: B) => Effect<C, E>
46
+ ): (start: A) => Effect<C, E>;
47
+
48
+ export declare function effectPipe<A, B, C, D, E = unknown>(
49
+ f1: (a: A) => Effect<B, E>,
50
+ f2: (b: B) => Effect<C, E>,
51
+ f3: (c: C) => Effect<D, E>
52
+ ): (start: A) => Effect<D, E>;
53
+
54
+ export declare function effectPipe<A, B, C, D, F, E = unknown>(
55
+ f1: (a: A) => Effect<B, E>,
56
+ f2: (b: B) => Effect<C, E>,
57
+ f3: (c: C) => Effect<D, E>,
58
+ f4: (d: D) => Effect<F, E>
59
+ ): (start: A) => Effect<F, E>;
60
+
61
+ export declare function effectPipe<A, B, C, D, F, G, E = unknown>(
62
+ f1: (a: A) => Effect<B, E>,
63
+ f2: (b: B) => Effect<C, E>,
64
+ f3: (c: C) => Effect<D, E>,
65
+ f4: (d: D) => Effect<F, E>,
66
+ f5: (f: F) => Effect<G, E>
67
+ ): (start: A) => Effect<G, E>;
68
+
69
+ export declare function effectPipe<A, B, C, D, F, G, H, E = unknown>(
70
+ f1: (a: A) => Effect<B, E>,
71
+ f2: (b: B) => Effect<C, E>,
72
+ f3: (c: C) => Effect<D, E>,
73
+ f4: (d: D) => Effect<F, E>,
74
+ f5: (f: F) => Effect<G, E>,
75
+ f6: (g: G) => Effect<H, E>
76
+ ): (start: A) => Effect<H, E>;
77
+
78
+ export declare function effectPipe<A, B, C, D, F, G, H, I, E = unknown>(
79
+ f1: (a: A) => Effect<B, E>,
80
+ f2: (b: B) => Effect<C, E>,
81
+ f3: (c: C) => Effect<D, E>,
82
+ f4: (d: D) => Effect<F, E>,
83
+ f5: (f: F) => Effect<G, E>,
84
+ f6: (g: G) => Effect<H, E>,
85
+ f7: (h: H) => Effect<I, E>
86
+ ): (start: A) => Effect<I, E>;
87
+
88
+ export declare function effectPipe<A, B, C, D, F, G, H, I, J, E = unknown>(
89
+ f1: (a: A) => Effect<B, E>,
90
+ f2: (b: B) => Effect<C, E>,
91
+ f3: (c: C) => Effect<D, E>,
92
+ f4: (d: D) => Effect<F, E>,
93
+ f5: (f: F) => Effect<G, E>,
94
+ f6: (g: G) => Effect<H, E>,
95
+ f7: (h: H) => Effect<I, E>,
96
+ f8: (i: I) => Effect<J, E>
97
+ ): (start: A) => Effect<J, E>;
98
+
99
+ export declare function runEffect<T, E = unknown>(
100
+ effect: Effect<T, E>,
101
+ context?: unknown
102
+ ): Promise<SuccessState<T> | FailureState<E>>;
103
+
104
+ export type StepRunner = (name: string, type: string, op: () => Promise<unknown>) => Promise<unknown>;
105
+
106
+ export type RunWrapper = (
107
+ effect: Effect<unknown>,
108
+ op: () => Promise<SuccessState<unknown> | FailureState<unknown>>,
109
+ flowName?: string
110
+ ) => Promise<SuccessState<unknown> | FailureState<unknown>>;
111
+
112
+ export type CommandInterceptor = (command: CommandState<unknown, unknown>, context?: any) => Promise<void>;
113
+
114
+ export interface EffectConfiguration {
115
+ onStep?: StepRunner;
116
+ onRun?: RunWrapper;
117
+ onBeforeCommand?: CommandInterceptor;
118
+ }
119
+
120
+ export declare function configureEffect(options: EffectConfiguration): void;
package/index.js CHANGED
@@ -11,10 +11,17 @@
11
11
  * initialInput?: any
12
12
  * }} CommandState
13
13
  */
14
+ /**
15
+ * @typedef {{
16
+ * type: 'Ask',
17
+ * next: (context: any) => Effect,
18
+ * initialInput?: any
19
+ * }} AskState
20
+ */
14
21
 
15
22
  /**
16
23
  * The Union type for all possible states
17
- * @typedef {SuccessState | FailureState | CommandState} Effect
24
+ * @typedef {SuccessState | FailureState | CommandState | AskState} Effect
18
25
  */
19
26
 
20
27
  /**
@@ -30,7 +37,11 @@ const Success = (value) => ({ type: 'Success', value });
30
37
  * @param {any} [initialInput] - initial input passed to the flow (optional)
31
38
  * @returns {FailureState}
32
39
  */
33
- const Failure = (error, initialInput) => ({ type: 'Failure', error, initialInput });
40
+ const Failure = (error, initialInput) => ({
41
+ type: 'Failure',
42
+ error,
43
+ initialInput
44
+ });
34
45
 
35
46
  /**
36
47
  * Represents a side effect to be executed later
@@ -41,9 +52,16 @@ const Failure = (error, initialInput) => ({ type: 'Failure', error, initialInput
41
52
  */
42
53
  const Command = (cmd, next, meta) => ({ type: 'Command', cmd, next, meta });
43
54
 
55
+ /**
56
+ * Reads the context object from the current `runEffect` call.
57
+ * @param {(context: any) => Effect} next - Receives the context and returns the next Effect
58
+ * @returns {AskState}
59
+ */
60
+ const Ask = (next) => ({ type: 'Ask', next });
61
+
44
62
  /**
45
63
  * Connects an Effect to the next function in the pipeline.
46
- * Handles the branching logic for Success, Failure, and Command.
64
+ * Handles the branching logic for Success, Failure, Command, and Ask.
47
65
  *
48
66
  * @param {Effect} effect - The current Effect object
49
67
  * @param {(value: any) => Effect} fn - The next function to run if the current effect is a Success
@@ -55,9 +73,14 @@ const chain = (effect, fn) => {
55
73
  return fn(effect.value);
56
74
  case 'Failure':
57
75
  return effect;
58
- case 'Command':
59
- const next = (/** @type {Effect} */ result) => chain(effect.next(result), fn);
76
+ case 'Command': {
77
+ const next = (/** @type {any} */ result) => chain(effect.next(result), fn);
60
78
  return Command(effect.cmd, next, effect.meta);
79
+ }
80
+ case 'Ask': {
81
+ const next = (/** @type {any} */ ctx) => chain(effect.next(ctx), fn);
82
+ return Ask(next);
83
+ }
61
84
  }
62
85
  };
63
86
 
@@ -65,12 +88,12 @@ const chain = (effect, fn) => {
65
88
  * Composes a list of functions into a single Effect pipeline.
66
89
  * Each function receives the output of the previous one.
67
90
  *
68
- * @param {...(input: any) => Effect} fns - Functions that return Success, Failure, or Command.
91
+ * @param {...(input: any) => Effect} fns - Functions that return Success, Failure, Command, or Ask.
69
92
  * @returns {(start: any) => Effect} A function that accepts an initial input and returns the final Effect tree.
70
93
  */
71
94
  const effectPipe = (...fns) => {
72
95
  return (start) => {
73
- const effect = fns.reduce(chain, Success(start));
96
+ const effect = fns.reduce(chain, /** @type {Effect} */ (Success(start)));
74
97
  effect.initialInput = start;
75
98
  return effect;
76
99
  };
@@ -114,23 +137,29 @@ const runEffect =
114
137
  /**
115
138
  * The Interpreter
116
139
  * Iterates through the Effect tree, executing Commands and handling async flow.
140
+ * Ask effects are resolved synchronously with the context object.
117
141
  *
118
142
  * @param {Effect} effect - The Effect tree returned by a pipeline
119
- * @param {any} [context] - Optional context object passed to the Command Interceptor
143
+ * @param {any} [context] - Optional context object. Passed to Ask continuations and the Command Interceptor.
120
144
  * @returns {Promise<SuccessState | FailureState>}
121
145
  */
122
146
  async function runEffect(effect, context = {}) {
123
147
  return runWrapper(
124
148
  effect,
125
149
  async () => {
126
- while (effect.type === 'Command') {
150
+ while (effect.type === 'Command' || effect.type === 'Ask') {
151
+ if (effect.type === 'Ask') {
152
+ effect = effect.next(context);
153
+ continue;
154
+ }
127
155
  const cmdName = effect.cmd.name || 'anonymous';
156
+ const initialInput = effect.initialInput;
128
157
  try {
129
158
  await commandInterceptor(effect, context);
130
159
  const result = await stepRunner(cmdName, 'Command', effect.cmd);
131
160
  effect = effect.next(result);
132
161
  } catch (e) {
133
- return Failure(e, effect.initialInput);
162
+ return Failure(e, initialInput);
134
163
  }
135
164
  }
136
165
 
@@ -140,4 +169,4 @@ const runEffect =
140
169
  );
141
170
  };
142
171
 
143
- export { Success, Failure, Command, effectPipe, runEffect, configureEffect };
172
+ export { Success, Failure, Command, Ask, effectPipe, runEffect, configureEffect };
@@ -9,14 +9,14 @@ import { configureEffect } from './index.js';
9
9
  /** @import { RunWrapper, StepRunner } from "./index.js" */
10
10
 
11
11
  const traceExporter = new OTLPTraceExporter({
12
- url: 'http://localhost:4318/v1/traces',
12
+ url: 'http://localhost:4318/v1/traces'
13
13
  });
14
14
 
15
15
  const sdk = new NodeSDK({
16
16
  serviceName: 'pure-effect-test',
17
17
  traceExporter,
18
18
  spanProcessor: new SimpleSpanProcessor(traceExporter),
19
- instrumentations: [],
19
+ instrumentations: []
20
20
  });
21
21
 
22
22
  sdk.start();
@@ -43,7 +43,7 @@ export function enableTelemetry() {
43
43
  if (result.type === 'Failure') {
44
44
  rootSpan.setStatus({
45
45
  code: SpanStatusCode.ERROR,
46
- message: String(result.error),
46
+ message: String(result.error)
47
47
  });
48
48
  } else {
49
49
  rootSpan.setStatus({ code: SpanStatusCode.OK });
@@ -79,13 +79,13 @@ export function enableTelemetry() {
79
79
  span.recordException(err);
80
80
  span.setStatus({
81
81
  code: SpanStatusCode.ERROR,
82
- message: err.message,
82
+ message: err.message
83
83
  });
84
84
  throw err;
85
85
  } finally {
86
86
  span.end();
87
87
  }
88
88
  });
89
- },
89
+ }
90
90
  });
91
91
  }
package/package.json CHANGED
@@ -1,11 +1,15 @@
1
1
  {
2
2
  "name": "pure-effect",
3
- "version": "0.4.0",
4
- "description": "A tiny, zero-dependency effect system for writing pure, testable JavaScript without mocks.",
3
+ "version": "0.6.0",
4
+ "description": "A tiny, zero-dependency effect system for writing pure, testable JavaScript/TypeScript without mocks.",
5
5
  "type": "module",
6
6
  "exports": "./index.js",
7
+ "types": "./index.d.ts",
7
8
  "scripts": {
8
- "test": "mocha"
9
+ "test": "mocha && tsd"
10
+ },
11
+ "tsd": {
12
+ "directory": "test"
9
13
  },
10
14
  "author": "Aycan Gulez",
11
15
  "homepage": "https://github.com/aycangulez/pure-effect",
@@ -14,6 +18,7 @@
14
18
  "@opentelemetry/sdk-node": "^0.211.0",
15
19
  "@types/mocha": "^10.0.10",
16
20
  "@types/node": "^24.10.1",
17
- "mocha": "^11.7.5"
21
+ "mocha": "^11.7.5",
22
+ "tsd": "^0.33.0"
18
23
  }
19
24
  }
package/test/all.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // @ts-check
2
2
 
3
3
  import { strict as assert } from 'assert';
4
- import { Success, Failure, Command, effectPipe, runEffect, configureEffect } from '../index.js';
4
+ import { Success, Failure, Command, Ask, effectPipe, runEffect, configureEffect } from '../index.js';
5
5
  import { enableTelemetry } from '../opentelemetry-example.js';
6
6
 
7
7
  /** @import { CommandInterceptor } from "../index.js" */
@@ -17,7 +17,7 @@ const db = {
17
17
  const u = { ...user, id: Date.now() };
18
18
  this.users.set(user.email, u);
19
19
  return u;
20
- },
20
+ }
21
21
  };
22
22
 
23
23
  function validateRegistration(/** @type {User} */ input) {
@@ -83,7 +83,7 @@ describe('Pure Effect', function () {
83
83
  it('should access context through onBeforeCommand', async function () {
84
84
  configureEffect({
85
85
  onBeforeCommand: /** @type CommandInterceptor */ async (command, context) =>
86
- assert.equal(context.env, 'test'),
86
+ assert.equal(context.env, 'test')
87
87
  });
88
88
  const input = { email: 'context@test.com', password: 'password123' };
89
89
  const result = await runEffect(registerUserFlow(input), { env: 'test' });
@@ -102,4 +102,30 @@ describe('Pure Effect', function () {
102
102
  const result = await registerUser(input);
103
103
  assert.equal(result.type, 'Success');
104
104
  });
105
+
106
+ it('should access context through Ask', async function () {
107
+ /** @type {any} */
108
+ let capturedCtx;
109
+ const step = () =>
110
+ Ask((ctx) => {
111
+ capturedCtx = ctx;
112
+ return Success(null);
113
+ });
114
+ await runEffect(step(), { env: 'test' });
115
+ assert.equal(capturedCtx.env, 'test');
116
+ });
117
+
118
+ it('should work with Ask at any point in the pipeline', async function () {
119
+ const flow = effectPipe(
120
+ () =>
121
+ Command(
122
+ () => 'value',
123
+ (r) => Success(r)
124
+ ),
125
+ (value) => Ask((/** @type {any} */ ctx) => Success({ value, env: ctx.env }))
126
+ );
127
+ const result = await runEffect(flow(null), { env: 'test' });
128
+ assert.equal(result.type, 'Success');
129
+ assert.deepEqual(result.value, { value: 'value', env: 'test' });
130
+ });
105
131
  });
@@ -0,0 +1,74 @@
1
+ import { expectType, expectError } from 'tsd';
2
+ import { Success, Failure, Command, Ask, effectPipe, runEffect } from '../index.js';
3
+ import type { SuccessState, FailureState, CommandState, AskState, Effect } from '../index.js';
4
+
5
+ interface User {
6
+ email: string;
7
+ password: string;
8
+ }
9
+ interface SavedUser {
10
+ id: number;
11
+ email: string;
12
+ }
13
+
14
+ // --- Success ---
15
+
16
+ const s = Success(42);
17
+ expectType<SuccessState<number>>(s);
18
+ expectError(Success()); // missing argument
19
+
20
+ // --- Failure ---
21
+
22
+ const f = Failure('oops');
23
+ expectType<FailureState<string>>(f);
24
+
25
+ // --- Command ---
26
+
27
+ const cmd = Command(
28
+ async () => ({ id: 1, email: 'a@b.com' }) as SavedUser,
29
+ (saved) => {
30
+ expectType<SavedUser>(saved);
31
+ return Success(saved);
32
+ }
33
+ );
34
+ expectType<CommandState<SavedUser, SavedUser, unknown>>(cmd);
35
+
36
+ // --- effectPipe type propagation ---
37
+
38
+ const step1 = (input: User) => Success(input);
39
+ const step2 = (user: User) =>
40
+ Command(
41
+ async () => ({ id: 1, ...user }) as SavedUser,
42
+ (s) => Success(s)
43
+ );
44
+
45
+ const flow = effectPipe(step1, step2);
46
+ expectType<Effect<SavedUser>>(flow({ email: 'a@b.com', password: 'secret123' }));
47
+ expectError(flow({ email: 'a@b.com' })); // missing password
48
+
49
+ // --- runEffect return type ---
50
+
51
+ const result = await runEffect(flow({ email: 'a@b.com', password: 'secret123' }));
52
+ expectType<SuccessState<SavedUser> | FailureState<unknown>>(result);
53
+
54
+ // --- discriminated union narrowing ---
55
+
56
+ if (result.type === 'Success') {
57
+ expectType<SavedUser>(result.value);
58
+ } else {
59
+ expectType<unknown>(result.error);
60
+ }
61
+
62
+ // --- Failure error type flows through runEffect ---
63
+
64
+ const failFlow = effectPipe((input: User): Effect<User, string> => Failure<string>('bad'));
65
+ const failResult = await runEffect(failFlow({ email: 'a@b.com', password: 'x' }));
66
+ expectType<SuccessState<User> | FailureState<string>>(failResult);
67
+
68
+ // --- Ask ---
69
+
70
+ const ask = Ask((ctx) => Success(ctx as User));
71
+ expectType<AskState<User, unknown>>(ask);
72
+
73
+ const askFlow = effectPipe((input: User) => Ask((_ctx) => Success(input)));
74
+ expectType<Effect<User>>(askFlow({ email: 'a@b.com', password: 'secret123' }));