pure-effect 0.5.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 CHANGED
@@ -17,29 +17,33 @@ No build or lint step — the library ships as plain ES modules with no transpil
17
17
 
18
18
  ### Core abstractions (all in `index.js`)
19
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
- | `effectPipe(...fns)` | | Composes functions into a sequential pipeline via `chain` |
26
- | `runEffect(effect, context)` | async | Interpreter: traverses the effect tree, executes Commands |
27
- | `configureEffect(options)` | | Injects telemetry hooks (`onStep`, `onRun`, `onBeforeCommand`) |
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`) |
28
29
 
29
30
  ### Data flow
30
31
 
31
32
  ```
32
33
  effectPipe(f1, f2, f3)(input)
33
- → returns tree of Success / Failure / Command values
34
+ → returns tree of Success / Failure / Command / Ask values
35
+ → f1 runs eagerly here
34
36
 
35
37
  runEffect(tree, context)
36
- → executes Commands async, passes results into next(), repeats
38
+ → executes Commands async, passes results into next(result), repeats
39
+ → resolves Ask by calling next(context), continues
37
40
  → resolves to final Success or Failure
38
41
  ```
39
42
 
40
- The `chain` combinator (internal) drives composition: `Success` passes its value to the next function, `Failure` short-circuits, `Command` defers execution. `runEffect` loops through the tree with a `while` loop rather than recursion.
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.
41
44
 
42
45
  `configureEffect` hooks:
46
+
43
47
  - `onStep` — fires on every Command execution; wraps the `cmd` call (use for per-command tracing)
44
48
  - `onRun` — fires once per `runEffect` call; wraps the whole workflow (use for top-level spans); receives `context.flowName` as the third argument
45
49
  - `onBeforeCommand` — intercepts each Command before execution; receives the Command and the `context` passed to `runEffect`
@@ -54,6 +58,6 @@ Full generic type declarations are in `index.d.ts` and referenced via the `types
54
58
 
55
59
  ### Tests
56
60
 
57
- `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.
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.
58
62
 
59
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** ships with JSDoc type annotations for JavaScript users and a bundled `index.d.ts` declaration file with full generic types for TypeScript users.
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 CHANGED
@@ -18,17 +18,17 @@ export type CommandState<R, T, E = unknown> = {
18
18
  initialInput?: unknown;
19
19
  };
20
20
 
21
- export type Effect<T, E = unknown> =
22
- | SuccessState<T>
23
- | FailureState<E>
24
- | CommandState<any, T, E>;
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>;
25
28
 
26
29
  export declare function Success<T>(value: T): SuccessState<T>;
27
30
 
28
- export declare function Failure<E = unknown>(
29
- error: E,
30
- initialInput?: unknown
31
- ): FailureState<E>;
31
+ export declare function Failure<E = unknown>(error: E, initialInput?: unknown): FailureState<E>;
32
32
 
33
33
  export declare function Command<R, T, E = unknown>(
34
34
  cmd: () => Promise<R> | R,
@@ -36,9 +36,9 @@ export declare function Command<R, T, E = unknown>(
36
36
  meta?: unknown
37
37
  ): CommandState<R, T, E>;
38
38
 
39
- export declare function effectPipe<A, B, E = unknown>(
40
- f1: (a: A) => Effect<B, E>
41
- ): (start: A) => Effect<B, E>;
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
42
 
43
43
  export declare function effectPipe<A, B, C, E = unknown>(
44
44
  f1: (a: A) => Effect<B, E>,
@@ -101,11 +101,7 @@ export declare function runEffect<T, E = unknown>(
101
101
  context?: unknown
102
102
  ): Promise<SuccessState<T> | FailureState<E>>;
103
103
 
104
- export type StepRunner = (
105
- name: string,
106
- type: string,
107
- op: () => Promise<unknown>
108
- ) => Promise<unknown>;
104
+ export type StepRunner = (name: string, type: string, op: () => Promise<unknown>) => Promise<unknown>;
109
105
 
110
106
  export type RunWrapper = (
111
107
  effect: Effect<unknown>,
@@ -113,10 +109,7 @@ export type RunWrapper = (
113
109
  flowName?: string
114
110
  ) => Promise<SuccessState<unknown> | FailureState<unknown>>;
115
111
 
116
- export type CommandInterceptor = (
117
- command: CommandState<unknown, unknown>,
118
- context?: any
119
- ) => Promise<void>;
112
+ export type CommandInterceptor = (command: CommandState<unknown, unknown>, context?: any) => Promise<void>;
120
113
 
121
114
  export interface EffectConfiguration {
122
115
  onStep?: StepRunner;
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,6 +1,6 @@
1
1
  {
2
2
  "name": "pure-effect",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
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",
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
  });
@@ -1,9 +1,15 @@
1
1
  import { expectType, expectError } from 'tsd';
2
- import { Success, Failure, Command, effectPipe, runEffect } from '../index.js';
3
- import type { SuccessState, FailureState, CommandState, Effect } from '../index.js';
2
+ import { Success, Failure, Command, Ask, effectPipe, runEffect } from '../index.js';
3
+ import type { SuccessState, FailureState, CommandState, AskState, Effect } from '../index.js';
4
4
 
5
- interface User { email: string; password: string; }
6
- interface SavedUser { id: number; email: string; }
5
+ interface User {
6
+ email: string;
7
+ password: string;
8
+ }
9
+ interface SavedUser {
10
+ id: number;
11
+ email: string;
12
+ }
7
13
 
8
14
  // --- Success ---
9
15
 
@@ -19,15 +25,22 @@ expectType<FailureState<string>>(f);
19
25
  // --- Command ---
20
26
 
21
27
  const cmd = Command(
22
- async () => ({ id: 1, email: 'a@b.com' } as SavedUser),
23
- (saved) => { expectType<SavedUser>(saved); return Success(saved); }
28
+ async () => ({ id: 1, email: 'a@b.com' }) as SavedUser,
29
+ (saved) => {
30
+ expectType<SavedUser>(saved);
31
+ return Success(saved);
32
+ }
24
33
  );
25
34
  expectType<CommandState<SavedUser, SavedUser, unknown>>(cmd);
26
35
 
27
36
  // --- effectPipe type propagation ---
28
37
 
29
38
  const step1 = (input: User) => Success(input);
30
- const step2 = (user: User) => Command(async () => ({ id: 1, ...user } as SavedUser), (s) => Success(s));
39
+ const step2 = (user: User) =>
40
+ Command(
41
+ async () => ({ id: 1, ...user }) as SavedUser,
42
+ (s) => Success(s)
43
+ );
31
44
 
32
45
  const flow = effectPipe(step1, step2);
33
46
  expectType<Effect<SavedUser>>(flow({ email: 'a@b.com', password: 'secret123' }));
@@ -51,3 +64,11 @@ if (result.type === 'Success') {
51
64
  const failFlow = effectPipe((input: User): Effect<User, string> => Failure<string>('bad'));
52
65
  const failResult = await runEffect(failFlow({ email: 'a@b.com', password: 'x' }));
53
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' }));