Skip to content

Instantly share code, notes, and snippets.

@tokland
Last active August 17, 2025 12:08
Show Gist options
  • Save tokland/17c0ca65509bb4bb06992f1ce94c7e86 to your computer and use it in GitHub Desktop.
Save tokland/17c0ca65509bb4bb06992f1ce94c7e86 to your computer and use it in GitHub Desktop.
import { promises as fsP } from "node:fs";
import { promises as readlineP } from "node:readline";
/**
* @module EffectSystem
*
* @overview
* This module defines a lightweight, composable effect system for managing asynchronous side effects
* in a structured and declarative way. It enables the creation, composition, and execution of effectful
* operations—such as I/O, user interaction, and file system access—while maintaining strong type safety
* and separation of concerns.
*
* @goals
* - Define a generic infrastructure for modeling asynchronous effects using TypeScript.
* - Allow effects to be composed using functional combinators like `map` and `flatMap`.
* - Provide a mechanism to execute composed effects with concrete implementations.
* - Support cancellation infrastructure for future extensibility (e.g., with cancellable promises).
*
* @core concepts
* - `BaseIEffects`: A generic type that defines the shape of an effect interface, mapping keys to async functions.
* - `Effect`: A class representing a single or composed effect, parameterized by the effect interface and result type.
* - `create`: Method to instantiate a concrete effect from an effect key and arguments.
* - `map` / `flatMap`: Functional combinators for transforming and chaining effects.
* - `run`: Executes the effect using a provided implementation and success callback.
*
* @example
* ```ts
* interface MyEffects {
* print(message: string): Promise<void>;
* readFile(options: { filename: string }): Promise<string>;
* }
*
* const effect = Effect.build<MyEffects>();
*
* const compositeEffect = effect
* .flatMap(() => effect.create("print", "Hello!"))
* .flatMap(() => effect.create("readFile", { filename: "input.txt" }))
* .map((contents) => contents.toUpperCase());
*
* const realImpl: MyEffects = {
* print: async (message) => console.log(message),
* readFile: async ({ filename }) => "file contents",
* };
*
* compositeEffect.run(realImpl, (result) => {
* console.log("Final result:", result);
* });
* ```
*
* @todo
* - Implement cancellation support for effects.
* - Add error branch to run.
* - Extend the effect system with more combinators for sequential, parallel execution, retries, etc.
*
*/
/* Infrastructure */
function debug(...args: any[]) {
console.debug("[DEBUG]", ...args);
}
type BaseIEffects<Keys extends keyof any> = {
[K in Keys]: (...args: any[]) => Promise<any>;
};
type CancelFn = () => void;
// We using standard promises, so we cannot cancel them, but let's add to the infrastructure anyway
// A library to have cancellable promises: 'real-cancellable-promise'.
const cancelFn: CancelFn = () => {};
class Effect<IEffects extends BaseIEffects<keyof IEffects>, AllEfectKind extends keyof IEffects, ResultType> {
constructor(
private options: {
run: (implementations: IEffects, onSuccess: (value: ResultType) => void) => CancelFn;
}
) {}
static build<SomeEffects extends BaseIEffects<keyof SomeEffects>>(): Effect<SomeEffects, never, void> {
return new Effect<SomeEffects, never, void>({
run: (_implementations, onSuccess) => {
onSuccess(undefined);
return cancelFn;
},
});
}
create<Kind extends keyof IEffects>(
kind: Kind,
...args: Parameters<IEffects[Kind]>
): Effect<IEffects, Kind, Awaited<ReturnType<IEffects[Kind]>>> {
return new Effect({
run: (implementations, onSuccess) => {
const effectFn = implementations[kind];
debug(`Running effect '${String(kind)}':`, ...args);
const value = effectFn(...args);
value.then((result) => {
debug(`Effect '${String(kind)}' completed with result:`, result);
onSuccess(result);
});
return cancelFn;
},
});
}
run(implementations: IEffects, onSuccess: (value: ResultType) => void): CancelFn {
return this.options.run(implementations, onSuccess);
}
/* Combination methods */
map<ResultType2>(
getValue: (value: ResultType) => ResultType2
): Effect<IEffects, AllEfectKind, ResultType2> {
return new Effect({
run: (implementations, onSuccess) => {
return this.options.run(implementations, (result) => {
const value = getValue(result);
onSuccess(value);
});
},
});
}
flatMap<EffectResultKind extends keyof IEffects, ReturnType2>(
getEffect: (value: ResultType) => Effect<IEffects, EffectResultKind, ReturnType2>
): Effect<IEffects, AllEfectKind | EffectResultKind, ReturnType2> {
return new Effect({
run: (implementations, onSuccess) => {
return this.options.run(implementations, (result) => {
const effect = getEffect(result);
effect.run(implementations, onSuccess);
});
},
});
}
}
/* Example usage */
interface MyEffects {
print(message: string): Promise<void>;
prompt(promptMessage: string): Promise<string>;
readFile(options: { filename: string }): Promise<string>;
writeFile(options: { filename: string; contents: string }): Promise<number>;
}
const effect = Effect.build<MyEffects>();
// Inferred as Effect<MyEffects, "print" | "prompt" | "readFile" | "writeFile", { written: number }>
function readFileAndPrint() {
return effect
.flatMap(() => effect.create("print", "Hi, this is a composite effect!"))
.flatMap(() => effect.create("prompt", "Please enter the filename: "))
.flatMap((filename) => effect.create("readFile", { filename }))
.flatMap((contents) => effect.create("writeFile", { filename: "output.txt", contents: contents }))
.map((bytesWritten) => ({ written: bytesWritten }));
}
// It's the time to execute the composite effect! We need to pass a concrete implementation of
// the effects to the 'run' method along with a callback to handle the success result.
class MyEffectsRealImpl implements MyEffects {
async prompt(promptMessage: string) {
const filename = process.env["FILENAME"];
if (filename) return filename;
const readline = readlineP.createInterface({ input: process.stdin, output: process.stdout });
try {
return await readline.question(promptMessage);
} finally {
readline.close();
}
}
async print(message: string) {
console.log(message);
}
async readFile(options: { filename: string }) {
return fsP.readFile(options.filename, "utf8").then((s) => s.trim());
}
async writeFile(options: { filename: string; contents: string }) {
await fsP.writeFile(options.filename, options.contents, "utf8");
return options.contents.length;
}
}
function runExample() {
const compositeEffect = readFileAndPrint();
const implementation = new MyEffectsRealImpl();
compositeEffect.run(implementation, (result) => {
console.log("Composite effect completed successfully with value:", result);
});
}
if (require.main === module) {
runExample();
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment