├── .githooks └── commit-msg ├── src ├── index.ts ├── errors.ts ├── types.js └── types.d.ts ├── tsconfig.json ├── typroof.config.ts ├── test ├── test-env.d.ts ├── typroof-env.d.ts ├── backend-app.spec.ts ├── koka-samples.spec.ts ├── effected.proof.ts ├── README.example.proof.ts └── README.example.spec.ts ├── tsconfig.test.json ├── prettier.config.cjs ├── .gitignore ├── .editorconfig ├── tsconfig.build.json ├── LICENSE ├── scripts └── generate-pipe-overloads.ts ├── .github └── workflows │ └── ci.yml ├── bench └── fib.bench.ts ├── commitlint.config.js ├── package.json └── eslint.config.js /.githooks/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | npx --no -- commitlint --edit "$1" 3 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./effected"; 2 | export * from "./errors"; 3 | 4 | export * from "./types"; 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [{ "path": "./tsconfig.build.json" }, { "path": "./tsconfig.test.json" }] 4 | } 5 | -------------------------------------------------------------------------------- /typroof.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "typroof/config"; 2 | 3 | export default defineConfig({ 4 | testFiles: "**/*.proof.ts", 5 | tsConfigFilePath: "tsconfig.test.json", 6 | }); 7 | -------------------------------------------------------------------------------- /test/test-env.d.ts: -------------------------------------------------------------------------------- 1 | // Fix TypeScript’s complaint about missing types 2 | 3 | // For Vitest 4 | // See: https://github.com/vitejs/vite/issues/9813 5 | declare interface Worker {} 6 | declare interface WebSocket {} 7 | 8 | declare namespace WebAssembly { 9 | interface Module {} 10 | } 11 | 12 | // For Effect 13 | declare interface QueuingStrategy {} 14 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "target": "ESNext", 6 | "lib": ["ESNext"], 7 | "module": "ESNext", 8 | "types": ["node"], 9 | "erasableSyntaxOnly": false 10 | }, 11 | "include": ["src/**/*", "test/**/*", "bench/**/*"], 12 | "exclude": [] 13 | } 14 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | module.exports = /** @satisfies {import("prettier").Config} */ ({ 4 | arrowParens: "always", 5 | bracketSameLine: true, 6 | bracketSpacing: true, 7 | experimentalTernaries: true, 8 | plugins: ["prettier-plugin-packagejson"], 9 | semi: true, 10 | singleQuote: false, 11 | trailingComma: "all", 12 | printWidth: 100, 13 | }); 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs/ 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | # Build 11 | node_modules/ 12 | dist/ 13 | *.local 14 | 15 | # Coverage 16 | coverage/ 17 | 18 | # Editor directories and files 19 | .vscode/* 20 | !.vscode/extensions.json 21 | .idea/ 22 | .DS_Store 23 | *.suo 24 | *.ntvs* 25 | *.njsproj 26 | *.sln 27 | *.sw? 28 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | 11 | # Matches multiple files with brace expansion notation 12 | # Set default charset 13 | [*.{js,cjs,mjs,ts,cts,mts,json}] 14 | charset = utf-8 15 | indent_style = space 16 | indent_size = 2 17 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | import type { Effect } from "./types"; 2 | 3 | /** 4 | * An error thrown when an unhandled effect is encountered. 5 | */ 6 | export class UnhandledEffectError extends Error { 7 | declare public effect: Effect; 8 | 9 | constructor( 10 | /** 11 | * The unhandled effect. 12 | */ 13 | effect: Effect, 14 | message?: string, 15 | ) { 16 | super(message); 17 | this.effect = effect; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/types.js: -------------------------------------------------------------------------------- 1 | /** 2 | * An algebraic effect. 3 | */ 4 | export class Effect { 5 | /** 6 | * @param {string | symbol} name Name of the effect, used to identify the effect. 7 | * 8 | * **⚠️ Warning:** This identifier is used to match effects, so be careful with name collisions. 9 | * @param {unknown[]} payloads Payloads of the effect. 10 | */ 11 | constructor(name, payloads) { 12 | /** 13 | * Name of the effect, used to identify the effect. 14 | * 15 | * **⚠️ Warning:** This identifier is used to match effects, so be careful with name collisions. 16 | */ 17 | this.name = name; 18 | /** 19 | * Payloads of the effect. 20 | */ 21 | this.payloads = payloads; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | "composite": true, 5 | "useDefineForClassFields": false, 6 | "lib": ["ES2015", "ES2018.AsyncGenerator"], 7 | "module": "ES2015", 8 | "types": [], 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "verbatimModuleSyntax": true, 13 | "erasableSyntaxOnly": true, 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "outDir": "dist", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "checkJs": true, 21 | "forceConsistentCasingInFileNames": true, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "noFallthroughCasesInSwitch": true, 25 | "noUncheckedIndexedAccess": true, 26 | 27 | /* Type */ 28 | "declaration": true 29 | }, 30 | "include": ["src/**/*"], 31 | "exclude": ["src/**/*.proof.ts", "src/**/*.spec.ts", "src/**/*.bench.ts"], 32 | "tsc-alias": { 33 | "resolveFullPaths": true 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Ge Gao (Snowflyt) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/typroof-env.d.ts: -------------------------------------------------------------------------------- 1 | import type { Serializer, Stringify, Type } from "typroof/plugin"; 2 | 3 | import type { Effect, Effected, Unresumable } from "../src"; 4 | 5 | declare module "typroof/plugin" { 6 | interface StringifySerializerRegistry { 7 | Effect: { if: ["extends", Effect]; serializer: EffectSerializer }; 8 | Effected: { if: ["extends", Effected]; serializer: EffectedSerializer }; 9 | } 10 | } 11 | 12 | interface EffectSerializer extends Serializer { 13 | return: Type extends Effect.Error ? `Effect.Error<${Stringify}>` 14 | : Type extends Effect.Dependency ? 15 | `Effect.Dependency<${Stringify}, ${Stringify}>` 16 | : Type extends Unresumable> ? 17 | `Unresumable}, ${Stringify}, ${Stringify}>>` 18 | : Type extends Effect ? 19 | `Effect<${Stringify}, ${Stringify}, ${Stringify}>` 20 | : never; 21 | } 22 | interface EffectedSerializer extends Serializer> { 23 | return: Type extends Effected ? 24 | `Effected<${Stringify}, ${Stringify}>` 25 | : never; 26 | } 27 | -------------------------------------------------------------------------------- /scripts/generate-pipe-overloads.ts: -------------------------------------------------------------------------------- 1 | const print = (params: (0 | 1)[]) => { 2 | const es = ["", "E"]; 3 | for (let i = 0; i < params.length; i++) 4 | es[i + 2] = params[i] === 0 ? `Exclude<${es[i + 1]}, E${i + 1}In> | E${i + 2}Out` : `E${i + 2}`; 5 | 6 | let result = "pipe<"; 7 | for (let i = 0; i < params.length; i++) { 8 | if (!result.endsWith("<")) result += ", "; 9 | result += 10 | params[i] === 0 ? 11 | `E${i + 1}In extends Effect, E${i + 2}Out extends Effect` 12 | : `E${i + 2} extends Effect`; 13 | result += `, R${i + 2}`; 14 | } 15 | result += ">("; 16 | for (let i = 0; i < params.length; i++) { 17 | if (!result.endsWith("(")) result += ", "; 18 | result += `${String.fromCharCode("a".charCodeAt(0) + i)}: (self: `; 19 | result += 20 | params[i] === 0 ? 21 | `EffectedDraft) => EffectedDraft` 22 | : `Effected<${es[i + 1]}, R${i === 0 ? "" : i + 1}>) => Effected`; 23 | } 24 | result += `): Effected<${es[es.length - 1]}, R${params.length + 1}>;`; 25 | return result; 26 | }; 27 | 28 | const allParams = (length: number) => { 29 | const result: (0 | 1)[][] = []; 30 | const add = (params: (0 | 1)[], index: number) => { 31 | if (index === length) { 32 | result.push(params); 33 | return; 34 | } 35 | add([...params, 0], index + 1); 36 | add([...params, 1], index + 1); 37 | }; 38 | add([], 0); 39 | return result; 40 | }; 41 | 42 | const printAll = (length: number) => { 43 | let result = ""; 44 | for (const params of allParams(length)) { 45 | if (result) result += "\n"; 46 | result += "// prettier-ignore\n"; 47 | result += print(params); 48 | } 49 | return result; 50 | }; 51 | 52 | for (let i = 1; i <= 8; i++) { 53 | console.log(`// * ${i}`); 54 | console.log(printAll(i)); 55 | } 56 | 57 | export {}; 58 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | typecheck: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout the repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up Node.js 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: lts/* 23 | 24 | - name: Install dependencies 25 | run: npm ci 26 | 27 | - name: Typecheck 28 | run: npm run typecheck 29 | 30 | lint: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - name: Checkout the repository 34 | uses: actions/checkout@v4 35 | 36 | - name: Set up Node.js 37 | uses: actions/setup-node@v4 38 | with: 39 | node-version: lts/* 40 | 41 | - name: Install dependencies 42 | run: npm ci 43 | 44 | - name: Lint 45 | run: npm run lint 46 | 47 | test: 48 | runs-on: ubuntu-latest 49 | 50 | strategy: 51 | matrix: 52 | node-version: [18.x, 20.x, 22.x] 53 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 54 | 55 | steps: 56 | - name: Checkout the repository 57 | uses: actions/checkout@v4 58 | 59 | - name: Set up Node.js ${{ matrix.node-version }} 60 | uses: actions/setup-node@v4 61 | with: 62 | node-version: ${{ matrix.node-version }} 63 | cache: "npm" 64 | 65 | - name: Install dependencies 66 | run: npm ci --ignore-scripts 67 | 68 | - name: Test types 69 | run: npm run test-types 70 | 71 | - name: Test 72 | run: npm run test:cov 73 | 74 | - name: Report Coveralls 75 | run: curl -sL https://coveralls.io/coveralls-linux.tar.gz | tar -xz && ./coveralls 76 | env: 77 | COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} 78 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | import type { Effected, effect } from "./effected"; 2 | 3 | /** 4 | * An algebraic effect. 5 | */ 6 | export class Effect< 7 | out Name extends string | symbol = string | symbol, 8 | out Payloads extends unknown[] = unknown[], 9 | out R = unknown, 10 | > { 11 | /** 12 | * Name of the effect, used to identify the effect. 13 | * 14 | * **⚠️ Warning:** This identifier is used to match effects, so be careful with name collisions. 15 | */ 16 | public readonly name: Name; 17 | /** 18 | * Payloads of the effect. 19 | */ 20 | public readonly payloads: Payloads; 21 | /** 22 | * This property only exists at type level and is used to infer the return type of the effect. 23 | */ 24 | public readonly __returnType: R; 25 | 26 | constructor( 27 | /** 28 | * Name of the effect, used to identify the effect. 29 | * 30 | * **⚠️ Warning:** This identifier is used to match effects, so be careful with name collisions. 31 | */ 32 | name: Name, 33 | /** 34 | * Payloads of the effect. 35 | */ 36 | payloads: Payloads, 37 | ); 38 | } 39 | 40 | export namespace Effect { 41 | /** 42 | * A special variant of {@link Effect} that represents an error. 43 | */ 44 | export type Error = Unresumable< 45 | Effect<`error:${Name}`, [message?: string], never> 46 | >; 47 | 48 | /** 49 | * A special variant of {@link Effect} that represents a dependency. 50 | */ 51 | export type Dependency = Effect< 52 | `dependency:${Name}`, 53 | [], 54 | T 55 | >; 56 | } 57 | 58 | /** 59 | * Mark an {@link Effect} as unresumable. 60 | */ 61 | export type Unresumable = E & { resumable: false }; 62 | 63 | /** 64 | * Mark an {@link Effect} as unresumable. 65 | */ 66 | export type Default = E & { 67 | defaultHandler: ({ 68 | effect, 69 | resume, 70 | terminate, 71 | }: { 72 | effect: Effect; // NOTE: Use `Effect` instead of stricter `E` is intentional to make `E` covariant 73 | resume: (value: E["__returnType"]) => void; 74 | terminate: (value: T) => void; 75 | }) => void | Generator | Effected; 76 | }; 77 | 78 | declare const unhandledEffect: unique symbol; 79 | /** 80 | * A type representing unhandled effects. 81 | */ 82 | export interface UnhandledEffect { 83 | readonly [unhandledEffect]: E; 84 | } 85 | 86 | /***************** 87 | * Utility types * 88 | *****************/ 89 | /** 90 | * Infer the {@link Effect} type from an {@link Effected} instance or an {@link EffectFactory} 91 | * defined with {@link effect}. 92 | * 93 | * @example 94 | * ```typescript 95 | * const println = effect("println"); 96 | * type Println = InferEffect; 97 | * // ^?: Effect<"println", unknown[], void> 98 | * ``` 99 | * 100 | * @example 101 | * ```typescript 102 | * declare const program: Effected; 103 | * type E = InferEffect; 104 | * // ^?: Println | Raise 105 | * ``` 106 | */ 107 | export type InferEffect | ((...args: any) => Iterable)> = 108 | E extends Iterable ? E 109 | : E extends (...args: any) => Iterable ? E 110 | : never; 111 | 112 | /** 113 | * A factory function for an {@link Effect}. 114 | */ 115 | export type EffectFactory = ( 116 | ...payloads: E["payloads"] 117 | ) => Effected; 118 | -------------------------------------------------------------------------------- /bench/fib.bench.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Benchmark for overhead of computationally expensive functions. 3 | * 4 | * Currently the effected version is around 100x (`fibPipeT`) to 200x (`fibGenT`) slower than the 5 | * non-effected version. 6 | * 7 | * Such overhead should be acceptable for most use-cases, as real-world applications are unlikely to 8 | * execute computationally expensive logic in an effected function. 9 | * 10 | * It is worth noting that tinyeffect’s version using generator syntax is around 20% faster than 11 | * Effect’s version using `Effect.gen`, but the one using pipeline syntax is around 30%~120% slower 12 | * than Effect’s version, which is quite surprising. A further investigation is needed to find out 13 | * why Effect’s version is faster than tinyeffect’s version using pipeline syntax. 14 | */ 15 | 16 | import { Effect } from "effect"; 17 | import { bench, describe } from "vitest"; 18 | 19 | import { Effected, effected } from "../src"; 20 | 21 | const fib = (n: number): number => { 22 | if (n <= 1) return n; 23 | return fib(n - 1) + fib(n - 2); 24 | }; 25 | 26 | /* tinyeffect */ 27 | const fibGenT = (n: number): Effected => 28 | effected(function* () { 29 | if (n <= 1) return n; 30 | return (yield* fibGenT(n - 1)) + (yield* fibGenT(n - 2)); 31 | }); 32 | 33 | const fibPipeT1 = (n: number): Effected => { 34 | if (n <= 1) return Effected.of(n); 35 | return fibPipeT2(n - 1).flatMap((a) => fibPipeT1(n - 2).map((b) => a + b)); 36 | }; 37 | 38 | const fibPipeT2 = (n: number): Effected => { 39 | if (n <= 1) return Effected.of(n); 40 | return fibPipeT2(n - 1).andThen((a) => fibPipeT2(n - 2).andThen((b) => a + b)); 41 | }; 42 | 43 | const fibPipeT3 = (n: number): Effected => { 44 | if (n <= 1) return Effected.of(n); 45 | return fibPipeT2(n - 1).zip(fibPipeT2(n - 2), (a, b) => a + b); 46 | }; 47 | 48 | /* Effect */ 49 | const fibGenE = (n: number): Effect.Effect => 50 | Effect.gen(function* () { 51 | if (n <= 1) return n; 52 | return (yield* fibGenE(n - 1)) + (yield* fibGenE(n - 2)); 53 | }); 54 | 55 | const fibPipeE1 = (n: number): Effect.Effect => { 56 | if (n <= 1) return Effect.succeed(n); 57 | return fibPipeE1(n - 1).pipe( 58 | Effect.flatMap((a) => fibPipeE1(n - 2).pipe(Effect.map((b) => a + b))), 59 | ); 60 | }; 61 | 62 | const fibPipeE2 = (n: number): Effect.Effect => { 63 | if (n <= 1) return Effect.succeed(n); 64 | return fibPipeE2(n - 1).pipe( 65 | Effect.andThen((a) => fibPipeE2(n - 2).pipe(Effect.andThen((b) => a + b))), 66 | ); 67 | }; 68 | 69 | const fibPipeE3 = (n: number): Effect.Effect => { 70 | if (n <= 1) return Effect.succeed(n); 71 | return fibPipeE2(n - 1).pipe(Effect.zipWith(fibPipeE2(n - 2), (a, b) => a + b)); 72 | }; 73 | 74 | /* Bench */ 75 | describe("fib(20)", () => { 76 | bench("[baseline] fib(20)", () => void fib(20)); 77 | bench("[tinyeffect] fibGen(20)", () => void fibGenT(20).runSync()); 78 | bench("[tinyeffect] fibPipe(20) with map/flatMap", () => void fibPipeT1(20).runSync()); 79 | bench("[tinyeffect] fibPipe(20) with andThen", () => void fibPipeT2(20).runSync()); 80 | bench("[tinyeffect] fibPipe(20) with zip", () => void fibPipeT3(20).runSync()); 81 | bench("[Effect] fibGen(20)", () => void Effect.runSync(fibGenE(20))); 82 | bench("[Effect] fibPipe(20) with map/flatMap", () => void Effect.runSync(fibPipeE1(20))); 83 | bench("[Effect] fibPipe(20) with andThen", () => void Effect.runSync(fibPipeE2(20))); 84 | bench("[Effect] fibPipe(20) with zip", () => void Effect.runSync(fibPipeE3(20))); 85 | }); 86 | 87 | describe("fib(30)", () => { 88 | bench("[baseline] fib(30)", () => void fib(30)); 89 | bench("[tinyeffect] fibGen(30)", () => void fibGenT(30).runSync()); 90 | bench("[tinyeffect] fibPipe(30) with map/flatMap", () => void fibPipeT1(30).runSync()); 91 | bench("[tinyeffect] fibPipe(30) with andThen", () => void fibPipeT2(30).runSync()); 92 | bench("[tinyeffect] fibPipe(30) with zip", () => void fibPipeT3(30).runSync()); 93 | bench("[Effect] fibGen(30)", () => void Effect.runSync(fibGenE(30))); 94 | bench("[Effect] fibPipe(30) with map/flatMap", () => void Effect.runSync(fibPipeE1(30))); 95 | bench("[Effect] fibPipe(30) with andThen", () => void Effect.runSync(fibPipeE2(30))); 96 | bench("[Effect] fibPipe(30) with zip", () => void Effect.runSync(fibPipeE3(30))); 97 | }); 98 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * @typedef {object} Parsed 5 | * @property {?string} emoji The emoji at the beginning of the commit message. 6 | * @property {?string} type The type of the commit message. 7 | * @property {?string} scope The scope of the commit message. 8 | * @property {?string} subject The subject of the commit message. 9 | */ 10 | 11 | const emojiEnum = /** @type {const} */ ([ 12 | 2, 13 | "always", 14 | { 15 | "🎉": ["init", "Project initialization"], 16 | "✨": ["feat", "Adding new features"], 17 | "🐞": ["fix", "Fixing bugs"], 18 | "📃": ["docs", "Modify documentation only"], 19 | "🌈": [ 20 | "style", 21 | "Only the spaces, formatting indentation, commas, etc. were changed, not the code logic", 22 | ], 23 | "🦄": ["refactor", "Code refactoring, no new features added or bugs fixed"], 24 | "🎈": ["perf", "Optimization-related, such as improving performance, experience"], 25 | "🧪": ["test", "Adding or modifying test cases"], 26 | "🔧": [ 27 | "build", 28 | "Dependency-related content, such as Webpack, Vite, Rollup, npm, package.json, etc.", 29 | ], 30 | "🐎": ["ci", "CI configuration related, e.g. changes to k8s, docker configuration files"], 31 | "🐳": ["chore", "Other modifications, e.g. modify the configuration file"], 32 | "↩": ["revert", "Rollback to previous version"], 33 | }, 34 | ]); 35 | 36 | /** @satisfies {import("@commitlint/types").UserConfig} */ 37 | const config = { 38 | parserPreset: { 39 | parserOpts: { 40 | headerPattern: 41 | /^(?\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff]) (?\w+)(?:\((?.*)\))?!?: (?(?:(?!#).)*(?:(?!\s).))$/, 42 | headerCorrespondence: ["emoji", "type", "scope", "subject"], 43 | }, 44 | }, 45 | plugins: [ 46 | { 47 | rules: { 48 | "header-match-git-commit-message-with-emoji-pattern": (parsed) => { 49 | const { emoji, scope, subject, type } = /** @type {Parsed} */ ( 50 | /** @type {unknown} */ (parsed) 51 | ); 52 | if (emoji === null && type === null && scope === null && subject === null) 53 | return [ 54 | false, 55 | 'header must be in format " (?): ", e.g:\n' + 56 | " - 🎉 init: Initial commit\n" + 57 | " - ✨ feat(assertions): Add assertions\n" + 58 | " ", 59 | ]; 60 | return [true, ""]; 61 | }, 62 | "emoji-enum": (parsed, _, value) => { 63 | const { emoji } = /** @type {Parsed} */ (/** @type {unknown} */ (parsed)); 64 | const emojisObject = /** @type {typeof emojiEnum[2]} */ (/** @type {unknown} */ (value)); 65 | if (emoji && !Object.keys(emojisObject).includes(emoji)) { 66 | return [ 67 | false, 68 | "emoji must be one of:\n" + 69 | Object.entries(emojisObject) 70 | .map(([emoji, [type, description]]) => ` ${emoji} ${type} - ${description}`) 71 | .join("\n") + 72 | "\n ", 73 | ]; 74 | } 75 | return [true, ""]; 76 | }, 77 | }, 78 | }, 79 | ], 80 | rules: { 81 | "header-match-git-commit-message-with-emoji-pattern": [2, "always"], 82 | "body-leading-blank": [2, "always"], 83 | "footer-leading-blank": [2, "always"], 84 | "header-max-length": [2, "always", 72], 85 | "scope-case": [2, "always", ["lower-case", "upper-case"]], 86 | "subject-case": [2, "always", "sentence-case"], 87 | "subject-empty": [2, "never"], 88 | "subject-exclamation-mark": [2, "never"], 89 | "subject-full-stop": [2, "never", "."], 90 | "emoji-enum": emojiEnum, 91 | "type-case": [2, "always", "lower-case"], 92 | "type-empty": [2, "never"], 93 | "type-enum": [ 94 | 2, 95 | "always", 96 | [ 97 | "init", 98 | "feat", 99 | "fix", 100 | "docs", 101 | "style", 102 | "refactor", 103 | "perf", 104 | "test", 105 | "build", 106 | "ci", 107 | "chore", 108 | "revert", 109 | ], 110 | ], 111 | }, 112 | }; 113 | 114 | export default config; 115 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tinyeffect", 3 | "version": "0.3.4", 4 | "private": true, 5 | "description": "A tiny TypeScript library for handling side effects in a unified way using algebraic effects, offering a type-safe approach for async operations, error handling, dependency injection, and more.", 6 | "keywords": [ 7 | "type safe", 8 | "effect", 9 | "side effect", 10 | "algebraic effect", 11 | "asynchronous", 12 | "exception", 13 | "dependency injection" 14 | ], 15 | "homepage": "https://github.com/Snowflyt/tinyeffect", 16 | "bugs": { 17 | "url": "https://github.com/Snowflyt/tinyeffect/issues" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/Snowflyt/tinyeffect" 22 | }, 23 | "license": "MIT", 24 | "author": "Ge Gao (Snowflyt) ", 25 | "type": "module", 26 | "main": "./index.js", 27 | "module": "./index.js", 28 | "types": "./index.d.ts", 29 | "scripts": { 30 | "bench": "vitest bench --run", 31 | "bench:watch": "vitest bench", 32 | "prebuild": "npm run clean && npm run test-types && npm run test", 33 | "build": "npm run compile && cpy package.json dist && json -I -f dist/package.json -e \"delete this.private; delete this.scripts; delete this.devDependencies\" && cpy README.md dist && cpy LICENSE dist && node -e \"import { replaceInFileSync } from 'replace-in-file'; import packageJSON from './dist/package.json' with { type: 'json' }; replaceInFileSync({ files: 'dist/README.md', from: './screenshot.svg', to: 'https://raw.githubusercontent.com/Snowflyt/tinyeffect/v' + packageJSON['version'] + '/screenshot.svg' })\"", 34 | "clean": "rimraf dist", 35 | "compile": "tsc --emitDeclarationOnly --composite false -p tsconfig.build.json && cpy src/**/*.ts dist && rimraf -g dist/**/*.{proof,spec,bench}.ts dist/types.d.ts && node -e \"import path from 'node:path'; import fs from 'node:fs'; import tsBlankSpace from 'ts-blank-space'; fs.readdirSync('dist', { recursive: true }).map((file) => path.join('dist', file)).filter((file) => file.endsWith('.ts') && !file.endsWith('.d.ts') && fs.statSync(file).isFile()).forEach((file) => { fs.writeFileSync(file.substring(0, file.lastIndexOf('.')) + '.js', tsBlankSpace(fs.readFileSync(file, 'utf-8'))); fs.rmSync(file); })\" && cpy src/**/*.{js,d.ts} dist && tsc-alias -p tsconfig.build.json && replace-in-file \"/^\\s*\\/\\/ prettier-ignore$/mg\" \"\" dist/**/*.js --isRegex && replace-in-file \"/^\\s*\\/\\/ eslint-disable-next-line .+$/mg\" \"\" dist/**/*.js --isRegex && replace-in-file \"/^\\s*\\/\\/ @ts-.+$/mg\" \"\" dist/**/*.js --isRegex && replace-in-file \"/^\\s*\\/\\/ Generated .+$/mg\" \"\" dist/**/*.js --isRegex && replace-in-file \"/^\\s*\\/\\/ \\* \\d+$/mg\" \"\" dist/**/*.js --isRegex && prettier --log-level=silent --print-width 80 --write dist/**/* --ignore-path !dist/**/* && node -e \"import { replaceInFileSync } from 'replace-in-file'; replaceInFileSync({ files: 'dist/**/*.js', from: /^\\s*\\*\\/\\n\\n/mg, to: '*/\\n' })\" && prettier --log-level=silent --print-width 80 --write dist/**/* --ignore-path !dist/**/*", 36 | "format": "prettier --no-error-on-unmatched-pattern --write **/*.{js,ts,json,md} *.{cjs,mjs,cts,mts}", 37 | "lint": "eslint **/*.{js,ts} *.{cjs,mjs,cts,mts} --no-error-on-unmatched-pattern --report-unused-disable-directives-severity error --max-warnings 0", 38 | "lint:fix": "eslint --fix **/*.{js,ts} *.{cjs,mjs,cts,mts} --no-error-on-unmatched-pattern --report-unused-disable-directives-severity error --max-warnings 0", 39 | "prepare": "node -e \"import fs from 'fs'; import path from 'path'; const hooksDir = path.join(process.cwd(), '.githooks'); const gitHooksDir = path.join(process.cwd(), '.git/hooks'); if (!fs.existsSync(gitHooksDir)) { console.error('Git hooks directory not found, please run this in a git repository.'); process.exit(1); } fs.readdirSync(hooksDir).forEach(file => { const srcFile = path.join(hooksDir, file); const destFile = path.join(gitHooksDir, file); fs.copyFileSync(srcFile, destFile); if (process.platform !== 'win32' && !file.endsWith('.cmd')) { fs.chmodSync(destFile, 0o755); } })\"", 40 | "test": "vitest run", 41 | "test-types": "typroof", 42 | "test:cov": "vitest run --coverage --coverage.reporter=text --coverage.reporter=lcov --coverage.include \"src/**/!(*.proof).{js,ts}\"", 43 | "test:ui": "vitest --ui --coverage.enabled=true --coverage.include \"src/**/!(*.proof).{js,ts}\"", 44 | "test:watch": "vitest", 45 | "test:watch-cov": "vitest --coverage --coverage.reporter=text --coverage.reporter=lcov --coverage.include \"src/**/!(*.proof).{js,ts}\"", 46 | "typecheck": "tsc --noEmit --composite false -p tsconfig.build.json && tsc --noEmit --composite false -p tsconfig.test.json" 47 | }, 48 | "devDependencies": { 49 | "@commitlint/cli": "^19.8.0", 50 | "@typescript-eslint/parser": "^8.28.0", 51 | "@vitest/coverage-v8": "^3.0.9", 52 | "@vitest/ui": "^3.0.9", 53 | "cpy-cli": "^5.0.0", 54 | "effect": "^3.14.2", 55 | "eslint": "^9.23.0", 56 | "eslint-config-prettier": "^10.1.1", 57 | "eslint-import-resolver-typescript": "^4.2.7", 58 | "eslint-plugin-import-x": "^4.9.3", 59 | "eslint-plugin-jsdoc": "^50.6.9", 60 | "eslint-plugin-prettier": "^5.2.5", 61 | "eslint-plugin-sonarjs": "^3.0.2", 62 | "eslint-plugin-sort-destructure-keys": "^2.0.0", 63 | "globals": "^16.0.0", 64 | "json": "^11.0.0", 65 | "prettier": "^3.5.3", 66 | "prettier-plugin-packagejson": "^2.5.10", 67 | "replace-in-file": "^8.3.0", 68 | "rimraf": "^6.0.1", 69 | "ts-blank-space": "^0.6.1", 70 | "tsc-alias": "^1.8.12", 71 | "typescript": "^5.8.2", 72 | "typescript-eslint": "^8.28.0", 73 | "typroof": "^0.5.1", 74 | "vitest": "^3.0.9" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /test/backend-app.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A simple example of a backend application based on tinyeffect. 3 | */ 4 | 5 | import { expect, test, vi } from "vitest"; 6 | 7 | import type { Default, Effect, EffectFactory, Effected } from "../src"; 8 | import { dependency, effect, effected, error } from "../src"; 9 | 10 | /****************** 11 | * Implementation * 12 | ******************/ 13 | type EffectedMethods = { [K: string]: ((...args: any[]) => Generator) | EffectedMethods }; 14 | type TransformEffectedMethods = _Id<{ 15 | [K in keyof Methods]: Methods[K] extends ( 16 | (...args: infer A) => Generator 17 | ) ? 18 | (...args: A) => Effected 19 | : Methods[K] extends EffectedMethods ? TransformEffectedMethods 20 | : never; 21 | }>; 22 | type _Id = T extends infer U ? { [K in keyof U]: U[K] } : never; 23 | const defineEffectedFunctions = ( 24 | methods: Methods, 25 | ): TransformEffectedMethods => { 26 | const transform = (methods: any): any => 27 | Object.fromEntries( 28 | Object.entries(methods).map(([name, fnOrMethods]) => [ 29 | name, 30 | typeof fnOrMethods === "function" ? 31 | (...args: any) => effected(() => fnOrMethods(...args)) 32 | : transform(fnOrMethods), 33 | ]), 34 | ); 35 | return transform(methods); 36 | }; 37 | 38 | const defineRepository = defineEffectedFunctions; 39 | const defineService = defineEffectedFunctions; 40 | 41 | const encrypt = (password: string) => `hashed-${password}`; 42 | const verify = (password: string, hashed: string) => hashed === encrypt(password); 43 | 44 | /*********** 45 | * Effects * 46 | ***********/ 47 | type Println = Default>; 48 | const println: EffectFactory = effect("println", { 49 | defaultHandler: ({ resume }, ...args: unknown[]) => { 50 | console.log(...args); 51 | resume(); 52 | }, 53 | }); 54 | 55 | type AuthenticationError = Effect.Error<"authentication">; 56 | const authenticationError: EffectFactory = error("authentication"); 57 | type UnauthorizedError = Effect.Error<"unauthorized">; 58 | const unauthorizedError: EffectFactory = error("unauthorized"); 59 | type UserNotFoundError = Effect.Error<"userNotFound">; 60 | const userNotFoundError: EffectFactory = error("userNotFound"); 61 | 62 | type SetCurrentUser = Effect<"setCurrentUser", [Omit | null], void>; 63 | const setCurrentUser: EffectFactory = effect("setCurrentUser"); 64 | type CurrentUserDependency = Effect.Dependency<"currentUser", Omit | null>; 65 | const askCurrentUser: EffectFactory = dependency("currentUser"); 66 | 67 | /**************** 68 | * Repositories * 69 | ****************/ 70 | interface User { 71 | id: string; 72 | name: string; 73 | password: string; 74 | role: string; 75 | } 76 | 77 | const _users: User[] = [{ id: "0", name: "Alice", password: encrypt("password"), role: "admin" }]; 78 | 79 | const db = defineRepository({ 80 | user: { 81 | *save(user: Omit) { 82 | const savedUser: User = { 83 | id: _users.length.toString(), 84 | ...user, 85 | password: encrypt(user.password), 86 | }; 87 | _users.push(savedUser); 88 | return savedUser; 89 | }, 90 | 91 | *findByName(name: string) { 92 | return _users.find((user) => user.name === name) ?? null; 93 | }, 94 | }, 95 | }); 96 | 97 | /************ 98 | * Services * 99 | ************/ 100 | const userService = defineService({ 101 | *login(username: string, password: string) { 102 | const user = yield* db.user.findByName(username); 103 | if (user === null) return yield* userNotFoundError("User not found."); 104 | if (!verify(password, user.password)) return yield* authenticationError("Invalid password."); 105 | yield* setCurrentUser(user); 106 | }, 107 | 108 | *createUser(user: Omit) { 109 | const currentUser = yield* askCurrentUser(); 110 | if (!currentUser) return yield* authenticationError("User not authenticated."); 111 | if (currentUser.role !== "admin") 112 | return yield* unauthorizedError("Only admins can create users."); 113 | 114 | yield* println("Creating user:", user); 115 | return yield* db.user.save(user); 116 | }, 117 | }); 118 | 119 | /*************** 120 | * Entry point * 121 | ***************/ 122 | test("app", () => { 123 | let currentUser: Omit | null = null; 124 | 125 | const program = effected(function* () { 126 | yield* userService.login("Alice", "password"); 127 | if (!(yield* db.user.findByName("Bob"))) { 128 | // eslint-disable-next-line sonarjs/no-hardcoded-passwords 129 | const user = yield* userService.createUser({ name: "Bob", password: "secret", role: "user" }); 130 | yield* println("Created user:", user); 131 | } 132 | }) 133 | .provideBy("currentUser", () => currentUser) 134 | .resume("setCurrentUser", (user) => { 135 | currentUser = user; 136 | }) 137 | .catchAll((error, message) => { 138 | console.error(`Error(${error})` + (message ? `: ${message}` : "")); 139 | }); 140 | 141 | const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); 142 | program.runSync(); 143 | expect(logSpy.mock.calls).toMatchInlineSnapshot(` 144 | [ 145 | [ 146 | "Creating user:", 147 | { 148 | "name": "Bob", 149 | "password": "secret", 150 | "role": "user", 151 | }, 152 | ], 153 | [ 154 | "Created user:", 155 | { 156 | "id": "1", 157 | "name": "Bob", 158 | "password": "hashed-secret", 159 | "role": "user", 160 | }, 161 | ], 162 | ] 163 | `); 164 | logSpy.mockRestore(); 165 | 166 | expect(_users).toEqual([ 167 | { id: "0", name: "Alice", password: encrypt("password"), role: "admin" }, 168 | { id: "1", name: "Bob", password: encrypt("secret"), role: "user" }, 169 | ]); 170 | }); 171 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from "@eslint/js"; 4 | import importX from "eslint-plugin-import-x"; 5 | import jsdoc from "eslint-plugin-jsdoc"; 6 | import prettierRecommended from "eslint-plugin-prettier/recommended"; 7 | import sonarjs from "eslint-plugin-sonarjs"; 8 | import sortDestructureKeys from "eslint-plugin-sort-destructure-keys"; 9 | import globals from "globals"; 10 | import tseslint from "typescript-eslint"; 11 | 12 | export default tseslint.config( 13 | eslint.configs.recommended, 14 | tseslint.configs.strictTypeChecked, 15 | tseslint.configs.stylisticTypeChecked, 16 | jsdoc.configs["flat/recommended-typescript-error"], 17 | importX.flatConfigs.recommended, 18 | importX.flatConfigs.typescript, 19 | prettierRecommended, 20 | sonarjs.configs.recommended, 21 | { 22 | plugins: { 23 | jsdoc, 24 | "sort-destructure-keys": sortDestructureKeys, 25 | }, 26 | linterOptions: { 27 | reportUnusedDisableDirectives: true, 28 | }, 29 | languageOptions: { 30 | parserOptions: { 31 | projectService: { 32 | allowDefaultProject: ["*.{js,cjs}", "scripts/*.ts", "typroof.config.ts"], 33 | defaultProject: "tsconfig.test.json", 34 | }, 35 | tsconfigRootDir: import.meta.dirname, 36 | }, 37 | globals: { ...globals.browser }, 38 | }, 39 | rules: { 40 | "@typescript-eslint/restrict-plus-operands": [ 41 | "error", 42 | { allowAny: true, allowNumberAndString: true }, 43 | ], 44 | "@typescript-eslint/restrict-template-expressions": [ 45 | "error", 46 | { allowAny: true, allowBoolean: true, allowNullish: true, allowNumber: true }, 47 | ], 48 | "@typescript-eslint/consistent-indexed-object-style": "off", 49 | "@typescript-eslint/consistent-type-definitions": "off", // TS treats types and interfaces differently, this may break some advanced type gymnastics 50 | "@typescript-eslint/consistent-type-imports": [ 51 | "error", 52 | { prefer: "type-imports", disallowTypeAnnotations: false }, 53 | ], 54 | "@typescript-eslint/dot-notation": ["error", { allowIndexSignaturePropertyAccess: true }], 55 | "@typescript-eslint/no-confusing-void-expression": "off", 56 | "@typescript-eslint/no-empty-function": "off", 57 | "@typescript-eslint/no-empty-object-type": "off", 58 | "@typescript-eslint/no-explicit-any": "off", 59 | "@typescript-eslint/no-extraneous-class": "off", 60 | "@typescript-eslint/no-invalid-void-type": "off", 61 | "@typescript-eslint/no-namespace": "off", 62 | "@typescript-eslint/no-non-null-assertion": "off", 63 | "@typescript-eslint/no-unnecessary-type-parameters": "off", 64 | "@typescript-eslint/no-unsafe-argument": "off", 65 | "@typescript-eslint/no-unsafe-assignment": "off", 66 | "@typescript-eslint/no-unsafe-call": "off", 67 | "@typescript-eslint/no-unsafe-member-access": "off", 68 | "@typescript-eslint/no-unsafe-return": "off", 69 | "@typescript-eslint/no-unused-vars": "off", // Already covered by `tsconfig.json` 70 | "@typescript-eslint/prefer-nullish-coalescing": "off", 71 | "@typescript-eslint/prefer-optional-chain": "off", // This library targets ES2015, so optional chaining is not available 72 | "@typescript-eslint/unified-signatures": "off", 73 | "import-x/consistent-type-specifier-style": ["error", "prefer-top-level"], 74 | "import-x/no-named-as-default-member": "off", 75 | "import-x/no-unresolved": "off", 76 | "import-x/order": [ 77 | "error", 78 | { 79 | alphabetize: { order: "asc" }, 80 | groups: ["builtin", "external", "internal", "parent", "sibling", "index", "object"], 81 | "newlines-between": "always", 82 | }, 83 | ], 84 | "jsdoc/check-param-names": "off", 85 | "jsdoc/check-tag-names": "off", 86 | "jsdoc/check-values": "off", 87 | "jsdoc/no-types": "off", // Already checked by TypeScript 88 | "jsdoc/require-jsdoc": "off", 89 | "jsdoc/require-param": "off", 90 | "jsdoc/require-returns-description": "off", 91 | "jsdoc/tag-lines": "off", 92 | "no-restricted-syntax": [ 93 | "error", 94 | { 95 | selector: "CallExpression[callee.property.name='push'] > SpreadElement.arguments", 96 | message: 97 | "Do not use spread arguments in `Array#push`, " + 98 | "as it might cause stack overflow if you spread a large array. " + 99 | "Instead, use `Array#concat` or `Array.prototype.push.apply`.", 100 | }, 101 | ], 102 | "no-undef": "off", // Already checked by TypeScript 103 | "object-shorthand": "error", 104 | "sonarjs/class-name": ["error", { format: "^_?[A-Z][a-zA-Z0-9]*$" }], 105 | "sonarjs/code-eval": "off", // Already covered by `@typescript-eslint/no-implied-eval` 106 | "sonarjs/cognitive-complexity": "off", 107 | "sonarjs/deprecation": "off", // Already covered by `@typescript-eslint/no-deprecated` 108 | "sonarjs/different-types-comparison": "off", // Already checked by TypeScript 109 | "sonarjs/function-return-type": "off", // Already checked by TypeScript 110 | "sonarjs/generator-without-yield": "off", // Already covered by `require-yield` 111 | "sonarjs/no-alphabetical-sort": "off", 112 | "sonarjs/no-control-regex": "off", // Already covered by `no-control-regex` 113 | "sonarjs/no-ignored-exceptions": "off", 114 | "sonarjs/no-nested-assignment": "off", 115 | "sonarjs/no-nested-conditional": "off", 116 | "sonarjs/no-nested-functions": "off", 117 | "sonarjs/no-primitive-wrappers": "off", // Already covered by `@typescript-eslint/no-wrapper-object-types` 118 | "sonarjs/no-selector-parameter": "off", 119 | "sonarjs/no-useless-intersection": "off", // Already checked by TypeScript 120 | "sonarjs/no-unused-vars": "off", // Already checked by TypeScript 121 | "sonarjs/reduce-initial-value": "off", 122 | "sonarjs/redundant-type-aliases": "off", // Already covered by `@typescript-eslint/no-restricted-type-imports` 123 | "sonarjs/regex-complexity": "off", 124 | "sonarjs/todo-tag": "off", 125 | "sonarjs/use-type-alias": "off", 126 | "sonarjs/void-use": "off", 127 | "sort-destructure-keys/sort-destructure-keys": "error", 128 | "sort-imports": ["error", { ignoreDeclarationSort: true }], 129 | }, 130 | }, 131 | { 132 | files: ["{src,test}/**/*.{proof,spec}.ts"], 133 | rules: { 134 | "@typescript-eslint/ban-ts-comment": "off", 135 | "require-yield": "off", 136 | }, 137 | }, 138 | ); 139 | -------------------------------------------------------------------------------- /test/koka-samples.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Examples from Koka documentation 3 | * https://koka-lang.github.io/koka/doc/book.html 4 | */ 5 | 6 | import { expect, test } from "vitest"; 7 | 8 | import type { Effect, EffectFactory, InferEffect } from "../src"; 9 | import { Effected, defineHandlerFor, dependency, effect, effected, effectify } from "../src"; 10 | 11 | type Println = Effect<"println", unknown[], void>; 12 | const println: EffectFactory = effect("println"); 13 | 14 | test("2.3. Effect Handlers", () => { 15 | type Yield = Effect<"yield", [i: number], boolean>; 16 | const yield_: EffectFactory = effect("yield"); 17 | 18 | type List = Cons | Nil; 19 | type Cons = [T, List]; 20 | type Nil = { _tag: "Nil" }; 21 | const cons = (head: T, tail: List): List => [head, tail]; 22 | const nil: Nil = { _tag: "Nil" }; 23 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 24 | const isNil = (xs: List): xs is Nil => "_tag" in xs && xs._tag === "Nil"; 25 | const list = (...xs: T[]): List => xs.reduceRight>((acc, x) => cons(x, acc), nil); 26 | 27 | const traverse = (xs: List) => 28 | effected(function* (): Generator, void> { 29 | if (isNil(xs)) return; 30 | const [x, xx] = xs; 31 | if (yield* yield_(x)) yield* traverse(xx); 32 | }); 33 | 34 | const printElements = () => 35 | effected(function* () { 36 | yield* traverse(list(1, 2, 3, 4)).handle("yield", function* ({ resume }, i) { 37 | yield* println("yielded", i); 38 | resume(i <= 2); 39 | }); 40 | }); 41 | 42 | const logs: unknown[][] = []; 43 | printElements() 44 | .resume("println", (...args) => { 45 | logs.push(args); 46 | }) 47 | .runSync(); 48 | expect(logs).toEqual([ 49 | ["yielded", 1], 50 | ["yielded", 2], 51 | ["yielded", 3], 52 | ]); 53 | }); 54 | 55 | type Raise = Effect<"raise", [msg: string], never>; 56 | const raise: EffectFactory = effect("raise"); 57 | 58 | const safeDivide = (x: number, y: number) => 59 | effected(function* () { 60 | return y === 0 ? yield* raise("Division by zero") : x / y; 61 | }); 62 | 63 | type Maybe = { _tag: "Just"; value: T } | { _tag: "Nothing" }; 64 | const just = (value: T): Maybe => ({ _tag: "Just", value }); 65 | const nothing: Maybe = { _tag: "Nothing" }; 66 | 67 | const raiseMaybe = defineHandlerFor().with((self) => 68 | self.andThen((r) => just(r)).terminate("raise", () => nothing), 69 | ); 70 | 71 | test("3.2.3. Polymorphic effects", () => { 72 | const map = ( 73 | xs: readonly T[], 74 | f: (x: T) => U | Effected, 75 | ): Effected => 76 | effected(function* () { 77 | const ys: U[] = []; 78 | for (const x of xs) { 79 | const y = f(x); 80 | ys.push(y instanceof Effected ? yield* y : y); 81 | } 82 | return ys; 83 | }); 84 | 85 | expect( 86 | map([1, 21, 0, 2], (n) => safeDivide(42, n)) 87 | .with(raiseMaybe) 88 | .runSync(), 89 | ).toEqual(nothing); 90 | expect( 91 | map([1, 21, 2], (n) => safeDivide(42, n)) 92 | .with(raiseMaybe) 93 | .runSync(), 94 | ).toEqual(just([42, 2, 21])); 95 | }); 96 | 97 | test("3.4.1. Handling", async () => { 98 | const raiseConst1 = () => 99 | effected(function* () { 100 | return 8 + (yield* safeDivide(1, 0)); 101 | }) 102 | .terminate("raise", () => 42) 103 | .runSync(); 104 | expect(raiseConst1()).toEqual(42); 105 | 106 | const raiseConst2 = () => 107 | effected(function* () { 108 | return 8 + (yield* safeDivide(4, 2)); 109 | }) 110 | .terminate("raise", () => 42) 111 | .runAsync(); 112 | expect(await raiseConst2()).toEqual(10); 113 | }); 114 | 115 | test("3.4.2. Resuming", () => { 116 | type Ask = Effect<"ask", [], T>; 117 | const ask = (): Effected, T> => effect("ask")(); 118 | 119 | const addTwice = () => 120 | effected(function* () { 121 | return (yield* ask()) + (yield* ask()); 122 | }); 123 | 124 | const askConst = () => 125 | addTwice() 126 | .handle("ask", ({ resume }) => { 127 | resume(21); 128 | }) 129 | .runSync(); 130 | expect(askConst()).toEqual(42); 131 | 132 | const askConst2 = () => 133 | addTwice() 134 | .resume("ask", () => 21) 135 | .runSync(); 136 | expect(askConst2()).toEqual(42); 137 | 138 | const askOnce = () => { 139 | let count = 0; 140 | return addTwice() 141 | .handle<"ask", number>("ask", ({ resume, terminate }) => { 142 | count++; 143 | if (count <= 1) resume(42); 144 | else terminate(0); 145 | }) 146 | .runSync(); 147 | }; 148 | expect(askOnce()).toEqual(0); 149 | }); 150 | 151 | type WidthDependency = Effect.Dependency<"width", number>; 152 | const askWidth: EffectFactory = dependency("width"); 153 | 154 | test("3.4.3. Tail-Resumptive Operations", async () => { 155 | const prettyInternal = (line: string) => 156 | effected(function* () { 157 | const width = yield* askWidth(); 158 | return line.slice(0, width); 159 | }); 160 | 161 | const prettyThin1 = (d: string) => prettyInternal(d).provide("width", 5).runSync(); 162 | expect(prettyThin1("Hello, world!")).toEqual("Hello"); 163 | 164 | const prettyThin2 = (d: string) => 165 | prettyInternal(d) 166 | .provideBy("width", () => 5) 167 | .runSync(); 168 | expect(prettyThin2("This is a long string")).toEqual("This "); 169 | 170 | const prettyThin3 = (d: string) => 171 | prettyInternal(d) 172 | .provideBy("width", function* () { 173 | return yield* effectify(new Promise((resolve) => setTimeout(() => resolve(5), 10))); 174 | }) 175 | .runAsync(); 176 | expect(await prettyThin3("Delayed string")).toEqual("Delay"); 177 | }); 178 | 179 | type Emit = Effect<"emit", [msg: string], void>; 180 | const emit: EffectFactory = effect("emit"); 181 | 182 | test("3.4.4. Abstracting Handlers", () => { 183 | const eHello = () => 184 | effected(function* () { 185 | yield* emit("hello"); 186 | yield* emit("world"); 187 | }); 188 | 189 | const eHelloConsole = () => 190 | effected(function* () { 191 | yield* eHello().resume("emit", println); 192 | }); 193 | 194 | const logs: unknown[][] = []; 195 | eHelloConsole() 196 | .resume("println", (...args) => { 197 | logs.push(args); 198 | }) 199 | .runSync(); 200 | expect(logs).toEqual([["hello"], ["world"]]); 201 | 202 | const emitConsole2 = defineHandlerFor().with((self) => self.resume("emit", println)); 203 | 204 | const eHelloConsole2 = () => 205 | effected(function* () { 206 | yield* eHello().with(emitConsole2); 207 | }); 208 | 209 | logs.length = 0; 210 | eHelloConsole2() 211 | .resume("println", (...args) => { 212 | logs.push(args); 213 | }) 214 | .runSync(); 215 | expect(logs).toEqual([["hello"], ["world"]]); 216 | }); 217 | 218 | type State = Effect<"state.get", [], T> | Effect<"state.set", [T], void>; 219 | const state = { 220 | get: (): Effected, T> => effect("state.get")<[], T>(), 221 | set: (x: T): Effected, void> => effect("state.set")<[T], void>(x), 222 | }; 223 | const stateHandler = ({ get, set }: { get: () => T; set: (x: T) => void }) => 224 | defineHandlerFor>().with((self) => 225 | self.resume("state.get", get).resume("state.set", set), 226 | ); 227 | 228 | const pState = (init: T) => 229 | defineHandlerFor>().with((self) => { 230 | let st = init; 231 | return self 232 | .andThen((x) => [x, st] as const) 233 | .resume("state.get", () => st) 234 | .resume("state.set", (x) => { 235 | st = x; 236 | }); 237 | }); 238 | 239 | test("3.4.5. Return Operations", () => { 240 | expect(safeDivide(1, 0).with(raiseMaybe).runSync()).toEqual(nothing); 241 | expect(safeDivide(42, 2).with(raiseMaybe).runSync()).toEqual(just(21)); 242 | 243 | const sumDown = (sum = 0): Effected, number> => 244 | effected(function* () { 245 | const i = yield* state.get(); 246 | if (i <= 0) return sum; 247 | yield* state.set(i - 1); 248 | return yield* sumDown(sum + i); 249 | }); 250 | 251 | const state_ = (init: T, action: () => Effected, R>) => { 252 | let st = init; 253 | return action() 254 | .resume("state.get", () => st) 255 | .resume("state.set", (x) => { 256 | st = x; 257 | }) 258 | .runSync(); 259 | }; 260 | expect(state_(10, () => sumDown())).toEqual(55); 261 | 262 | expect(sumDown().with(pState(10)).runSync()).toEqual([55, 0]); 263 | }); 264 | 265 | test("3.4.6. Combining Handlers", () => { 266 | const noOdds = (): Effected, number> => 267 | effected(function* () { 268 | const i = yield* state.get(); 269 | if (i % 2 === 1) return yield* raise("no odds"); 270 | yield* state.set(i / 2); 271 | return i; 272 | }); 273 | 274 | const raiseState1 = (init: number) => noOdds().with(raiseMaybe).with(pState(init)); 275 | expect(raiseState1(42).runSync()).toEqual([just(42), 21]); 276 | expect(raiseState1(21).runSync()).toEqual([nothing, 21]); 277 | 278 | const raiseState2 = (init: number) => 279 | noOdds() 280 | .with( 281 | stateHandler({ 282 | get: () => init, 283 | set: (x) => { 284 | init = x; 285 | }, 286 | }), 287 | ) 288 | .with(raiseMaybe); 289 | expect(raiseState2(42).runSync()).toEqual(just(42)); 290 | expect(raiseState2(21).runSync()).toEqual(nothing); 291 | 292 | const stateRaise = (init: number) => noOdds().with(pState(init)).with(raiseMaybe); 293 | expect(stateRaise(42).runSync()).toEqual(just([42, 21])); 294 | expect(stateRaise(21).runSync()).toEqual(nothing); 295 | }); 296 | 297 | test("3.4.8. Overriding Handlers", () => { 298 | const emitQuoted = defineHandlerFor().with((self) => 299 | self.resume("emit", (msg) => emit(`"${msg}"`)), 300 | ); 301 | 302 | const messages: string[] = []; 303 | effected(function* () { 304 | yield* emit("hello"); 305 | yield* emit("world"); 306 | }) 307 | .with(emitQuoted) 308 | .resume("emit", (msg) => { 309 | messages.push(msg); 310 | }) 311 | .runSync(); 312 | expect(messages).toEqual(['"hello"', '"world"']); 313 | }); 314 | -------------------------------------------------------------------------------- /test/effected.proof.ts: -------------------------------------------------------------------------------- 1 | import { beNever, describe, equal, expect, it, error as triggerError } from "typroof"; 2 | 3 | import { dependency, effect, effected, error } from "../src"; 4 | import { Effected } from "../src/effected"; 5 | import type { 6 | Default, 7 | Effect, 8 | EffectFactory, 9 | InferEffect, 10 | UnhandledEffect, 11 | Unresumable, 12 | } from "../src/types"; 13 | 14 | const add42 = effect("add42")<[n: number], number>; 15 | const now = effect("now")<[], Date>; 16 | const log = effect("log"); 17 | const raise = effect("raise", { resumable: false })<[error: unknown], never>; 18 | 19 | describe("effect", () => { 20 | it("should create a function that returns an `Effected` instance which yields a single `Effect`", () => { 21 | expect(add42).to(equal<(n: number) => Effected, number>>); 22 | expect(now).to(equal<() => Effected, Date>>); 23 | expect(log).to(equal<(...args: unknown[]) => Effected, void>>); 24 | }); 25 | 26 | it("should create unresumable effects", () => { 27 | expect(raise).to( 28 | equal< 29 | (error: unknown) => Effected>, never> 30 | >, 31 | ); 32 | }); 33 | 34 | it("should be inferred as an `Effect` type by the `InferEffect` utility type", () => { 35 | expect>().to(equal>); 36 | expect>().to(equal>); 37 | expect>().to(equal>); 38 | expect>().to( 39 | equal>>, 40 | ); 41 | }); 42 | }); 43 | 44 | const typeError = error("type"); 45 | 46 | describe("error", () => { 47 | it("should create a function that returns an `Effected` instance which yields a single `Effect.Error`", () => { 48 | expect(typeError).to( 49 | equal< 50 | ( 51 | message?: string, 52 | ) => Effected< 53 | Unresumable>, 54 | never 55 | > 56 | >, 57 | ); 58 | expect>().to( 59 | equal>>, 60 | ); 61 | }); 62 | 63 | it("should be inferred as an `Effect` type by the `InferEffect` utility type", () => { 64 | expect>().to(equal>); 65 | }); 66 | }); 67 | 68 | const askNumber = dependency("number"); 69 | 70 | describe("dependency", () => { 71 | it("should create a function that returns an `Effected` instance which yields a single `Effect.Dependency`", () => { 72 | expect(askNumber).to(equal<() => Effected, number>>); 73 | }); 74 | 75 | it("should be inferred as an `Effect` type by the `InferEffect` utility type", () => { 76 | expect>().to(equal>); 77 | }); 78 | }); 79 | 80 | describe("effected", () => { 81 | const program = effected(function* () { 82 | const n = yield* add42(42); 83 | const time = yield* now(); 84 | yield* log("n:", n); 85 | return time; 86 | }); 87 | const program2 = program.resume("log", console.log); 88 | const program3 = program2.resume("add42", (n) => n + 42); 89 | const program4 = program3.resume("now", () => new Date()); 90 | 91 | it("should create an `Effected` object with the correct type", () => { 92 | expect(program).to( 93 | equal< 94 | Effected< 95 | | Effect<"add42", [n: number], number> 96 | | Effect<"now", [], Date> 97 | | Effect<"log", unknown[], void>, 98 | Date 99 | > 100 | >, 101 | ); 102 | }); 103 | 104 | it("should exclude handled effects from the type", () => { 105 | expect(program2).to( 106 | equal | Effect<"now", [], Date>, Date>>, 107 | ); 108 | expect(program3).to(equal, Date>>); 109 | expect(program4).to(equal>); 110 | }); 111 | 112 | it("should only be runnable after all effects are handled", () => { 113 | // @ts-expect-error 114 | expect(program.runSync()).to(triggerError); 115 | // @ts-expect-error 116 | expect(program.runAsync()).to(triggerError); 117 | // @ts-expect-error 118 | expect(program3.runSync()).to(triggerError); 119 | // @ts-expect-error 120 | expect(program3.runAsync()).to(triggerError); 121 | expect(program4.runSync()).not.to(triggerError); 122 | expect(program4.runAsync()).not.to(triggerError); 123 | 124 | expect(program.runSync).to( 125 | equal< 126 | UnhandledEffect< 127 | | Effect<"add42", [n: number], number> 128 | | Effect<"now", [], Date> 129 | | Effect<"log", unknown[], void> 130 | > 131 | >, 132 | ); 133 | expect(program.runAsync).to( 134 | equal< 135 | UnhandledEffect< 136 | | Effect<"add42", [n: number], number> 137 | | Effect<"now", [], Date> 138 | | Effect<"log", unknown[], void> 139 | > 140 | >, 141 | ); 142 | 143 | expect(program2.runSync).to( 144 | equal | Effect<"now", [], Date>>>, 145 | ); 146 | expect(program2.runAsync).to( 147 | equal | Effect<"now", [], Date>>>, 148 | ); 149 | 150 | expect(program3.runSync).to(equal>>); 151 | expect(program3.runAsync).to(equal>>); 152 | 153 | expect(program4.runSync).to(equal<() => Date>); 154 | expect(program4.runAsync).to(equal<() => Promise>); 155 | }); 156 | 157 | it("should always be runnable by `#runSyncUnsafe` and `#runAsyncUnsafe`", () => { 158 | expect(program.runSyncUnsafe()).not.to(triggerError); 159 | expect(program.runSyncUnsafe()).to(equal); 160 | expect(program.runAsyncUnsafe()).to(equal>); 161 | expect(program2.runSyncUnsafe()).not.to(triggerError); 162 | expect(program2.runSyncUnsafe()).to(equal); 163 | expect(program2.runAsyncUnsafe()).to(equal>); 164 | expect(program3.runSyncUnsafe()).not.to(triggerError); 165 | expect(program3.runSyncUnsafe()).to(equal); 166 | expect(program3.runAsyncUnsafe()).to(equal>); 167 | expect(program4.runSyncUnsafe()).not.to(triggerError); 168 | expect(program4.runSyncUnsafe()).to(equal); 169 | expect(program4.runAsyncUnsafe()).to(equal>); 170 | }); 171 | 172 | it("should be inferred as an `Effect` type by the `InferEffect` utility type", () => { 173 | expect>().to( 174 | equal< 175 | | Effect<"add42", [n: number], number> 176 | | Effect<"now", [], Date> 177 | | Effect<"log", unknown[], void> 178 | >, 179 | ); 180 | expect>().to( 181 | equal | Effect<"now", [], Date>>, 182 | ); 183 | expect>().to(equal>); 184 | expect>().to(beNever); 185 | }); 186 | }); 187 | 188 | describe("Default handlers", () => { 189 | it("should correctly infer return types from default handlers", () => { 190 | type Result = { success: boolean; value?: T; error?: string }; 191 | 192 | const computeValue = effect<[n: number], Result>()("computeValue", { 193 | defaultHandler: ({ resume }, n) => { 194 | if (n < 0) resume({ success: false, error: "Input must be positive" }); 195 | else resume({ success: true, value: n * 2 }); 196 | }, 197 | }); 198 | 199 | expect(computeValue).to( 200 | equal< 201 | ( 202 | n: number, 203 | ) => Effected>>, Result> 204 | >, 205 | ); 206 | 207 | const program = effected(function* () { 208 | return yield* computeValue(10); 209 | }); 210 | expect(program).to( 211 | equal>>, Result>>, 212 | ); 213 | expect(computeValue(10)).to( 214 | equal>>, Result>>, 215 | ); 216 | }); 217 | 218 | it("should work with the Default type for type annotations", () => { 219 | type LogDefault = Default>; 220 | 221 | const log: EffectFactory = effect("log", { 222 | defaultHandler: ({ resume }, message) => { 223 | console.log(message); 224 | resume(); 225 | }, 226 | }); 227 | 228 | const program = effected(function* () { 229 | yield* log("Test message"); 230 | return "Done"; 231 | }); 232 | expect(program).to(equal>, string>>); 233 | expect(log("Test message")).to( 234 | equal>, void>>, 235 | ); 236 | }); 237 | 238 | it("should allow specifying additional effects in default handlers via Default type", () => { 239 | const innerLog = effect("innerLog")<[message: string], void>; 240 | 241 | type ComplexDefault = Default< 242 | Effect<"complex", [value: number], string>, 243 | never, // Terminate type 244 | Effect<"innerLog", [message: string], void> // Additional effects 245 | >; 246 | 247 | const complexEffect: EffectFactory = effect("complex", { 248 | *defaultHandler({ resume }, value) { 249 | yield* innerLog(`Processing value: ${value}`); 250 | resume(`Result: ${value * 2}`); 251 | }, 252 | }); 253 | 254 | const program = effected(function* () { 255 | const result = yield* complexEffect(21); 256 | return result; 257 | }).resume("innerLog", (_message) => {}); 258 | 259 | expect(program).to( 260 | equal>, string>>, 261 | ); 262 | expect(program.runSync()).to(equal); 263 | }); 264 | }); 265 | 266 | describe("Effected.all(Seq)", () => { 267 | const log = effect("log")<[message: string], void>; 268 | const fetch = effect("fetch")<[url: string], string>; 269 | 270 | it("should infer the return type of array of effects", () => { 271 | const program1 = Effected.all([Effected.of(1), Effected.of(2), Effected.of(3)]); 272 | expect(program1.runSync()).to(equal<[number, number, number]>); 273 | 274 | const program2 = Effected.all([ 275 | effected(function* () { 276 | yield* log("first"); 277 | return 1; 278 | }), 279 | effected(function* () { 280 | yield* log("second"); 281 | return 2; 282 | }), 283 | Effected.of(3), 284 | ]); 285 | expect(program2.resume("log", () => {}).runSync()).to(equal<[number, number, number]>); 286 | }); 287 | 288 | it("should infer the return type of non-array iterable of effects", () => { 289 | const set = new Set>(); 290 | const program1 = Effected.all(set); 291 | expect(program1.runSync()).to(equal); 292 | 293 | // Custom iterable 294 | const customIterable = { 295 | *[Symbol.iterator]() { 296 | yield Effected.of(42); 297 | yield Effected.of("foo"); 298 | yield Effected.of("bar"); 299 | }, 300 | }; 301 | const program2 = Effected.all(customIterable); 302 | expect(program2.runSync()).to(equal<(number | string)[]>); 303 | }); 304 | 305 | it("should infer the return type of object (record) of effects", () => { 306 | const program1 = Effected.all({ 307 | a: Effected.of(1), 308 | b: Effected.of(2), 309 | c: Effected.of(3), 310 | }); 311 | expect(program1.runSync()).to(equal<{ a: number; b: number; c: number }>); 312 | 313 | const program2 = Effected.all({ 314 | a: effected(function* () { 315 | yield* log("processing a"); 316 | return 1; 317 | }), 318 | b: effected(function* () { 319 | yield* log("processing b"); 320 | return 2; 321 | }), 322 | c: Effected.of("foobar"), 323 | }); 324 | expect(program2.resume("log", () => {}).runSync()).to( 325 | equal<{ a: number; b: number; c: string }>, 326 | ); 327 | }); 328 | 329 | it("should handle complex nested scenarios", async () => { 330 | const fetchData = (url: string) => 331 | effected(function* () { 332 | const data = yield* fetch(url); 333 | yield* log(`Fetched ${data} from ${url}`); 334 | return data; 335 | }); 336 | 337 | const urls = ["api/users", "api/posts", "api/comments"] as const; 338 | 339 | // Combination of array and object 340 | const program = Effected.all({ 341 | users: fetchData(urls[0]), 342 | posts: fetchData(urls[1]), 343 | metadata: Effected.allSeq([Effected.of("v1.0"), fetchData(urls[2])]), 344 | }); 345 | 346 | const result = await program 347 | .resume("fetch", (url) => `data from ${url}`) 348 | .resume("log", () => {}) 349 | .runAsync(); 350 | 351 | expect(result).to( 352 | equal<{ 353 | users: string; 354 | posts: string; 355 | metadata: [string, string]; 356 | }>, 357 | ); 358 | }); 359 | 360 | it("should infer the return type of empty arrays/objects", () => { 361 | expect(Effected.all([])).to(equal>); 362 | expect(Effected.all({})).to(equal>); 363 | }); 364 | }); 365 | 366 | describe("Effected#catchAndThrow", () => { 367 | type TypeError = Effect.Error<"type">; 368 | const typeError: EffectFactory = error("type"); 369 | type RangeError = Effect.Error<"range">; 370 | const rangeError: EffectFactory = error("range"); 371 | 372 | it("should exclude the specified error effect", () => { 373 | const program = effected(function* () { 374 | yield* typeError("foo"); 375 | yield* rangeError("bar"); 376 | }); 377 | 378 | expect(program.catchAndThrow("type")).not.to(triggerError); 379 | expect(program.catchAndThrow("type")).to(equal>); 380 | expect(program.catchAndThrow("range")).not.to(triggerError); 381 | expect(program.catchAndThrow("range")).to(equal>); 382 | expect(program.catchAndThrow("type").catchAndThrow("range")).not.to(triggerError); 383 | expect(program.catchAndThrow("type").catchAndThrow("range")).to(equal>); 384 | 385 | expect(program.catchAndThrow("type", "custom message")).not.to(triggerError); 386 | expect(program.catchAndThrow("type", "custom message")).to(equal>); 387 | expect(program.catchAndThrow("range", "custom message")).not.to(triggerError); 388 | expect(program.catchAndThrow("range", "custom message")).to(equal>); 389 | expect(program.catchAndThrow("type", "foo").catchAndThrow("range", "bar")).not.to(triggerError); 390 | expect(program.catchAndThrow("type", "foo").catchAndThrow("range", "bar")).to( 391 | equal>, 392 | ); 393 | 394 | program.catchAndThrow("type", (message) => { 395 | expect().to(equal); 396 | return ""; 397 | }); 398 | expect(program.catchAndThrow("type", (message) => `custom ${message}`)).not.to(triggerError); 399 | expect(program.catchAndThrow("type", (message) => `custom ${message}`)).to( 400 | equal>, 401 | ); 402 | }); 403 | }); 404 | 405 | describe("Effected#catchAllAndThrow", () => { 406 | type TypeError = Effect.Error<"type">; 407 | const typeError: EffectFactory = error("type"); 408 | type RangeError = Effect.Error<"range">; 409 | const rangeError: EffectFactory = error("range"); 410 | 411 | it("should exclude all error effects", () => { 412 | const program = effected(function* () { 413 | yield* typeError("foo"); 414 | yield* rangeError("bar"); 415 | }); 416 | 417 | expect(program.catchAllAndThrow()).not.to(triggerError); 418 | expect(program.catchAllAndThrow()).to(equal>); 419 | 420 | expect(program.catchAllAndThrow("custom message")).not.to(triggerError); 421 | expect(program.catchAllAndThrow("custom message")).to(equal>); 422 | 423 | program.catchAllAndThrow((error, message) => { 424 | expect().to(equal); 425 | expect().to(equal); 426 | return ""; 427 | }); 428 | expect(program.catchAllAndThrow((error, message) => `${error}${message}`)).not.to(triggerError); 429 | expect(program.catchAllAndThrow((error, message) => `${error}${message}`)).to( 430 | equal>, 431 | ); 432 | }); 433 | }); 434 | -------------------------------------------------------------------------------- /test/README.example.proof.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable sonarjs/no-identical-functions */ 2 | 3 | import { equal, expect, extend, test, error as triggerError } from "typroof"; 4 | 5 | import type { 6 | Default, 7 | Effect, 8 | EffectFactory, 9 | InferEffect, 10 | UnhandledEffect, 11 | Unresumable, 12 | } from "../src"; 13 | import { Effected, defineHandlerFor, dependency, effect, effected, effectify, error } from "../src"; 14 | 15 | type User = { id: number; name: string; role: "admin" | "user" }; 16 | 17 | test("banner", () => { 18 | const log = effect("log")<[msg: string], void>; 19 | const askUser = dependency("user"); 20 | const authError = error("auth"); 21 | 22 | const requiresAdmin = () => 23 | effected(function* () { 24 | const user = yield* askUser(); 25 | if (!user) return yield* authError("No user found"); 26 | if (user.role !== "admin") return yield* authError(`User ${user.name} is not an admin`); 27 | }); 28 | 29 | const fetchAdminData = () => 30 | effected(function* () { 31 | yield* requiresAdmin(); 32 | const data = yield* effectify( 33 | fetch("https://jsonplaceholder.typicode.com/todos/1").then((res) => res.json()), 34 | ); 35 | yield* log("Fetched data: " + JSON.stringify(data)); 36 | }); 37 | 38 | const program = fetchAdminData() 39 | .resume("log", (msg) => { 40 | console.log(msg); 41 | }) 42 | .provideBy("user", () => ({ id: 1, name: "Alice", role: "admin" }) satisfies User) 43 | .catch("auth", (err) => { 44 | console.error("Authorization error:", err); 45 | }); 46 | 47 | expect(program).to(equal>); 48 | }); 49 | 50 | test("Usage", () => { 51 | const println = effect("println"); 52 | const executeSQL = effect("executeSQL")<[sql: string, ...params: unknown[]], any>; 53 | const askCurrentUser = dependency("currentUser"); 54 | const authenticationError = error("authentication"); 55 | const unauthorizedError = error("unauthorized"); 56 | 57 | const requiresAdmin = () => 58 | effected(function* () { 59 | const currentUser = yield* askCurrentUser(); 60 | if (!currentUser) return yield* authenticationError(); 61 | if (currentUser.role !== "admin") 62 | return yield* unauthorizedError(`User "${currentUser.name}" is not an admin`); 63 | }); 64 | 65 | const createUser = (user: Omit) => 66 | effected(function* () { 67 | yield* requiresAdmin(); 68 | const id = yield* executeSQL("INSERT INTO users (name) VALUES (?)", user.name); 69 | const savedUser: User = { id, ...user }; 70 | yield* println("User created:", savedUser); 71 | return savedUser; 72 | }); 73 | 74 | expect(requiresAdmin).to( 75 | equal< 76 | () => Effected< 77 | | Unresumable> 78 | | Effect<"dependency:currentUser", [], User | null> 79 | | Unresumable>, 80 | undefined 81 | > 82 | >, 83 | ); 84 | 85 | expect(createUser).to( 86 | equal< 87 | ( 88 | user: Omit, 89 | ) => Effected< 90 | | Unresumable> 91 | | Effect<"dependency:currentUser", [], User | null> 92 | | Unresumable> 93 | | Effect<"executeSQL", [sql: string, ...params: unknown[]], any> 94 | | Effect<"println", unknown[], void>, 95 | User 96 | > 97 | >, 98 | ); 99 | 100 | const alice: Omit = { name: "Alice", role: "user" }; 101 | 102 | const handled1 = createUser(alice).handle("executeSQL", ({ resume }, sql, ...params) => { 103 | console.log(`Executing SQL: ${sql}`); 104 | console.log("Parameters:", params); 105 | resume(42); 106 | }); 107 | 108 | expect(handled1).to( 109 | equal< 110 | Effected< 111 | | Unresumable> 112 | | Effect<"dependency:currentUser", [], User | null> 113 | | Unresumable> 114 | | Effect<"println", unknown[], void>, 115 | User 116 | > 117 | >, 118 | ); 119 | 120 | const handled2 = createUser(alice) 121 | .handle("executeSQL", ({ resume }, sql, ...params) => { 122 | console.log(`Executing SQL: ${sql}`); 123 | console.log("Parameters:", params); 124 | resume(42); 125 | }) 126 | .handle("println", ({ resume }, ...args) => { 127 | console.log(...args); 128 | resume(); 129 | }) 130 | .handle("dependency:currentUser", ({ resume }) => { 131 | resume({ id: 1, name: "Charlie", role: "admin" }); 132 | }) 133 | .handle<"error:authentication", void>("error:authentication", ({ terminate }) => { 134 | console.error("Authentication error"); 135 | terminate(); 136 | }) 137 | .handle<"error:unauthorized", void>("error:unauthorized", ({ terminate }) => { 138 | console.error("Unauthorized error"); 139 | terminate(); 140 | }); 141 | 142 | expect(handled2).to(equal>); 143 | expect(handled2.runSync()).to(equal); 144 | 145 | const handled3 = createUser(alice) 146 | .handle("executeSQL", ({ resume }, sql, ...params) => { 147 | console.log(`Executing SQL: ${sql}`); 148 | console.log("Parameters:", params); 149 | resume(42); 150 | }) 151 | .handle<"error:authentication", void>("error:authentication", ({ terminate }) => { 152 | console.error("Authentication error"); 153 | terminate(); 154 | }) 155 | .handle<"error:unauthorized", void>("error:unauthorized", ({ terminate }) => { 156 | console.error("Unauthorized error"); 157 | terminate(); 158 | }); 159 | 160 | expect(handled3.runSync).to( 161 | equal< 162 | UnhandledEffect< 163 | Effect<"dependency:currentUser", [], User | null> | Effect<"println", unknown[], void> 164 | > 165 | >, 166 | ); 167 | 168 | const handled4 = createUser(alice) 169 | .resume("executeSQL", (sql, ...params) => { 170 | console.log(`Executing SQL: ${sql}`); 171 | console.log("Parameters:", params); 172 | return 42; 173 | }) 174 | .resume("println", (...args) => { 175 | console.log(...args); 176 | }) 177 | .provide("currentUser", { id: 1, name: "Charlie", role: "admin" }) 178 | .catch("authentication", () => { 179 | console.error("Authentication error"); 180 | }) 181 | .catch("unauthorized", () => { 182 | console.error("Unauthorized error"); 183 | }); 184 | 185 | expect(handled4).to(equal>); 186 | expect(handled4.runSync()).to(equal); 187 | 188 | const db = { 189 | user: { 190 | create: (_user: Omit) => 191 | new Promise((resolve) => setTimeout(() => resolve(42), 100)), 192 | }, 193 | }; 194 | 195 | const createUser2 = (user: Omit) => 196 | effected(function* () { 197 | yield* requiresAdmin(); 198 | const id = yield* effectify(db.user.create(user)); 199 | const savedUser = { id, ...user }; 200 | yield* println("User created:", savedUser); 201 | return savedUser; 202 | }); 203 | 204 | expect(createUser2).to( 205 | equal< 206 | ( 207 | user: Omit, 208 | ) => Effected< 209 | | Unresumable> 210 | | Effect<"dependency:currentUser", [], User | null> 211 | | Unresumable> 212 | | Effect<"println", unknown[], void>, 213 | User 214 | > 215 | >, 216 | ); 217 | }); 218 | 219 | test("The `Effect` type > Unresumable effects", () => { 220 | const raise = effect("raise", { resumable: false })<[error: unknown], never>; 221 | 222 | expect(raise).to( 223 | equal< 224 | (error: unknown) => Effected>, never> 225 | >, 226 | ); 227 | 228 | const program = effected(function* () { 229 | yield* raise(new Error("Something went wrong")); 230 | }); 231 | // @ts-expect-error 232 | expect(program.resume("raise", console.error)).to(triggerError); 233 | }); 234 | 235 | test("The `Effect` type > Provide more readable type information", () => { 236 | type Println = Effect<"println", unknown[], void>; 237 | const println: EffectFactory = effect("println"); 238 | type ExecuteSQL = Effect<"executeSQL", [sql: string, ...params: unknown[]], any>; 239 | const executeSQL: EffectFactory = effect("executeSQL"); 240 | type CurrentUserDependency = Effect.Dependency<"currentUser", User | null>; 241 | const askCurrentUser: EffectFactory = dependency( 242 | "currentUser", 243 | ); 244 | type AuthenticationError = Effect.Error<"authentication">; 245 | const authenticationError: EffectFactory = error("authentication"); 246 | type UnauthorizedError = Effect.Error<"unauthorized">; 247 | const unauthorizedError: EffectFactory = error("unauthorized"); 248 | 249 | const requiresAdmin = () => 250 | effected(function* () { 251 | const currentUser = yield* askCurrentUser(); 252 | if (!currentUser) return yield* authenticationError(); 253 | if (currentUser.role !== "admin") 254 | return yield* unauthorizedError(`User "${currentUser.name}" is not an admin`); 255 | }); 256 | 257 | const createUser = (user: Omit) => 258 | effected(function* () { 259 | yield* requiresAdmin(); 260 | const id = yield* executeSQL("INSERT INTO users (name) VALUES (?)", user.name); 261 | const savedUser: User = { id, ...user }; 262 | yield* println("User created:", savedUser); 263 | return savedUser; 264 | }); 265 | 266 | expect(createUser).to( 267 | equal< 268 | ( 269 | user: Omit, 270 | ) => Effected< 271 | | Unresumable 272 | | CurrentUserDependency 273 | | Unresumable 274 | | ExecuteSQL 275 | | Println, 276 | User 277 | > 278 | >, 279 | ); 280 | }); 281 | 282 | test("A deep dive into `resume` and `terminate`", () => { 283 | type Iterate = Effect<"iterate", [value: T], void>; 284 | const iterate = (value: T) => effect("iterate")<[value: T], void>(value); 285 | 286 | const iterateOver = (iterable: Iterable) => 287 | effected(function* () { 288 | for (const value of iterable) { 289 | yield* iterate(value); 290 | } 291 | }); 292 | 293 | expect(iterateOver).to(equal<(iterable: Iterable) => Effected, void>>); 294 | 295 | let i = 0; 296 | const program = iterateOver([1, 2, 3, 4, 5]).handle("iterate", ({ resume, terminate }, value) => { 297 | if (i++ >= 3) { 298 | // Too many iterations 299 | terminate(); 300 | return; 301 | } 302 | console.log("Iterating over", value); 303 | resume(); 304 | }); 305 | 306 | expect(program).to(equal>); 307 | }); 308 | 309 | test("Handling effects with another effected program", () => { 310 | { 311 | type Ask = Effect<"ask", [], T>; 312 | const ask = (): Effected, T> => effect("ask")(); 313 | 314 | const double = (): Effected, number> => 315 | effected(function* () { 316 | return (yield* ask()) + (yield* ask()); 317 | }); 318 | 319 | type Random = Effect<"random", [], number>; 320 | const random: EffectFactory = effect("random"); 321 | 322 | const program = effected(function* () { 323 | return yield* double(); 324 | }).resume("ask", function* () { 325 | return yield* random(); 326 | }); 327 | 328 | expect(program).to(equal>); 329 | } 330 | 331 | { 332 | type Emit = Effect<"emit", [msg: string], void>; 333 | const emit: EffectFactory = effect("emit"); 334 | 335 | const program1 = effected(function* () { 336 | yield* emit("hello"); 337 | yield* emit("world"); 338 | }).resume("emit", (msg) => emit(`"${msg}"`)); 339 | 340 | expect(program1).to(equal>); 341 | 342 | const program2 = program1.resume("emit", (...args) => console.log(...args)); 343 | 344 | expect(program2).to(equal>); 345 | } 346 | }); 347 | 348 | test("Default handlers", () => { 349 | const println = effect()("println", { 350 | defaultHandler: ({ resume }, ...args) => { 351 | console.log(...args); 352 | resume(); 353 | }, 354 | }); 355 | 356 | { 357 | const program = effected(function* () { 358 | yield* println("Hello, world!"); 359 | }); 360 | expect(program).to(equal>, void>>); 361 | 362 | expect(println("Hello, world!")).to( 363 | equal>, void>>, 364 | ); 365 | } 366 | 367 | { 368 | const program = effected(function* () { 369 | yield* println("Hello, world!"); 370 | }).resume("println", () => { 371 | console.log("This will be logged instead of the default handler"); 372 | }); 373 | expect(program).to(equal>); 374 | 375 | expect( 376 | println("Hello, world!").resume("println", () => { 377 | console.log("This will be logged instead of the default handler"); 378 | }), 379 | ).to(equal>); 380 | } 381 | 382 | { 383 | type User = { id: number; name: string; role: "admin" | "user" }; 384 | 385 | const askCurrentUser = dependency()("currentUser", () => ({ 386 | id: 1, 387 | name: "Charlie", 388 | role: "admin", 389 | })); 390 | 391 | const program = effected(function* () { 392 | const user = yield* askCurrentUser(); 393 | yield* println("Current user:", user); 394 | }); 395 | expect(program).to( 396 | equal< 397 | Effected< 398 | | Default> 399 | | Default>, 400 | void 401 | > 402 | >, 403 | ); 404 | 405 | expect(program.provide("currentUser", { id: 2, name: "Alice", role: "user" })).to( 406 | equal>, void>>, 407 | ); 408 | } 409 | }); 410 | 411 | test("Handling return values", () => { 412 | { 413 | type Raise = Unresumable>; 414 | const raise: EffectFactory = effect("raise", { resumable: false }); 415 | 416 | const safeDivide = (a: number, b: number) => 417 | effected(function* () { 418 | if (b === 0) return yield* raise("Division by zero"); 419 | return a / b; 420 | }); 421 | 422 | expect(safeDivide).to(equal<(a: number, b: number) => Effected>); 423 | 424 | type Option = { kind: "some"; value: T } | { kind: "none" }; 425 | 426 | const some = (value: T): Option => ({ kind: "some", value }); 427 | const none: Option = { kind: "none" }; 428 | 429 | const safeDivide2 = (a: number, b: number) => 430 | safeDivide(a, b) 431 | .andThen((value) => some(value)) 432 | .terminate("raise", () => none); 433 | 434 | expect(safeDivide2).to(equal<(a: number, b: number) => Effected>>); 435 | } 436 | 437 | { 438 | type Defer = Effect<"defer", [fn: () => void], void>; 439 | const defer: EffectFactory = effect("defer"); 440 | 441 | const deferHandler = defineHandlerFor().with((self) => { 442 | const deferredActions: (() => void)[] = []; 443 | 444 | return self 445 | .resume("defer", (fn) => { 446 | deferredActions.push(fn); 447 | }) 448 | .tap(() => { 449 | deferredActions.forEach((fn) => fn()); 450 | }); 451 | }); 452 | 453 | const program = effected(function* () { 454 | yield* defer(() => console.log("Deferred action")); 455 | console.log("Normal action"); 456 | }).with(deferHandler); 457 | 458 | expect(program).to(equal>); 459 | } 460 | }); 461 | 462 | test("Handling multiple effects in one handler", () => { 463 | { 464 | type Logging = 465 | | Effect<"logging:log", unknown[], void> 466 | | Effect<"logging:warn", unknown[], void> 467 | | Effect<"logging:error", unknown[], void>; 468 | const logger = { 469 | log: effect("logging:log"), 470 | warn: effect("logging:warn"), 471 | error: effect("logging:error"), 472 | }; 473 | 474 | type ReadFile = Effect<"readFile", [path: string], string>; 475 | const readFile: EffectFactory = effect("readFile"); 476 | 477 | interface Settings { 478 | something: string; 479 | } 480 | 481 | const defaultSettings: Settings = { 482 | something: "foo", 483 | }; 484 | 485 | const readSettings = (path: string): Effected => 486 | effected(function* () { 487 | const content = yield* readFile(path); 488 | try { 489 | const settings = JSON.parse(content); 490 | yield* logger.log("Settings loaded"); 491 | return settings; 492 | } catch (e) { 493 | yield* logger.error("Failed to parse settings file:", e); 494 | return defaultSettings; 495 | } 496 | }); 497 | 498 | const readSettingsWithoutLogging = (path: string) => 499 | readSettings(path).resume( 500 | (name): name is Logging["name"] => name.startsWith("logging:"), 501 | () => { 502 | // Omit logging 503 | }, 504 | ); 505 | 506 | expect(readSettingsWithoutLogging).to(equal<(path: string) => Effected>); 507 | } 508 | 509 | { 510 | type Result = { kind: "ok"; value: T } | { kind: "err"; error: E }; 511 | 512 | const ok = (value: T): Result => ({ kind: "ok", value }); 513 | const err = (error: E): Result => ({ kind: "err", error }); 514 | 515 | type TypeError = Effect.Error<"type">; 516 | const typeError: EffectFactory = error("type"); 517 | type RangeError = Effect.Error<"range">; 518 | const rangeError: EffectFactory = error("range"); 519 | 520 | type Log = Effect<"println", unknown[], void>; 521 | const log: EffectFactory = effect("println"); 522 | 523 | const range = (start: number, stop: number): Effected => 524 | effected(function* () { 525 | if (start >= stop) return yield* rangeError("Start must be less than stop"); 526 | if (!Number.isInteger(start) || !Number.isInteger(stop)) 527 | return yield* typeError("Start and stop must be integers"); 528 | yield* log(`Generating range from ${start} to ${stop}`); 529 | return Array.from({ length: stop - start }, (_, i) => start + i); 530 | }); 531 | 532 | const handleErrorAsResult = ( 533 | self: Effected | E, R>, 534 | ): Effected> => { 535 | const isErrorEffect = (name: string | symbol): name is `error:${ErrorName}` => { 536 | if (typeof name === "symbol") return false; 537 | return name.startsWith("error:"); 538 | }; 539 | 540 | return self 541 | .andThen((value) => ok(value)) 542 | .handle(isErrorEffect, (({ effect, terminate }: any, message: any) => { 543 | terminate(err({ error: effect.name.slice("error:".length), message })); 544 | }) as never) as Effected>; 545 | }; 546 | 547 | const range2 = (start: number, stop: number) => handleErrorAsResult(range(start, stop)); 548 | 549 | expect(range2).to( 550 | equal< 551 | ( 552 | start: number, 553 | stop: number, 554 | ) => Effected> 555 | >, 556 | ); 557 | 558 | const range3 = (start: number, stop: number) => range(start, stop).with(handleErrorAsResult); 559 | 560 | expect(range3).to(equal(range2)); 561 | 562 | const range4 = (start: number, stop: number) => 563 | range(start, stop) 564 | .andThen((value) => ok(value)) 565 | .catchAll((error, ...args) => 566 | err({ error, ...(args.length === 0 ? {} : { message: args[0] }) }), 567 | ); 568 | 569 | expect>().to(equal); 570 | expect(range4).to(extend(range3)); 571 | } 572 | }); 573 | 574 | test("Handling error effects", () => { 575 | { 576 | type SyntaxError = Effect.Error<"syntax">; 577 | const syntaxError: EffectFactory = error("syntax"); 578 | type Raise = Unresumable>; 579 | const raise: EffectFactory = effect("raise", { resumable: false }); 580 | 581 | const parseJSON = (json: string): Effected => 582 | effected(function* () { 583 | try { 584 | return JSON.parse(json); 585 | } catch (e) { 586 | if (e instanceof SyntaxError) return yield* syntaxError(e.message); 587 | return yield* raise(e); 588 | } 589 | }); 590 | 591 | interface Settings { 592 | something: string; 593 | } 594 | 595 | const defaultSettings: Settings = { 596 | something: "foo", 597 | }; 598 | 599 | const readSettings = (json: string) => 600 | effected(function* () { 601 | const settings = yield* parseJSON(json).catch("syntax", (message) => { 602 | console.error(`Invalid JSON: ${message}`); 603 | return defaultSettings; 604 | }); 605 | expect(settings).to(equal); 606 | /* ... */ 607 | }); 608 | 609 | expect(readSettings).to(equal<(json: string) => Effected>); 610 | } 611 | 612 | { 613 | type TypeError = Effect.Error<"type">; 614 | const typeError: EffectFactory = error("type"); 615 | type RangeError = Effect.Error<"range">; 616 | const rangeError: EffectFactory = error("range"); 617 | 618 | type Log = Effect<"log", unknown[], void>; 619 | const log: EffectFactory = effect("log"); 620 | 621 | const range = (start: number, stop: number): Effected => 622 | effected(function* () { 623 | if (start >= stop) return yield* rangeError("Start must be less than stop"); 624 | if (!Number.isInteger(start) || !Number.isInteger(stop)) 625 | return yield* typeError("Start and stop must be integers"); 626 | yield* log(`Generating range from ${start} to ${stop}`); 627 | return Array.from({ length: stop - start }, (_, i) => start + i); 628 | }); 629 | 630 | const tolerantRange = (start: number, stop: number) => 631 | range(start, stop).catchAll((error, message) => { 632 | console.warn(`Error(${error}): ${message || ""}`); 633 | return [] as number[]; 634 | }); 635 | 636 | expect(tolerantRange).to(equal<(start: number, stop: number) => Effected>); 637 | 638 | const range2 = (start: number, stop: number) => range(start, stop).catchAndThrow("type"); 639 | 640 | expect(range2).to(equal<(start: number, stop: number) => Effected>); 641 | 642 | const range3 = (start: number, stop: number) => 643 | range(start, stop).catchAndThrow("type", "Invalid start or stop value"); 644 | 645 | expect(range3).to(equal<(start: number, stop: number) => Effected>); 646 | 647 | const range4 = (start: number, stop: number) => 648 | range(start, stop).catchAndThrow("range", (message) => `Invalid range: ${message}`); 649 | 650 | expect(range4).to(equal<(start: number, stop: number) => Effected>); 651 | 652 | const range5 = (start: number, stop: number) => range(start, stop).catchAllAndThrow(); 653 | 654 | expect(range5).to(equal<(start: number, stop: number) => Effected>); 655 | 656 | const range6 = (start: number, stop: number) => 657 | range(start, stop).catchAllAndThrow("An error occurred while generating the range"); 658 | 659 | expect(range6).to(equal<(start: number, stop: number) => Effected>); 660 | 661 | const range7 = (start: number, stop: number) => 662 | range(start, stop).catchAllAndThrow((error, message) => `Error(${error}): ${message}`); 663 | 664 | expect(range7).to(equal<(start: number, stop: number) => Effected>); 665 | } 666 | }); 667 | 668 | test("Abstracting handlers", () => { 669 | { 670 | type State = Effect<"state.get", [], T> | Effect<"state.set", [value: T], void>; 671 | const state = { 672 | get: (): Effected, T> => effect("state.get")<[], T>(), 673 | set: (value: T): Effected, void> => effect("state.set")<[value: T], void>(value), 674 | }; 675 | 676 | const sumDown = (sum = 0): Effected, number> => 677 | effected(function* () { 678 | const n = yield* state.get(); 679 | if (n <= 0) return sum; 680 | yield* state.set(n - 1); 681 | return yield* sumDown(sum + n); 682 | }); 683 | 684 | const stateHandler = ({ get, set }: { get: () => T; set: (x: T) => void }) => 685 | defineHandlerFor>().with((self) => 686 | self.resume("state.get", get).resume("state.set", set), 687 | ); 688 | 689 | let n = 10; 690 | const handler = stateHandler({ get: () => n, set: (x) => (n = x) }); 691 | 692 | expect(sumDown().with(handler)).not.to(triggerError); 693 | expect(sumDown().with(handler)).to(equal>); 694 | } 695 | 696 | { 697 | type Raise = Unresumable>; 698 | const raise: EffectFactory = effect("raise", { resumable: false }); 699 | 700 | const safeDivide = (a: number, b: number): Effected => 701 | effected(function* () { 702 | if (b === 0) return yield* raise("Division by zero"); 703 | return a / b; 704 | }); 705 | 706 | type Option = { kind: "some"; value: T } | { kind: "none" }; 707 | 708 | const some = (value: T): Option => ({ kind: "some", value }); 709 | const none: Option = { kind: "none" }; 710 | 711 | const raiseOption = defineHandlerFor().with((self) => 712 | self.andThen((value) => some(value)).terminate("raise", () => none), 713 | ); 714 | 715 | const safeDivide2 = (a: number, b: number) => safeDivide(a, b).with(raiseOption); 716 | 717 | expect(safeDivide2).to(equal<(a: number, b: number) => Effected>>); 718 | } 719 | }); 720 | 721 | test("Parallel execution with `Effected.all`", () => { 722 | { 723 | const log = effect("log")<[msg: string], void>; 724 | const httpGet = effect("httpGet")<[url: string], any>; 725 | 726 | const fetchUserData = (userId: number) => 727 | effected(function* () { 728 | yield* log(`Fetching user ${userId}`); 729 | const data = yield* httpGet(`/api/users/${userId}`); 730 | return data; 731 | }); 732 | 733 | expect(Effected.allSeq([fetchUserData(1), fetchUserData(2), fetchUserData(3)])).to( 734 | equal< 735 | Effected< 736 | Effect<"httpGet", [url: string], any> | Effect<"log", [msg: string], void>, 737 | [any, any, any] 738 | > 739 | >, 740 | ); 741 | expect(Effected.all([fetchUserData(1), fetchUserData(2), fetchUserData(3)])).to( 742 | equal< 743 | Effected< 744 | Effect<"httpGet", [url: string], any> | Effect<"log", [msg: string], void>, 745 | [any, any, any] 746 | > 747 | >, 748 | ); 749 | } 750 | 751 | { 752 | const fetchUser = (userId: number): Effected => 753 | Effected.of({ id: userId, name: "John Doe", role: "user" }); 754 | const fetchUserPosts = (_userId: number): Effected => Effected.of([]); 755 | const fetchUserSettings = (_userId: number): Effected => Effected.of({}); 756 | 757 | expect(Effected.all([fetchUser(1), fetchUser(2), fetchUser(3)]).runAsync()).to( 758 | equal>, 759 | ); 760 | 761 | expect( 762 | Effected.all({ 763 | user: fetchUser(1), 764 | posts: fetchUserPosts(1), 765 | settings: fetchUserSettings(1), 766 | }).runAsync(), 767 | ).to(equal>); 768 | } 769 | 770 | { 771 | const compute = effect("compute")<[label: string, delay: number], number>; 772 | const calculate = effect("calculate")<[a: number, b: number], number>; 773 | 774 | const program = effected(function* () { 775 | const results = yield* Effected.all([ 776 | // Sync task 777 | calculate(10, 5), 778 | // Fast async task 779 | compute("fast task", 50), 780 | // Slow async task 781 | compute("slow task", 150), 782 | ]); 783 | console.log("Results:", results); 784 | }) 785 | .resume("calculate", (a, b) => a + b) 786 | .handle("compute", ({ resume }, label, delay) => { 787 | console.log(`Starting ${label}`); 788 | setTimeout(() => { 789 | console.log(`Completed ${label}`); 790 | resume(delay); 791 | }, delay); 792 | }); 793 | 794 | expect(program).to(equal>); 795 | } 796 | }); 797 | 798 | test("Effects without generators", () => { 799 | { 800 | const println = effect("println"); 801 | const executeSQL = effect("executeSQL")<[sql: string, ...params: unknown[]], any>; 802 | const askCurrentUser = dependency("currentUser"); 803 | const authenticationError = error("authentication"); 804 | const unauthorizedError = error("unauthorized"); 805 | 806 | const requiresAdmin = () => 807 | effected(function* () { 808 | const currentUser = yield* askCurrentUser(); 809 | if (!currentUser) return yield* authenticationError(); 810 | if (currentUser.role !== "admin") 811 | return yield* unauthorizedError(`User "${currentUser.name}" is not an admin`); 812 | }); 813 | 814 | const createUser1 = (user: Omit) => 815 | requiresAdmin() 816 | .andThen(() => 817 | executeSQL("INSERT INTO users (name) VALUES (?)", user.name).andThen( 818 | (id) => ({ id, ...user }) as User, 819 | ), 820 | ) 821 | .tap((savedUser) => println("User created:", savedUser)); 822 | 823 | expect(createUser1).to( 824 | equal< 825 | ( 826 | user: Omit, 827 | ) => Effected< 828 | | Unresumable> 829 | | Effect<"dependency:currentUser", [], User | null> 830 | | Unresumable> 831 | | Effect<"executeSQL", [sql: string, ...params: unknown[]], any> 832 | | Effect<"println", unknown[], void>, 833 | User 834 | > 835 | >, 836 | ); 837 | 838 | const createUser2 = (user: Omit) => 839 | requiresAdmin() 840 | .flatMap(() => 841 | executeSQL("INSERT INTO users (name) VALUES (?)", user.name).map( 842 | (id) => ({ id, ...user }) as User, 843 | ), 844 | ) 845 | .tap((savedUser) => println("User created:", savedUser)); 846 | 847 | expect(createUser2).to( 848 | equal< 849 | ( 850 | user: Omit, 851 | ) => Effected< 852 | | Unresumable> 853 | | Effect<"dependency:currentUser", [], User | null> 854 | | Unresumable> 855 | | Effect<"executeSQL", [sql: string, ...params: unknown[]], any> 856 | | Effect<"println", unknown[], void>, 857 | User 858 | > 859 | >, 860 | ); 861 | } 862 | 863 | { 864 | const println = effect("println"); 865 | 866 | const program1 = Effected.of("Hello, world!") 867 | .tap((message) => println(message)) 868 | .andThen((message) => message.toUpperCase()); 869 | 870 | expect(program1).to(equal, string>>); 871 | 872 | const program2 = Effected.from(() => { 873 | console.log("Computing value..."); 874 | // eslint-disable-next-line sonarjs/pseudo-random 875 | return Math.random() * 100; 876 | }).andThen((value) => println(`Random value: ${value}`)); 877 | 878 | expect(program2).to(equal, void>>); 879 | } 880 | 881 | { 882 | expect(Effected.of(42).as("Hello, world!")).to(equal>); 883 | expect(Effected.of(42).asVoid()).to(equal>); 884 | } 885 | 886 | { 887 | type User = { id: number; name: string; role: "admin" | "user" }; 888 | const askCurrentUser = dependency("currentUser"); 889 | 890 | const getUserName = askCurrentUser().map((user) => user?.name || "Guest"); 891 | const askTheme = dependency("theme")<"light" | "dark">; 892 | 893 | expect(getUserName.zip(askTheme())).to( 894 | equal< 895 | Effected< 896 | | Effect<"dependency:currentUser", [], User | null> 897 | | Effect<"dependency:theme", [], "light" | "dark">, 898 | [string, "light" | "dark"] 899 | > 900 | >, 901 | ); 902 | 903 | const welcomeMessage1 = getUserName 904 | .zip(askTheme()) 905 | .map(([username, theme]) => `Welcome ${username}! Using ${theme} theme.`); 906 | expect(welcomeMessage1).to( 907 | equal< 908 | Effected< 909 | | Effect<"dependency:currentUser", [], User | null> 910 | | Effect<"dependency:theme", [], "light" | "dark">, 911 | string 912 | > 913 | >, 914 | ); 915 | 916 | const welcomeMessage2 = getUserName.zip( 917 | askTheme(), 918 | (username, theme) => `Welcome ${username}! Using ${theme} theme.`, 919 | ); 920 | expect(welcomeMessage2).to( 921 | equal< 922 | Effected< 923 | | Effect<"dependency:currentUser", [], User | null> 924 | | Effect<"dependency:theme", [], "light" | "dark">, 925 | string 926 | > 927 | >, 928 | ); 929 | } 930 | 931 | { 932 | type Sleep = Default>; 933 | const sleep: EffectFactory = effect("sleep", { 934 | defaultHandler: ({ resume }, ms) => { 935 | setTimeout(resume, ms); 936 | }, 937 | }); 938 | 939 | const delay = 940 | (ms: number) => 941 | (self: Effected): Effected => 942 | sleep(ms).andThen(() => self); 943 | 944 | const withLog = 945 | (message: string) => 946 | (self: Effected): Effected => 947 | self.tap((value) => { 948 | console.log(`${message}: ${String(value)}`); 949 | }); 950 | 951 | expect(Effected.of(42).pipe(delay(100))).to(equal>); 952 | 953 | expect(Effected.of(42).pipe(delay(100), withLog("Result"))).to(equal>); 954 | } 955 | }); 956 | 957 | test("Pipeline Syntax V.S. Generator Syntax", () => { 958 | { 959 | const fetchUser = effect("fetchUser")<[userId: number], User | null>; 960 | const fetchPosts = effect("fetchPosts")<[userId: number], any[]>; 961 | 962 | const getUserPosts1 = (userId: number) => 963 | effected(function* () { 964 | const user = yield* fetchUser(userId); 965 | if (!user) return null; 966 | return yield* fetchPosts(user.id); 967 | }); 968 | 969 | expect(getUserPosts1).to( 970 | equal< 971 | ( 972 | userId: number, 973 | ) => Effected< 974 | | Effect<"fetchUser", [userId: number], User | null> 975 | | Effect<"fetchPosts", [userId: number], any[]>, 976 | any[] | null 977 | > 978 | >, 979 | ); 980 | 981 | const getUserPosts2 = (userId: number) => 982 | fetchUser(userId).andThen((user) => { 983 | if (!user) return null; 984 | return fetchPosts(user.id); 985 | }); 986 | 987 | expect(getUserPosts2).to( 988 | equal< 989 | ( 990 | userId: number, 991 | ) => Effected< 992 | | Effect<"fetchUser", [userId: number], User | null> 993 | | Effect<"fetchPosts", [userId: number], any[]>, 994 | any[] | null 995 | > 996 | >, 997 | ); 998 | } 999 | 1000 | { 1001 | const logger = { 1002 | error: effect("logger:error"), 1003 | }; 1004 | const readFile = effect("readFile")<[path: string], string>; 1005 | const parseError = error("parse"); 1006 | const parseContent = (content: string) => 1007 | effected(function* () { 1008 | try { 1009 | return JSON.parse(content); 1010 | } catch (e) { 1011 | return yield* parseError((e as any).message); 1012 | } 1013 | }); 1014 | 1015 | const processFile1 = (path: string) => 1016 | effected(function* () { 1017 | const content = yield* readFile(path); 1018 | return yield* parseContent(content); 1019 | }).catchAll(function* (error, message) { 1020 | yield* logger.error(`[${error}Error] Error processing ${path}:`, message); 1021 | return null; 1022 | }); 1023 | 1024 | expect(processFile1).to( 1025 | equal< 1026 | ( 1027 | path: string, 1028 | ) => Effected< 1029 | Effect<"readFile", [path: string], string> | Effect<"logger:error", unknown[], void>, 1030 | any 1031 | > 1032 | >, 1033 | ); 1034 | 1035 | const processFile2 = (path: string) => 1036 | readFile(path) 1037 | .andThen((content) => parseContent(content)) 1038 | .catchAll((error, message) => 1039 | logger.error(`[${error}Error] Error processing ${path}:`, message).as(null), 1040 | ); 1041 | 1042 | expect(processFile2).to( 1043 | equal< 1044 | ( 1045 | path: string, 1046 | ) => Effected< 1047 | Effect<"readFile", [path: string], string> | Effect<"logger:error", unknown[], void>, 1048 | any 1049 | > 1050 | >, 1051 | ); 1052 | } 1053 | 1054 | { 1055 | type Order = { id: number; items: any[] }; 1056 | const askConfig = dependency("config")<{ apiUrl: string }>; 1057 | const askCurrentUser = dependency("currentUser"); 1058 | const validateOrder = (_order: Order, _user: User) => Effected.of(true); 1059 | const saveOrder = (_order: Order, _apiUrl: string) => Effected.of({ id: 1, items: [] }); 1060 | const sendNotification = (_email: string, _message: string) => Effected.of(true); 1061 | 1062 | const submitOrder1 = (order: Order) => 1063 | effected(function* () { 1064 | const [config, user] = yield* Effected.all([askConfig(), askCurrentUser()]); 1065 | yield* validateOrder(order, user); 1066 | const result = yield* saveOrder(order, config.apiUrl); 1067 | yield* sendNotification(user.name, "Order submitted"); 1068 | return result; 1069 | }); 1070 | 1071 | expect(submitOrder1).to( 1072 | equal< 1073 | ( 1074 | order: Order, 1075 | ) => Effected< 1076 | | Effect<"dependency:config", [], { apiUrl: string }> 1077 | | Effect<"dependency:currentUser", [], User>, 1078 | Order 1079 | > 1080 | >, 1081 | ); 1082 | 1083 | const submitOrder2 = (order: Order) => 1084 | Effected.all([askConfig(), askCurrentUser()]).andThen(([config, user]) => 1085 | validateOrder(order, user).andThen(() => 1086 | saveOrder(order, config.apiUrl).tap(() => 1087 | sendNotification(user.name, "Order submitted").asVoid(), 1088 | ), 1089 | ), 1090 | ); 1091 | 1092 | expect(submitOrder2).to( 1093 | equal< 1094 | ( 1095 | order: Order, 1096 | ) => Effected< 1097 | | Effect<"dependency:config", [], { apiUrl: string }> 1098 | | Effect<"dependency:currentUser", [], User>, 1099 | Order 1100 | > 1101 | >, 1102 | ); 1103 | } 1104 | }); 1105 | 1106 | test("Example: Build a configurable logging system with effects", () => { 1107 | type Sleep = Default>; 1108 | const sleep: EffectFactory = effect("sleep", { 1109 | defaultHandler: ({ resume }, ms) => { 1110 | setTimeout(resume, ms); 1111 | }, 1112 | }); 1113 | 1114 | interface Logger { 1115 | debug: (...args: unknown[]) => void | Promise; 1116 | info: (...args: unknown[]) => void | Promise; 1117 | warn: (...args: unknown[]) => void | Promise; 1118 | error: (...args: unknown[]) => void | Promise; 1119 | } 1120 | type LoggerDependency = Default>; 1121 | const askLogger = dependency()("logger", () => console); 1122 | 1123 | type Logging = 1124 | | Default, never, LoggerDependency> 1125 | | Default, never, LoggerDependency> 1126 | | Default, never, LoggerDependency> 1127 | | Default, never, LoggerDependency>; 1128 | 1129 | const logLevels = ["debug", "info", "warn", "error"] as const; 1130 | type LogLevel = (typeof logLevels)[number]; 1131 | 1132 | const logEffect = (level: LogLevel): EffectFactory => 1133 | effect(`logging.${level}`, { 1134 | *defaultHandler({ resume }, ...args) { 1135 | const logger = yield* askLogger(); 1136 | const result = logger[level](...args); 1137 | if (result instanceof Promise) void result.then(resume); 1138 | else resume(); 1139 | }, 1140 | }); 1141 | 1142 | const logDebug = logEffect("debug"); 1143 | const logInfo = logEffect("info"); 1144 | const logWarn = logEffect("warn"); 1145 | const logError = logEffect("error"); 1146 | 1147 | function withPrefix(prefixFactory: (level: LogLevel) => string) { 1148 | return defineHandlerFor().with((self) => 1149 | self.handle( 1150 | (name): name is Logging["name"] => typeof name === "string" && name.startsWith("logging."), 1151 | function* ({ effect, resume }): Generator { 1152 | const prefix = prefixFactory(effect.name.slice("logging.".length) as LogLevel); 1153 | effect.payloads.splice(0, 0, prefix); 1154 | yield effect; 1155 | resume(); 1156 | }, 1157 | ), 1158 | ); 1159 | } 1160 | 1161 | function withMinimumLogLevel(level: LogLevel | "none") { 1162 | return defineHandlerFor().with((self) => { 1163 | const disabledLevels = new Set( 1164 | level === "none" ? logLevels : logLevels.slice(0, logLevels.indexOf(level)), 1165 | ); 1166 | return self.handle( 1167 | (name): name is Logging["name"] => 1168 | typeof name === "string" && 1169 | name.startsWith("logging.") && 1170 | disabledLevels.has(name.slice("logging.".length) as LogLevel), 1171 | function* ({ effect, resume }): Generator { 1172 | effect.defaultHandler = ({ resume }) => resume(); 1173 | yield effect; 1174 | resume(); 1175 | }, 1176 | ); 1177 | }); 1178 | } 1179 | 1180 | const program1 = effected(function* () { 1181 | yield* logDebug("Debug message"); 1182 | yield* sleep(1000); 1183 | yield* logInfo("Info message"); 1184 | yield* logWarn("Warning!"); 1185 | yield* logError("Error occurred!"); 1186 | }); 1187 | expect(program1).to(equal>); 1188 | 1189 | const program2 = effected(function* () { 1190 | yield* logDebug("Debug message"); 1191 | yield* sleep(1000); 1192 | yield* logInfo("Info message"); 1193 | yield* logWarn("Warning!"); 1194 | yield* logError("Error occurred!"); 1195 | }).pipe(withMinimumLogLevel("warn")); 1196 | expect(program2).to(equal>); 1197 | 1198 | function logPrefix(level: LogLevel) { 1199 | const date = new Date(); 1200 | const yyyy = date.getFullYear(); 1201 | const MM = String(date.getMonth() + 1).padStart(2, "0"); 1202 | const dd = String(date.getDate()).padStart(2, "0"); 1203 | const HH = String(date.getHours()).padStart(2, "0"); 1204 | const mm = String(date.getMinutes()).padStart(2, "0"); 1205 | const ss = String(date.getSeconds()).padStart(2, "0"); 1206 | const ms = String(date.getMilliseconds()).padEnd(3, "0"); 1207 | const datePart = `[${yyyy}-${MM}-${dd} ${HH}:${mm}:${ss}.${ms}]`; 1208 | const levelPart = `[${level}]`; 1209 | return `${datePart} ${levelPart}`; 1210 | } 1211 | 1212 | const program3 = effected(function* () { 1213 | yield* logDebug("Debug message"); 1214 | yield* sleep(1000); 1215 | yield* logInfo("Info message"); 1216 | yield* logWarn("Warning!"); 1217 | yield* logError("Error occurred!"); 1218 | }).pipe(withMinimumLogLevel("warn"), withPrefix(logPrefix)); 1219 | expect(program3).to(equal>); 1220 | 1221 | function fileLogger(_path: string) { 1222 | return new Proxy({} as Logger, { 1223 | get() { 1224 | return async () => {}; 1225 | }, 1226 | }); 1227 | } 1228 | 1229 | const program4 = effected(function* () { 1230 | yield* logDebug("Debug message"); 1231 | yield* sleep(1000); 1232 | yield* logInfo("Info message"); 1233 | yield* logWarn("Warning!"); 1234 | yield* logError("Error occurred!"); 1235 | }) 1236 | .pipe(withMinimumLogLevel("warn"), withPrefix(logPrefix)) 1237 | .provide("logger", fileLogger("log.txt")); 1238 | expect(program4).to( 1239 | equal< 1240 | Effected< 1241 | | Default> 1242 | | Default> 1243 | | Default> 1244 | | Default> 1245 | | Sleep, 1246 | void 1247 | > 1248 | >, 1249 | ); 1250 | 1251 | function dualLogger(logger1: Logger, logger2: Logger) { 1252 | return new Proxy({} as Logger, { 1253 | get(_, prop, receiver) { 1254 | return (...args: unknown[]) => { 1255 | const result1 = Reflect.get(logger1, prop, receiver)(...args); 1256 | const result2 = Reflect.get(logger2, prop, receiver)(...args); 1257 | if (result1 instanceof Promise && result2 instanceof Promise) 1258 | return Promise.all([result1, result2]); 1259 | else if (result1 instanceof Promise) return result1; 1260 | else if (result2 instanceof Promise) return result2; 1261 | }; 1262 | }, 1263 | }); 1264 | } 1265 | 1266 | const program5 = effected(function* () { 1267 | yield* logDebug("Debug message"); 1268 | yield* sleep(1000); 1269 | yield* logInfo("Info message"); 1270 | yield* logWarn("Warning!"); 1271 | yield* logError("Error occurred!"); 1272 | }) 1273 | .pipe(withMinimumLogLevel("warn"), withPrefix(logPrefix)) 1274 | .provide("logger", dualLogger(console, fileLogger("log.txt"))); 1275 | expect(program5).to( 1276 | equal< 1277 | Effected< 1278 | | Default> 1279 | | Default> 1280 | | Default> 1281 | | Default> 1282 | | Sleep, 1283 | void 1284 | > 1285 | >, 1286 | ); 1287 | }); 1288 | -------------------------------------------------------------------------------- /test/README.example.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable sonarjs/no-identical-functions */ 2 | 3 | import { expect, test, vi } from "vitest"; 4 | 5 | import type { Default, Effect, EffectFactory, Unresumable } from "../src"; 6 | import { 7 | Effected, 8 | UnhandledEffectError, 9 | defineHandlerFor, 10 | dependency, 11 | effect, 12 | effected, 13 | effectify, 14 | error, 15 | } from "../src"; 16 | 17 | test("banner", async () => { 18 | type User = { id: number; name: string; role: "admin" | "user" }; 19 | 20 | const log = effect("log")<[msg: string], void>; 21 | const askUser = dependency("user"); 22 | const authError = error("auth"); 23 | 24 | const requiresAdmin = () => 25 | effected(function* () { 26 | const user = yield* askUser(); 27 | if (!user) return yield* authError("No user found"); 28 | if (user.role !== "admin") return yield* authError(`User ${user.name} is not an admin`); 29 | }); 30 | 31 | const fetchAdminData = () => 32 | effected(function* () { 33 | yield* requiresAdmin(); 34 | const data = yield* effectify( 35 | fetch("https://jsonplaceholder.typicode.com/todos/1").then((res) => res.json()), 36 | ); 37 | yield* log("Fetched data: " + JSON.stringify(data)); 38 | }); 39 | 40 | const program = fetchAdminData() 41 | .resume("log", (msg) => console.log(msg)) 42 | .provideBy("user", () => ({ id: 1, name: "Alice", role: "admin" }) satisfies User) 43 | .catch("auth", (err) => console.error("Authorization error:", err)); 44 | 45 | const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); 46 | await program.runAsync(); 47 | expect(logSpy.mock.calls).toMatchInlineSnapshot(` 48 | [ 49 | [ 50 | "Fetched data: {"userId":1,"id":1,"title":"delectus aut autem","completed":false}", 51 | ], 52 | ] 53 | `); 54 | logSpy.mockRestore(); 55 | }); 56 | 57 | test("Usage", async () => { 58 | type User = { id: number; name: string; role: "admin" | "user" }; 59 | 60 | const println = effect("println"); 61 | const executeSQL = effect("executeSQL")<[sql: string, ...params: unknown[]], any>; 62 | const askCurrentUser = dependency("currentUser"); 63 | const authenticationError = error("authentication"); 64 | const unauthorizedError = error("unauthorized"); 65 | 66 | const requiresAdmin = () => 67 | effected(function* () { 68 | const currentUser = yield* askCurrentUser(); 69 | if (!currentUser) return yield* authenticationError(); 70 | if (currentUser.role !== "admin") 71 | return yield* unauthorizedError(`User "${currentUser.name}" is not an admin`); 72 | }); 73 | 74 | const createUser = (user: Omit) => 75 | effected(function* () { 76 | yield* requiresAdmin(); 77 | const id = yield* executeSQL("INSERT INTO users (name) VALUES (?)", user.name); 78 | const savedUser: User = { id, ...user }; 79 | yield* println("User created:", savedUser); 80 | return savedUser; 81 | }); 82 | 83 | const alice: Omit = { name: "Alice", role: "user" }; 84 | 85 | const handled2 = createUser(alice) 86 | .handle("executeSQL", ({ resume }, sql, ...params) => { 87 | console.log(`Executing SQL: ${sql}`); 88 | console.log("Parameters:", params); 89 | resume(42); 90 | }) 91 | .handle("println", ({ resume }, ...args) => { 92 | console.log(...args); 93 | resume(); 94 | }) 95 | .handle("dependency:currentUser", ({ resume }) => { 96 | resume({ id: 1, name: "Charlie", role: "admin" }); 97 | }) 98 | .handle<"error:authentication", void>("error:authentication", ({ terminate }) => { 99 | console.error("Authentication error"); 100 | terminate(); 101 | }) 102 | .handle<"error:unauthorized", void>("error:unauthorized", ({ terminate }) => { 103 | console.error("Unauthorized error"); 104 | terminate(); 105 | }); 106 | 107 | let logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); 108 | handled2.runSync(); 109 | expect(logSpy.mock.calls).toMatchInlineSnapshot(` 110 | [ 111 | [ 112 | "Executing SQL: INSERT INTO users (name) VALUES (?)", 113 | ], 114 | [ 115 | "Parameters:", 116 | [ 117 | "Alice", 118 | ], 119 | ], 120 | [ 121 | "User created:", 122 | { 123 | "id": 42, 124 | "name": "Alice", 125 | "role": "user", 126 | }, 127 | ], 128 | ] 129 | `); 130 | logSpy.mockRestore(); 131 | 132 | const handled3 = createUser(alice) 133 | .handle("executeSQL", ({ resume }, sql, ...params) => { 134 | console.log(`Executing SQL: ${sql}`); 135 | console.log("Parameters:", params); 136 | resume(42); 137 | }) 138 | .handle<"error:authentication", void>("error:authentication", ({ terminate }) => { 139 | console.error("Authentication error"); 140 | terminate(); 141 | }) 142 | .handle<"error:unauthorized", void>("error:unauthorized", ({ terminate }) => { 143 | console.error("Unauthorized error"); 144 | terminate(); 145 | }); 146 | 147 | let thrown = false; 148 | try { 149 | // @ts-expect-error 150 | handled3.runSync(); 151 | } catch (error) { 152 | thrown = true; 153 | expect(error).toBeInstanceOf(UnhandledEffectError); 154 | expect((error as UnhandledEffectError).effect).toMatchInlineSnapshot(` 155 | Effect { 156 | "name": "dependency:currentUser", 157 | "payloads": [], 158 | } 159 | `); 160 | expect((error as UnhandledEffectError).message).toMatchInlineSnapshot( 161 | `"Unhandled effect: dependency:currentUser()"`, 162 | ); 163 | } 164 | expect(thrown).toBe(true); 165 | 166 | const handled4 = createUser(alice) 167 | .resume("executeSQL", (sql, ...params) => { 168 | console.log(`Executing SQL: ${sql}`); 169 | console.log("Parameters:", params); 170 | return 42; 171 | }) 172 | .resume("println", (...args) => console.log(...args)) 173 | .provide("currentUser", { id: 1, name: "Charlie", role: "admin" }) 174 | .catch("authentication", () => console.error("Authentication error")) 175 | .catch("unauthorized", () => console.error("Unauthorized error")); 176 | 177 | logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); 178 | handled4.runSync(); 179 | expect(logSpy.mock.calls).toMatchInlineSnapshot(` 180 | [ 181 | [ 182 | "Executing SQL: INSERT INTO users (name) VALUES (?)", 183 | ], 184 | [ 185 | "Parameters:", 186 | [ 187 | "Alice", 188 | ], 189 | ], 190 | [ 191 | "User created:", 192 | { 193 | "id": 42, 194 | "name": "Alice", 195 | "role": "user", 196 | }, 197 | ], 198 | ] 199 | `); 200 | logSpy.mockRestore(); 201 | 202 | const handled5 = createUser(alice) 203 | .handle("executeSQL", ({ resume }, sql, ...params) => { 204 | console.log(`Executing SQL: ${sql}`); 205 | console.log("Parameters:", params); 206 | // Simulate async operation 207 | setTimeout(() => { 208 | console.log(`SQL executed`); 209 | resume(42); 210 | }, 10); 211 | }) 212 | .resume("println", (...args) => console.log(...args)) 213 | .provide("currentUser", { id: 1, name: "Charlie", role: "admin" }) 214 | .catch("authentication", () => console.error("Authentication error")) 215 | .catch("unauthorized", () => console.error("Unauthorized error")); 216 | 217 | logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); 218 | const promise = handled5.runAsync(); 219 | expect(logSpy.mock.calls).toMatchInlineSnapshot(` 220 | [ 221 | [ 222 | "Executing SQL: INSERT INTO users (name) VALUES (?)", 223 | ], 224 | [ 225 | "Parameters:", 226 | [ 227 | "Alice", 228 | ], 229 | ], 230 | ] 231 | `); 232 | await promise; 233 | expect(logSpy.mock.calls).toMatchInlineSnapshot(` 234 | [ 235 | [ 236 | "Executing SQL: INSERT INTO users (name) VALUES (?)", 237 | ], 238 | [ 239 | "Parameters:", 240 | [ 241 | "Alice", 242 | ], 243 | ], 244 | [ 245 | "SQL executed", 246 | ], 247 | [ 248 | "User created:", 249 | { 250 | "id": 42, 251 | "name": "Alice", 252 | "role": "user", 253 | }, 254 | ], 255 | ] 256 | `); 257 | logSpy.mockRestore(); 258 | 259 | logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); 260 | expect(handled5.runSync).toThrow( 261 | new Error("Cannot run an asynchronous effected program with `runSync`, use `runAsync` instead"), 262 | ); 263 | expect(logSpy.mock.calls).toMatchInlineSnapshot(` 264 | [ 265 | [ 266 | "Executing SQL: INSERT INTO users (name) VALUES (?)", 267 | ], 268 | [ 269 | "Parameters:", 270 | [ 271 | "Alice", 272 | ], 273 | ], 274 | ] 275 | `); 276 | // Wait for the async operation to complete 277 | await new Promise((resolve) => setTimeout(resolve, 15)); 278 | logSpy.mockRestore(); 279 | 280 | const db = { 281 | user: { 282 | create: (_user: Omit) => 283 | new Promise((resolve) => setTimeout(() => resolve(42), 100)), 284 | }, 285 | }; 286 | 287 | const createUser2 = (user: Omit) => 288 | effected(function* () { 289 | yield* requiresAdmin(); 290 | const id = yield* effectify(db.user.create(user)); 291 | const savedUser = { id, ...user }; 292 | yield* println("User created:", savedUser); 293 | return savedUser; 294 | }); 295 | 296 | logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); 297 | expect( 298 | await createUser2(alice) 299 | .resume("println", (...args) => console.log(...args)) 300 | .provide("currentUser", { id: 1, name: "Charlie", role: "admin" }) 301 | .catch("authentication", () => console.error("Authentication error")) 302 | .catch("unauthorized", () => console.error("Unauthorized error")) 303 | .runAsync(), 304 | ).toEqual({ id: 42, name: "Alice", role: "user" }); 305 | expect(logSpy.mock.calls).toMatchInlineSnapshot(` 306 | [ 307 | [ 308 | "User created:", 309 | { 310 | "id": 42, 311 | "name": "Alice", 312 | "role": "user", 313 | }, 314 | ], 315 | ] 316 | `); 317 | logSpy.mockRestore(); 318 | 319 | let sqlId = 1; 320 | 321 | const program = effected(function* () { 322 | yield* createUser({ name: "Alice", role: "user" }); 323 | yield* createUser({ name: "Bob", role: "admin" }); 324 | }) 325 | .resume("println", (...args) => { 326 | console.log(...args); 327 | }) 328 | .handle("executeSQL", ({ resume }, sql, ...params) => { 329 | console.log(`[${sqlId}] Executing SQL: ${sql}`); 330 | console.log(`[${sqlId}] Parameters: ${params.join(", ")}`); 331 | // Simulate async operation 332 | setTimeout(() => { 333 | console.log(`[${sqlId}] SQL executed`); 334 | sqlId++; 335 | resume(sqlId); 336 | }, 100); 337 | }) 338 | .provide("currentUser", { id: 1, name: "Charlie", role: "admin" }) 339 | .catch("authentication", () => { 340 | console.error("Authentication error"); 341 | }) 342 | .catch("unauthorized", () => { 343 | console.error("Unauthorized error"); 344 | }); 345 | 346 | logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); 347 | await program.runAsync(); 348 | expect(logSpy.mock.calls).toMatchInlineSnapshot(` 349 | [ 350 | [ 351 | "[1] Executing SQL: INSERT INTO users (name) VALUES (?)", 352 | ], 353 | [ 354 | "[1] Parameters: Alice", 355 | ], 356 | [ 357 | "[1] SQL executed", 358 | ], 359 | [ 360 | "User created:", 361 | { 362 | "id": 2, 363 | "name": "Alice", 364 | "role": "user", 365 | }, 366 | ], 367 | [ 368 | "[2] Executing SQL: INSERT INTO users (name) VALUES (?)", 369 | ], 370 | [ 371 | "[2] Parameters: Bob", 372 | ], 373 | [ 374 | "[2] SQL executed", 375 | ], 376 | [ 377 | "User created:", 378 | { 379 | "id": 3, 380 | "name": "Bob", 381 | "role": "admin", 382 | }, 383 | ], 384 | ] 385 | `); 386 | logSpy.mockRestore(); 387 | }); 388 | 389 | test("The `Effect` type > Name collisions", () => { 390 | { 391 | const effectA = effect("foo"); 392 | const programA = effected(function* () { 393 | return yield* effectA(); 394 | }); 395 | 396 | const effectB = effect("foo"); // Same name as effectA 397 | const programB = effected(function* () { 398 | return yield* effectB(); 399 | }); 400 | 401 | const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); 402 | effected(function* () { 403 | console.log(yield* programA); 404 | console.log(yield* programB); 405 | }) 406 | .resume("foo", () => 42) 407 | .runSync(); 408 | expect(logSpy.mock.calls).toMatchInlineSnapshot(` 409 | [ 410 | [ 411 | 42, 412 | ], 413 | [ 414 | 42, 415 | ], 416 | ] 417 | `); 418 | logSpy.mockRestore(); 419 | } 420 | 421 | { 422 | const effectA = effect("foo"); 423 | const programA = effected(function* () { 424 | return yield* effectA(); 425 | }).resume("foo", () => 21); 426 | 427 | const effectB = effect("foo"); 428 | const programB = effected(function* () { 429 | return yield* effectB(); 430 | }); 431 | 432 | const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); 433 | effected(function* () { 434 | console.log(yield* programA); 435 | console.log(yield* programB); 436 | }) 437 | .resume("foo", () => 42) 438 | .runSync(); 439 | expect(logSpy.mock.calls).toMatchInlineSnapshot(` 440 | [ 441 | [ 442 | 21, 443 | ], 444 | [ 445 | 42, 446 | ], 447 | ] 448 | `); 449 | logSpy.mockRestore(); 450 | } 451 | 452 | { 453 | const nameA = Symbol("nameA"); 454 | const effectA = effect(nameA); 455 | const programA = effected(function* () { 456 | return yield* effectA(); 457 | }); 458 | 459 | const nameB = Symbol("nameB"); 460 | const effectB = effect(nameB); 461 | const programB = effected(function* () { 462 | return yield* effectB(); 463 | }); 464 | 465 | const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); 466 | effected(function* () { 467 | console.log(yield* programA); 468 | console.log(yield* programB); 469 | }) 470 | .resume(nameA, () => 21) 471 | .resume(nameB, () => 42) 472 | .runSync(); 473 | expect(logSpy.mock.calls).toMatchInlineSnapshot(` 474 | [ 475 | [ 476 | 21, 477 | ], 478 | [ 479 | 42, 480 | ], 481 | ] 482 | `); 483 | logSpy.mockRestore(); 484 | } 485 | }); 486 | 487 | test("The `Effect` type > Unresumable effects", () => { 488 | const raise = effect("raise", { resumable: false })<[error: unknown], never>; 489 | 490 | const program = effected(function* () { 491 | yield* raise("An error occurred"); 492 | }).resume( 493 | // @ts-expect-error 494 | "raise", 495 | () => {}, 496 | ); 497 | 498 | expect(program.runSync).toThrow( 499 | new Error('Cannot resume non-resumable effect: raise("An error occurred")'), 500 | ); 501 | }); 502 | 503 | test("A deep dive into `resume` and `terminate`", () => { 504 | { 505 | const raise = effect("raise")<[error: unknown], any>; 506 | 507 | const safeDivide = (a: number, b: number) => 508 | effected(function* () { 509 | if (b === 0) return yield* raise("Division by zero"); 510 | return a / b; 511 | }); 512 | 513 | expect( 514 | effected(function* () { 515 | return 8 + (yield* safeDivide(1, 0)); 516 | }) 517 | .terminate("raise", () => 42) 518 | .runSync(), 519 | ).toBe(42); 520 | } 521 | 522 | { 523 | type Iterate = Effect<"iterate", [value: T], void>; 524 | const iterate = (value: T) => effect("iterate")<[value: T], void>(value); 525 | 526 | const iterateOver = (iterable: Iterable): Effected, void> => 527 | effected(function* () { 528 | for (const value of iterable) { 529 | yield* iterate(value); 530 | } 531 | }); 532 | 533 | const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); 534 | let i = 0; 535 | iterateOver([1, 2, 3, 4, 5]) 536 | .handle("iterate", ({ resume, terminate }, value) => { 537 | if (i++ >= 3) { 538 | // Too many iterations 539 | terminate(); 540 | return; 541 | } 542 | console.log("Iterating over", value); 543 | resume(); 544 | }) 545 | .runSync(); 546 | expect(logSpy.mock.calls).toMatchInlineSnapshot(` 547 | [ 548 | [ 549 | "Iterating over", 550 | 1, 551 | ], 552 | [ 553 | "Iterating over", 554 | 2, 555 | ], 556 | [ 557 | "Iterating over", 558 | 3, 559 | ], 560 | ] 561 | `); 562 | logSpy.mockRestore(); 563 | } 564 | }); 565 | 566 | test("Handling effects with another effected program", () => { 567 | { 568 | type Ask = Effect<"ask", [], T>; 569 | const ask = (): Effected, T> => effect("ask")(); 570 | 571 | const double = () => 572 | effected(function* () { 573 | return (yield* ask()) + (yield* ask()); 574 | }); 575 | 576 | expect( 577 | effected(function* () { 578 | return yield* double(); 579 | }) 580 | .resume("ask", () => 21) 581 | .runSync(), 582 | ).toBe(42); 583 | 584 | type Random = Effect<"random", [], number>; 585 | const random: EffectFactory = effect("random"); 586 | 587 | expect( 588 | effected(function* () { 589 | return yield* double(); 590 | }) 591 | .resume("ask", function* () { 592 | return yield* random(); 593 | }) 594 | .resume("random", () => 42) 595 | .runSync(), 596 | ).toBe(84); 597 | } 598 | 599 | { 600 | type Emit = Effect<"emit", [msg: string], void>; 601 | const emit: EffectFactory = effect("emit"); 602 | 603 | const program = effected(function* () { 604 | yield* emit("hello"); 605 | yield* emit("world"); 606 | }) 607 | .resume("emit", (msg) => emit(`"${msg}"`)) 608 | .resume("emit", (...args) => console.log(...args)); 609 | 610 | const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); 611 | program.runSync(); 612 | expect(logSpy.mock.calls).toMatchInlineSnapshot(` 613 | [ 614 | [ 615 | ""hello"", 616 | ], 617 | [ 618 | ""world"", 619 | ], 620 | ] 621 | `); 622 | logSpy.mockRestore(); 623 | } 624 | }); 625 | 626 | test("Default handlers", () => { 627 | const logs: unknown[][] = []; 628 | const println = effect()("println", { 629 | defaultHandler: ({ resume }, ...args) => { 630 | logs.push(args); 631 | resume(); 632 | }, 633 | }); 634 | 635 | { 636 | const program = effected(function* () { 637 | yield* println("Hello, world!"); 638 | }); 639 | expect(program.runSync).not.toThrow(); 640 | expect(logs).toEqual([["Hello, world!"]]); 641 | logs.length = 0; 642 | 643 | expect(println("Hello, world!").runSync).not.toThrow(); 644 | expect(logs).toEqual([["Hello, world!"]]); 645 | logs.length = 0; 646 | } 647 | 648 | { 649 | const program = effected(function* () { 650 | yield* println("Hello, world!"); 651 | }).resume("println", () => { 652 | logs.push(["This will be logged instead of the default handler"]); 653 | }); 654 | expect(program.runSync).not.toThrow(); 655 | expect(logs).toEqual([["This will be logged instead of the default handler"]]); 656 | logs.length = 0; 657 | 658 | expect( 659 | println("Hello, world!").resume("println", () => { 660 | logs.push(["This will be logged instead of the default handler"]); 661 | }).runSync, 662 | ).not.toThrow(); 663 | expect(logs).toEqual([["This will be logged instead of the default handler"]]); 664 | logs.length = 0; 665 | } 666 | 667 | { 668 | type User = { id: number; name: string; role: "admin" | "user" }; 669 | 670 | const askCurrentUser = dependency()("currentUser", () => ({ 671 | id: 1, 672 | name: "Charlie", 673 | role: "admin", 674 | })); 675 | 676 | const program = effected(function* () { 677 | const user = yield* askCurrentUser(); 678 | yield* println("Current user:", user); 679 | }); 680 | expect(program.runSync).not.toThrow(); 681 | expect(logs).toEqual([["Current user:", { id: 1, name: "Charlie", role: "admin" }]]); 682 | logs.length = 0; 683 | 684 | expect( 685 | program.provide("currentUser", { id: 2, name: "Alice", role: "user" }).runSync, 686 | ).not.toThrow(); 687 | expect(logs).toEqual([["Current user:", { id: 2, name: "Alice", role: "user" }]]); 688 | logs.length = 0; 689 | } 690 | }); 691 | 692 | test("Handling return values", () => { 693 | { 694 | type Raise = Unresumable>; 695 | const raise: EffectFactory = effect("raise", { resumable: false }); 696 | 697 | const safeDivide = (a: number, b: number): Effected => 698 | effected(function* () { 699 | if (b === 0) return yield* raise("Division by zero"); 700 | return a / b; 701 | }); 702 | 703 | type Option = { kind: "some"; value: T } | { kind: "none" }; 704 | 705 | const some = (value: T): Option => ({ kind: "some", value }); 706 | const none: Option = { kind: "none" }; 707 | 708 | const safeDivide2 = (a: number, b: number): Effected> => 709 | safeDivide(a, b) 710 | .andThen((value) => some(value)) 711 | .terminate("raise", () => none); 712 | 713 | expect(safeDivide2(1, 0).runSync()).toEqual(none); 714 | expect(safeDivide2(1, 2).runSync()).toEqual(some(0.5)); 715 | } 716 | 717 | { 718 | type Defer = Effect<"defer", [fn: () => void], void>; 719 | const defer: EffectFactory = effect("defer"); 720 | 721 | const deferHandler = defineHandlerFor().with((self) => { 722 | const deferredActions: (() => void)[] = []; 723 | 724 | return self 725 | .resume("defer", (fn) => { 726 | deferredActions.push(fn); 727 | }) 728 | .tap(() => { 729 | deferredActions.forEach((fn) => fn()); 730 | }); 731 | }); 732 | 733 | const program = effected(function* () { 734 | yield* defer(() => console.log("Deferred action")); 735 | console.log("Normal action"); 736 | }).with(deferHandler); 737 | 738 | const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); 739 | program.runSync(); 740 | expect(logSpy.mock.calls).toMatchInlineSnapshot(` 741 | [ 742 | [ 743 | "Normal action", 744 | ], 745 | [ 746 | "Deferred action", 747 | ], 748 | ] 749 | `); 750 | logSpy.mockRestore(); 751 | } 752 | }); 753 | 754 | test("Handling multiple effects in one handler", () => { 755 | { 756 | type Logging = 757 | | Effect<"logging:log", unknown[], void> 758 | | Effect<"logging:warn", unknown[], void> 759 | | Effect<"logging:error", unknown[], void>; 760 | const logger = { 761 | log: effect("logging:log"), 762 | warn: effect("logging:warn"), 763 | error: effect("logging:error"), 764 | }; 765 | 766 | type ReadFile = Effect<"readFile", [path: string], string>; 767 | const readFile: EffectFactory = effect("readFile"); 768 | 769 | interface Settings { 770 | something: string; 771 | } 772 | 773 | const defaultSettings: Settings = { 774 | something: "foo", 775 | }; 776 | 777 | const readSettings = (path: string): Effected => 778 | effected(function* () { 779 | const content = yield* readFile(path); 780 | try { 781 | const settings = JSON.parse(content); 782 | yield* logger.log("Settings loaded"); 783 | return settings; 784 | } catch (e) { 785 | yield* logger.error("Failed to parse settings file:", e); 786 | return defaultSettings; 787 | } 788 | }); 789 | 790 | const readSettingsWithoutLogging = readSettings("settings.json").resume( 791 | (name): name is Logging["name"] => name.startsWith("logging:"), 792 | (..._args) => { 793 | // Omit logging 794 | }, 795 | ); 796 | 797 | expect(readSettingsWithoutLogging.resume("readFile", () => "Invalid JSON").runSync()).toEqual( 798 | defaultSettings, 799 | ); 800 | expect( 801 | readSettingsWithoutLogging.resume("readFile", () => '{"something": "bar"}').runSync(), 802 | ).toEqual({ something: "bar" }); 803 | } 804 | 805 | { 806 | type Result = { kind: "ok"; value: T } | { kind: "err"; error: E }; 807 | 808 | const ok = (value: T): Result => ({ kind: "ok", value }); 809 | const err = (error: E): Result => ({ kind: "err", error }); 810 | 811 | const handleErrorAsResult = ( 812 | self: Effected | E, R>, 813 | ): Effected> => { 814 | const isErrorEffect = (name: string | symbol): name is `error:${ErrorName}` => { 815 | if (typeof name === "symbol") return false; 816 | return name.startsWith("error:"); 817 | }; 818 | 819 | return self 820 | .andThen((value) => ok(value)) 821 | .handle(isErrorEffect, (({ effect, terminate }: any, message: any) => { 822 | terminate(err({ error: effect.name.slice("error:".length), message })); 823 | }) as never) as Effected>; 824 | }; 825 | 826 | type TypeError = Effect.Error<"type">; 827 | const typeError: EffectFactory = error("type"); 828 | type RangeError = Effect.Error<"range">; 829 | const rangeError: EffectFactory = error("range"); 830 | 831 | type Log = Effect<"println", unknown[], void>; 832 | const log: EffectFactory = effect("println"); 833 | 834 | const range = (start: number, stop: number): Effected => 835 | effected(function* () { 836 | if (start >= stop) return yield* rangeError("Start must be less than stop"); 837 | if (!Number.isInteger(start) || !Number.isInteger(stop)) 838 | return yield* typeError("Start and stop must be integers"); 839 | yield* log(`Generating range from ${start} to ${stop}`); 840 | return Array.from({ length: stop - start }, (_, i) => start + i); 841 | }); 842 | 843 | const range2 = (start: number, stop: number) => handleErrorAsResult(range(start, stop)); 844 | 845 | const logs: unknown[][] = []; 846 | expect( 847 | range2(1, 0) 848 | .resume("println", (...args) => { 849 | logs.push(args); 850 | }) 851 | .runSync(), 852 | ).toEqual(err({ error: "range", message: "Start must be less than stop" })); 853 | expect(logs).toEqual([]); 854 | logs.length = 0; 855 | 856 | expect( 857 | range2(1.5, 5) 858 | .resume("println", (...args) => { 859 | logs.push(args); 860 | }) 861 | .runSync(), 862 | ).toEqual(err({ error: "type", message: "Start and stop must be integers" })); 863 | expect(logs).toEqual([]); 864 | logs.length = 0; 865 | 866 | expect( 867 | range2(1, 5) 868 | .resume("println", (...args) => { 869 | logs.push(args); 870 | }) 871 | .runSync(), 872 | ).toEqual(ok([1, 2, 3, 4])); 873 | expect(logs).toEqual([["Generating range from 1 to 5"]]); 874 | logs.length = 0; 875 | 876 | const range3 = (start: number, stop: number) => range(start, stop).with(handleErrorAsResult); 877 | 878 | expect( 879 | range3(1, 0) 880 | .resume("println", (...args) => { 881 | logs.push(args); 882 | }) 883 | .runSync(), 884 | ).toEqual(err({ error: "range", message: "Start must be less than stop" })); 885 | expect(logs).toEqual([]); 886 | logs.length = 0; 887 | 888 | expect( 889 | range3(1.5, 5) 890 | .resume("println", (...args) => { 891 | logs.push(args); 892 | }) 893 | .runSync(), 894 | ).toEqual(err({ error: "type", message: "Start and stop must be integers" })); 895 | expect(logs).toEqual([]); 896 | logs.length = 0; 897 | 898 | expect( 899 | range3(1, 5) 900 | .resume("println", (...args) => { 901 | logs.push(args); 902 | }) 903 | .runSync(), 904 | ).toEqual(ok([1, 2, 3, 4])); 905 | expect(logs).toEqual([["Generating range from 1 to 5"]]); 906 | logs.length = 0; 907 | 908 | const range4 = (start: number, stop: number) => 909 | range(start, stop) 910 | .andThen((value) => ok(value)) 911 | .catchAll((error, message) => err({ error, message })); 912 | 913 | expect( 914 | range4(1, 0) 915 | .resume("println", (...args) => { 916 | logs.push(args); 917 | }) 918 | .runSync(), 919 | ).toEqual(err({ error: "range", message: "Start must be less than stop" })); 920 | expect(logs).toEqual([]); 921 | logs.length = 0; 922 | 923 | expect( 924 | range4(1.5, 5) 925 | .resume("println", (...args) => { 926 | logs.push(args); 927 | }) 928 | .runSync(), 929 | ).toEqual(err({ error: "type", message: "Start and stop must be integers" })); 930 | expect(logs).toEqual([]); 931 | logs.length = 0; 932 | 933 | expect( 934 | range4(1, 5) 935 | .resume("println", (...args) => { 936 | logs.push(args); 937 | }) 938 | .runSync(), 939 | ).toEqual(ok([1, 2, 3, 4])); 940 | expect(logs).toEqual([["Generating range from 1 to 5"]]); 941 | } 942 | }); 943 | 944 | test("Handling error effects", () => { 945 | { 946 | type SyntaxError = Effect.Error<"syntax">; 947 | const syntaxError: EffectFactory = error("syntax"); 948 | type Raise = Unresumable>; 949 | const raise: EffectFactory = effect("raise", { resumable: false }); 950 | 951 | const parseJSON = (json: string): Effected => 952 | effected(function* () { 953 | try { 954 | return JSON.parse(json); 955 | } catch (e) { 956 | if (e instanceof SyntaxError) return yield* syntaxError(e.message); 957 | return yield* raise(e); 958 | } 959 | }); 960 | 961 | interface Settings { 962 | something: string; 963 | } 964 | 965 | const defaultSettings: Settings = { 966 | something: "foo", 967 | }; 968 | 969 | const readSettings = (json: string) => 970 | effected(function* () { 971 | return yield* parseJSON(json).catch("syntax", (message) => { 972 | console.error(`Invalid JSON: ${message}`); 973 | return defaultSettings; 974 | }); 975 | }); 976 | 977 | const spyError = vi.spyOn(console, "error").mockImplementation(() => {}); 978 | expect( 979 | readSettings("invalid json") 980 | .terminate("raise", () => {}) 981 | .runSync(), 982 | ).toEqual(defaultSettings); 983 | expect(spyError).toHaveBeenCalledOnce(); 984 | expect(spyError.mock.calls[0]!.length).toBe(1); 985 | expect(spyError.mock.calls[0]![0]).toMatch(/^Invalid JSON: Unexpected token /); 986 | spyError.mockRestore(); 987 | 988 | expect( 989 | readSettings('{"something": "bar"}') 990 | .terminate("raise", () => {}) 991 | .runSync(), 992 | ).toEqual({ something: "bar" }); 993 | } 994 | 995 | { 996 | type TypeError = Effect.Error<"type">; 997 | const typeError: EffectFactory = error("type"); 998 | type RangeError = Effect.Error<"range">; 999 | const rangeError: EffectFactory = error("range"); 1000 | 1001 | type Log = Effect<"log", unknown[], void>; 1002 | const log: EffectFactory = effect("log"); 1003 | 1004 | const range = (start: number, stop: number): Effected => 1005 | effected(function* () { 1006 | if (start >= stop) return yield* rangeError("Start must be less than stop"); 1007 | if (!Number.isInteger(start) || !Number.isInteger(stop)) 1008 | return yield* typeError("Start and stop must be integers"); 1009 | yield* log(`Generating range from ${start} to ${stop}`); 1010 | return Array.from({ length: stop - start }, (_, i) => start + i); 1011 | }); 1012 | 1013 | const tolerantRange = (start: number, stop: number) => 1014 | range(start, stop).catchAll((error, message) => { 1015 | console.warn(`Error(${error}): ${message || ""}`); 1016 | return [] as number[]; 1017 | }); 1018 | 1019 | const spyWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); 1020 | expect( 1021 | tolerantRange(4, 1) 1022 | .resume("log", () => {}) 1023 | .runSync(), 1024 | ).toEqual([]); 1025 | expect(spyWarn.mock.calls).toMatchInlineSnapshot(` 1026 | [ 1027 | [ 1028 | "Error(range): Start must be less than stop", 1029 | ], 1030 | ] 1031 | `); 1032 | spyWarn.mockRestore(); 1033 | 1034 | const range2 = (start: number, stop: number) => range(start, stop).catchAndThrow("type"); 1035 | 1036 | let thrown = false; 1037 | try { 1038 | range2(1.5, 2) 1039 | .catch("range", () => {}) 1040 | .resume("log", console.log) 1041 | .runSync(); 1042 | } catch (e) { 1043 | thrown = true; 1044 | expect(e).toBeInstanceOf(Error); 1045 | if (e instanceof Error) { 1046 | expect(e.name).toBe("TypeError"); 1047 | expect(e.message).toBe("Start and stop must be integers"); 1048 | const errorProto = Object.getPrototypeOf(e); 1049 | expect(errorProto).not.toBe(Error.prototype); 1050 | expect(errorProto).toBeInstanceOf(Error); 1051 | expect(errorProto.name).toBe("TypeError"); 1052 | expect(errorProto.constructor.name).toBe("TypeError"); 1053 | } 1054 | } 1055 | expect(thrown).toBe(true); 1056 | 1057 | const range3 = (start: number, stop: number) => 1058 | range(start, stop).catchAndThrow("type", "Invalid start or stop value"); 1059 | 1060 | thrown = false; 1061 | try { 1062 | range3(1.5, 2) 1063 | .catch("range", () => {}) 1064 | .resume("log", console.log) 1065 | .runSync(); 1066 | } catch (e) { 1067 | thrown = true; 1068 | expect(e).toBeInstanceOf(Error); 1069 | if (e instanceof Error) { 1070 | expect(e.name).toBe("TypeError"); 1071 | expect(e.message).toBe("Invalid start or stop value"); 1072 | const errorProto = Object.getPrototypeOf(e); 1073 | expect(errorProto).not.toBe(Error.prototype); 1074 | expect(errorProto).toBeInstanceOf(Error); 1075 | expect(errorProto.name).toBe("TypeError"); 1076 | expect(errorProto.constructor.name).toBe("TypeError"); 1077 | } 1078 | } 1079 | expect(thrown).toBe(true); 1080 | 1081 | const range4 = (start: number, stop: number) => 1082 | range(start, stop).catchAndThrow("range", (message) => `Invalid range: ${message}`); 1083 | 1084 | thrown = false; 1085 | try { 1086 | range4(4, 1) 1087 | .catch("type", () => {}) 1088 | .resume("log", console.log) 1089 | .runSync(); 1090 | } catch (e) { 1091 | thrown = true; 1092 | expect(e).toBeInstanceOf(Error); 1093 | if (e instanceof Error) { 1094 | expect(e.name).toBe("RangeError"); 1095 | expect(e.message).toBe("Invalid range: Start must be less than stop"); 1096 | const errorProto = Object.getPrototypeOf(e); 1097 | expect(errorProto).not.toBe(Error.prototype); 1098 | expect(errorProto).toBeInstanceOf(Error); 1099 | expect(errorProto.name).toBe("RangeError"); 1100 | expect(errorProto.constructor.name).toBe("RangeError"); 1101 | } 1102 | } 1103 | expect(thrown).toBe(true); 1104 | 1105 | const range5 = (start: number, stop: number) => range(start, stop).catchAllAndThrow(); 1106 | 1107 | thrown = false; 1108 | try { 1109 | range5(4, 1).resume("log", console.log).runSync(); 1110 | } catch (e) { 1111 | thrown = true; 1112 | expect(e).toBeInstanceOf(Error); 1113 | if (e instanceof Error) { 1114 | expect(e.name).toBe("RangeError"); 1115 | expect(e.message).toBe("Start must be less than stop"); 1116 | const errorProto = Object.getPrototypeOf(e); 1117 | expect(errorProto).not.toBe(Error.prototype); 1118 | expect(errorProto).toBeInstanceOf(Error); 1119 | expect(errorProto.name).toBe("RangeError"); 1120 | expect(errorProto.constructor.name).toBe("RangeError"); 1121 | } 1122 | } 1123 | expect(thrown).toBe(true); 1124 | 1125 | const range6 = (start: number, stop: number) => 1126 | range(start, stop).catchAllAndThrow("An error occurred while generating the range"); 1127 | 1128 | thrown = false; 1129 | try { 1130 | range6(1.5, 2).resume("log", console.log).runSync(); 1131 | } catch (e) { 1132 | thrown = true; 1133 | expect(e).toBeInstanceOf(Error); 1134 | if (e instanceof Error) { 1135 | expect(e.name).toBe("TypeError"); 1136 | expect(e.message).toBe("An error occurred while generating the range"); 1137 | const errorProto = Object.getPrototypeOf(e); 1138 | expect(errorProto).not.toBe(Error.prototype); 1139 | expect(errorProto).toBeInstanceOf(Error); 1140 | expect(errorProto.name).toBe("TypeError"); 1141 | expect(errorProto.constructor.name).toBe("TypeError"); 1142 | } 1143 | } 1144 | expect(thrown).toBe(true); 1145 | 1146 | const range7 = (start: number, stop: number) => 1147 | range(start, stop).catchAllAndThrow((error, message) => `Error(${error}): ${message}`); 1148 | 1149 | thrown = false; 1150 | try { 1151 | range7(1.5, 2).resume("log", console.log).runSync(); 1152 | } catch (e) { 1153 | thrown = true; 1154 | expect(e).toBeInstanceOf(Error); 1155 | if (e instanceof Error) { 1156 | expect(e.name).toBe("TypeError"); 1157 | expect(e.message).toBe("Error(type): Start and stop must be integers"); 1158 | const errorProto = Object.getPrototypeOf(e); 1159 | expect(errorProto).not.toBe(Error.prototype); 1160 | expect(errorProto).toBeInstanceOf(Error); 1161 | expect(errorProto.name).toBe("TypeError"); 1162 | expect(errorProto.constructor.name).toBe("TypeError"); 1163 | } 1164 | } 1165 | expect(thrown).toBe(true); 1166 | } 1167 | }); 1168 | 1169 | test("Abstracting handlers", () => { 1170 | { 1171 | type State = Effect<"state.get", [], T> | Effect<"state.set", [value: T], void>; 1172 | const state = { 1173 | get: (): Effected, T> => effect("state.get")<[], T>(), 1174 | set: (value: T): Effected, void> => effect("state.set")<[value: T], void>(value), 1175 | }; 1176 | 1177 | const sumDown = (sum = 0): Effected, number> => 1178 | effected(function* () { 1179 | const n = yield* state.get(); 1180 | if (n <= 0) return sum; 1181 | yield* state.set(n - 1); 1182 | return yield* sumDown(sum + n); 1183 | }); 1184 | 1185 | let n = 10; 1186 | const program = sumDown() 1187 | .resume("state.get", () => n) 1188 | .resume("state.set", (value) => { 1189 | n = value; 1190 | }); 1191 | 1192 | expect(program.runSync()).toBe(55); 1193 | 1194 | const stateHandler = ({ get, set }: { get: () => T; set: (x: T) => void }) => 1195 | defineHandlerFor>().with((self) => 1196 | self.resume("state.get", get).resume("state.set", set), 1197 | ); 1198 | 1199 | n = 10; 1200 | const program2 = sumDown().with(stateHandler({ get: () => n, set: (x) => (n = x) })); 1201 | 1202 | expect(program2.runSync()).toBe(55); 1203 | } 1204 | 1205 | { 1206 | type Raise = Unresumable>; 1207 | const raise: EffectFactory = effect("raise", { resumable: false }); 1208 | 1209 | const safeDivide = (a: number, b: number): Effected => 1210 | effected(function* () { 1211 | if (b === 0) return yield* raise("Division by zero"); 1212 | return a / b; 1213 | }); 1214 | 1215 | type Option = { kind: "some"; value: T } | { kind: "none" }; 1216 | 1217 | const some = (value: T): Option => ({ kind: "some", value }); 1218 | const none: Option = { kind: "none" }; 1219 | 1220 | const raiseOption = defineHandlerFor().with((self) => 1221 | self.andThen((value) => some(value)).terminate("raise", () => none), 1222 | ); 1223 | 1224 | const safeDivide2 = (a: number, b: number) => safeDivide(a, b).with(raiseOption); 1225 | 1226 | expect(safeDivide2(1, 0).runSync()).toEqual(none); 1227 | expect(safeDivide2(1, 2).runSync()).toEqual(some(0.5)); 1228 | } 1229 | }); 1230 | 1231 | test("Parallel execution with `Effected.all`", async () => { 1232 | // Test sequential vs parallel behavior 1233 | { 1234 | const log = effect("log")<[message: string], void>; 1235 | const httpGet = effect("httpGet")<[url: string], any>; 1236 | 1237 | const fetchUserData = (userId: number) => 1238 | effected(function* () { 1239 | yield* log(`Fetching user ${userId}`); 1240 | const data = yield* httpGet(`/api/users/${userId}`); 1241 | return data; 1242 | }); 1243 | 1244 | const sequentialFetch = Effected.allSeq([fetchUserData(1), fetchUserData(2), fetchUserData(3)]); 1245 | 1246 | const parallelFetch = Effected.all([fetchUserData(1), fetchUserData(2), fetchUserData(3)]); 1247 | 1248 | const logMessages: string[] = []; 1249 | const fetchTimes: Record = {}; 1250 | let currentTime = 0; 1251 | 1252 | // Test sequential execution 1253 | const seqResult = await sequentialFetch 1254 | .resume("log", (message) => { 1255 | logMessages.push(message); 1256 | }) 1257 | .handle("httpGet", ({ resume }, url) => { 1258 | const userId = url.split("/").pop(); 1259 | const key = `seq-${userId}`; 1260 | fetchTimes[key] = { start: currentTime, end: 0 }; 1261 | 1262 | // Simulate sequential execution with 100ms delay each 1263 | setTimeout(() => { 1264 | currentTime += 100; 1265 | fetchTimes[key]!.end = currentTime; 1266 | resume({ id: Number(userId), name: `User ${userId}` }); 1267 | }, 100); 1268 | }) 1269 | .runAsync(); 1270 | 1271 | expect(seqResult).toEqual([ 1272 | { id: 1, name: "User 1" }, 1273 | { id: 2, name: "User 2" }, 1274 | { id: 3, name: "User 3" }, 1275 | ]); 1276 | expect(logMessages).toEqual(["Fetching user 1", "Fetching user 2", "Fetching user 3"]); 1277 | 1278 | // Clear state 1279 | logMessages.length = 0; 1280 | currentTime = 0; 1281 | 1282 | // Test parallel execution 1283 | const parallelResult = await parallelFetch 1284 | .resume("log", (message) => { 1285 | logMessages.push(message); 1286 | }) 1287 | .handle("httpGet", ({ resume }, url) => { 1288 | const userId = url.split("/").pop(); 1289 | const key = `par-${userId}`; 1290 | fetchTimes[key] = { start: currentTime, end: 0 }; 1291 | 1292 | // All should start at the same time, but take different times to complete 1293 | const delay = Number(userId) * 50; 1294 | setTimeout(() => { 1295 | fetchTimes[key]!.end = currentTime + delay; 1296 | resume({ id: Number(userId), name: `User ${userId}` }); 1297 | }, delay); 1298 | }) 1299 | .runAsync(); 1300 | 1301 | expect(parallelResult).toEqual([ 1302 | { id: 1, name: "User 1" }, 1303 | { id: 2, name: "User 2" }, 1304 | { id: 3, name: "User 3" }, 1305 | ]); 1306 | expect(logMessages).toEqual(["Fetching user 1", "Fetching user 2", "Fetching user 3"]); 1307 | } 1308 | 1309 | // Test object syntax example 1310 | { 1311 | const fetchUser = (userId: number) => Effected.of({ id: userId, name: `User ${userId}` }); 1312 | const fetchUserPosts = (userId: number) => 1313 | Effected.of([{ id: 1, title: `Post for ${userId}` }]); 1314 | const fetchUserSettings = (_userId: number) => 1315 | Effected.of({ theme: "dark", notifications: true }); 1316 | 1317 | const userData = await Effected.all({ 1318 | user: fetchUser(1), 1319 | posts: fetchUserPosts(1), 1320 | settings: fetchUserSettings(1), 1321 | }).runAsync(); 1322 | 1323 | expect(userData).toEqual({ 1324 | user: { id: 1, name: "User 1" }, 1325 | posts: [{ id: 1, title: "Post for 1" }], 1326 | settings: { theme: "dark", notifications: true }, 1327 | }); 1328 | } 1329 | 1330 | // Test mixed sync and async effects 1331 | { 1332 | const compute = effect("compute")<[label: string, delay: number], number>; 1333 | const calculate = effect("calculate")<[a: number, b: number], number>; 1334 | 1335 | const computeResults: string[] = []; 1336 | 1337 | const mixedProgram = effected(function* () { 1338 | const results = yield* Effected.all([ 1339 | // Sync task 1340 | calculate(10, 5), 1341 | // Fast async task 1342 | compute("fast task", 50), 1343 | // Slow async task 1344 | compute("slow task", 150), 1345 | ]); 1346 | return results; 1347 | }) 1348 | .resume("calculate", (a, b) => a + b) 1349 | .handle("compute", ({ resume }, label, delay) => { 1350 | computeResults.push(`Starting ${label}`); 1351 | setTimeout(() => { 1352 | computeResults.push(`Completed ${label}`); 1353 | resume(delay); 1354 | }, delay); 1355 | }); 1356 | 1357 | const mixedResults = await mixedProgram.runAsync(); 1358 | expect(mixedResults).toEqual([15, 50, 150]); 1359 | expect(computeResults).toEqual([ 1360 | "Starting fast task", 1361 | "Starting slow task", 1362 | "Completed fast task", 1363 | "Completed slow task", 1364 | ]); 1365 | } 1366 | }); 1367 | 1368 | test("Effects without generators (Pipeline syntax)", async () => { 1369 | { 1370 | const fib1 = (n: number): Effected => 1371 | effected(function* () { 1372 | if (n <= 1) return n; 1373 | return (yield* fib1(n - 1)) + (yield* fib1(n - 2)); 1374 | }); 1375 | 1376 | const fib2 = (n: number): Effected => { 1377 | if (n <= 1) return Effected.of(n); 1378 | return fib2(n - 1).andThen((a) => fib2(n - 2).andThen((b) => a + b)); 1379 | }; 1380 | 1381 | const fib3 = (n: number): Effected => { 1382 | if (n <= 1) return Effected.from(() => n); 1383 | return fib3(n - 1).andThen((a) => fib3(n - 2).andThen((b) => a + b)); 1384 | }; 1385 | 1386 | const fib4 = (n: number): Effected => { 1387 | if (n <= 1) return Effected.of(n); 1388 | return fib4(n - 1).flatMap((a) => fib4(n - 2).map((b) => a + b)); 1389 | }; 1390 | 1391 | expect(fib1(10).runSync()).toBe(55); 1392 | expect(fib2(10).runSync()).toBe(55); 1393 | expect(fib3(10).runSync()).toBe(55); 1394 | } 1395 | 1396 | { 1397 | type User = { id: number; name: string; role: "admin" | "user" }; 1398 | 1399 | const println = effect("println"); 1400 | const executeSQL = effect("executeSQL")<[sql: string, ...params: unknown[]], any>; 1401 | const askCurrentUser = dependency("currentUser"); 1402 | const authenticationError = error("authentication"); 1403 | const unauthorizedError = error("unauthorized"); 1404 | 1405 | const requiresAdmin = () => 1406 | effected(function* () { 1407 | const currentUser = yield* askCurrentUser(); 1408 | if (!currentUser) return yield* authenticationError(); 1409 | if (currentUser.role !== "admin") 1410 | return yield* unauthorizedError(`User "${currentUser.name}" is not an admin`); 1411 | }); 1412 | 1413 | const alice: Omit = { name: "Alice", role: "user" }; 1414 | 1415 | const createUser = (user: Omit) => 1416 | requiresAdmin() 1417 | .andThen(() => 1418 | executeSQL("INSERT INTO users (name) VALUES (?)", user.name).andThen( 1419 | (id) => ({ id, ...user }) as User, 1420 | ), 1421 | ) 1422 | .tap((savedUser) => println("User created:", savedUser)); 1423 | 1424 | const logs: unknown[][] = []; 1425 | let sqlExecuted = false; 1426 | 1427 | // Test with admin user 1428 | const result = createUser(alice) 1429 | .resume("executeSQL", (sql, ...params) => { 1430 | expect(sql).toBe("INSERT INTO users (name) VALUES (?)"); 1431 | expect(params[0]).toBe("Alice"); 1432 | sqlExecuted = true; 1433 | return 42; 1434 | }) 1435 | .resume("println", (...args) => { 1436 | logs.push(args); 1437 | }) 1438 | .provide("currentUser", { id: 1, name: "Charlie", role: "admin" }) 1439 | .catch("authentication", () => { 1440 | throw new Error("Should not hit this path"); 1441 | }) 1442 | .catch("unauthorized", () => { 1443 | throw new Error("Should not hit this path"); 1444 | }) 1445 | .runSync(); 1446 | 1447 | expect(sqlExecuted).toBe(true); 1448 | expect(logs).toEqual([["User created:", { id: 42, name: "Alice", role: "user" }]]); 1449 | expect(result).toEqual({ id: 42, name: "Alice", role: "user" }); 1450 | } 1451 | 1452 | // Test Effected.of and Effected.from examples 1453 | { 1454 | const println = effect("println")<[message: string], void>; 1455 | 1456 | const printlnLogs: unknown[][] = []; 1457 | 1458 | const program1 = Effected.of("Hello, world!") 1459 | .tap((message) => println(message)) 1460 | .andThen((message) => message.toUpperCase()); 1461 | 1462 | const resultProgram1 = program1 1463 | .resume("println", (...args) => { 1464 | printlnLogs.push(args); 1465 | }) 1466 | .runSync(); 1467 | 1468 | expect(printlnLogs).toEqual([["Hello, world!"]]); 1469 | expect(resultProgram1).toBe("HELLO, WORLD!"); 1470 | 1471 | // Test .as and .asVoid methods 1472 | expect(Effected.of(42).as("Hello, world!").runSync()).toBe("Hello, world!"); 1473 | expect(Effected.of(42).asVoid().runSync()).toBe(undefined); 1474 | } 1475 | 1476 | // Test combining effects with zip 1477 | { 1478 | type User = { id: number; name: string; role: "admin" | "user" }; 1479 | const askCurrentUser = dependency("currentUser"); 1480 | 1481 | const getUserName = askCurrentUser().map((user) => user?.name || "Guest"); 1482 | const askTheme = dependency("theme")<"light" | "dark">; 1483 | 1484 | const welcomeMessage1 = getUserName 1485 | .zip(askTheme()) 1486 | .map(([username, theme]) => `Welcome ${username}! Using ${theme} theme.`); 1487 | 1488 | expect( 1489 | welcomeMessage1 1490 | .provide("currentUser", { id: 1, name: "Alice", role: "admin" }) 1491 | .provide("theme", "dark") 1492 | .runSync(), 1493 | ).toBe("Welcome Alice! Using dark theme."); 1494 | 1495 | const welcomeMessage2 = getUserName.zip( 1496 | askTheme(), 1497 | (username, theme) => `Welcome ${username}! Using ${theme} theme.`, 1498 | ); 1499 | expect( 1500 | welcomeMessage2 1501 | .provide("currentUser", { id: 1, name: "Alice", role: "admin" }) 1502 | .provide("theme", "dark") 1503 | .runSync(), 1504 | ).toBe("Welcome Alice! Using dark theme."); 1505 | } 1506 | 1507 | // Test `.pipe(...fs)` 1508 | { 1509 | type Sleep = Default>; 1510 | const sleep: EffectFactory = effect("sleep", { 1511 | defaultHandler: ({ resume }, ms) => { 1512 | setTimeout(resume, ms); 1513 | }, 1514 | }); 1515 | 1516 | const delay = 1517 | (ms: number) => 1518 | (self: Effected): Effected => 1519 | sleep(ms).andThen(() => self); 1520 | 1521 | const withLog = 1522 | (message: string) => 1523 | (self: Effected): Effected => 1524 | self.tap((value) => { 1525 | console.log(`${message}: ${String(value)}`); 1526 | }); 1527 | 1528 | expect(await Effected.of(42).pipe(delay(100)).runAsync()).toBe(42); 1529 | 1530 | const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); 1531 | expect(await Effected.of(42).pipe(delay(100), withLog("Result")).runAsync()).toBe(42); 1532 | expect(logSpy).toHaveBeenCalledOnce(); 1533 | expect(logSpy.mock.calls[0]![0]).toBe("Result: 42"); 1534 | logSpy.mockRestore(); 1535 | } 1536 | }); 1537 | 1538 | test("Pipeline Syntax VS Generator Syntax", async () => { 1539 | const fetchUser = effect("fetchUser")<[userId: number], { id: number; name: string } | null>; 1540 | const fetchPosts = effect("fetchPosts")<[userId: number], { id: number; title: string }[]>; 1541 | const readFile = effect("readFile")<[path: string], string>; 1542 | const parseContent = effect("parseContent")<[content: string], any>; 1543 | const logger = { 1544 | error: effect("logger.error"), 1545 | }; 1546 | 1547 | // Test generator syntax vs pipeline syntax for getUserPosts 1548 | { 1549 | // Generator syntax 1550 | const getUserPostsGen = (userId: number) => 1551 | effected(function* () { 1552 | const user = yield* fetchUser(userId); 1553 | if (!user) return null; 1554 | return yield* fetchPosts(user.id); 1555 | }); 1556 | 1557 | // Pipeline syntax 1558 | const getUserPostsPipe = (userId: number) => 1559 | fetchUser(userId).andThen((user) => { 1560 | if (!user) return null; 1561 | return fetchPosts(user.id); 1562 | }); 1563 | 1564 | // Test getUserPosts - user exists 1565 | const userPostsGen = getUserPostsGen(1) 1566 | .resume("fetchUser", (userId) => { 1567 | expect(userId).toBe(1); 1568 | return { id: 1, name: "Test User" }; 1569 | }) 1570 | .resume("fetchPosts", (userId) => { 1571 | expect(userId).toBe(1); 1572 | return [{ id: 101, title: "Test Post" }]; 1573 | }) 1574 | .runSync(); 1575 | 1576 | expect(userPostsGen).toEqual([{ id: 101, title: "Test Post" }]); 1577 | 1578 | const userPostsPipe = getUserPostsPipe(1) 1579 | .resume("fetchUser", (userId) => { 1580 | expect(userId).toBe(1); 1581 | return { id: 1, name: "Test User" }; 1582 | }) 1583 | .resume("fetchPosts", (userId) => { 1584 | expect(userId).toBe(1); 1585 | return [{ id: 101, title: "Test Post" }]; 1586 | }) 1587 | .runSync(); 1588 | 1589 | expect(userPostsPipe).toEqual([{ id: 101, title: "Test Post" }]); 1590 | 1591 | // Test getUserPosts - user doesn't exist 1592 | const nullUserPostsGen = getUserPostsGen(999) 1593 | .resume("fetchUser", () => null) 1594 | .resume("fetchPosts", () => { 1595 | throw new Error("Should not be called"); 1596 | }) 1597 | .runSync(); 1598 | 1599 | expect(nullUserPostsGen).toBeNull(); 1600 | 1601 | const nullUserPostsPipe = getUserPostsPipe(999) 1602 | .resume("fetchUser", () => null) 1603 | .resume("fetchPosts", () => { 1604 | throw new Error("Should not be called"); 1605 | }) 1606 | .runSync(); 1607 | 1608 | expect(nullUserPostsPipe).toBeNull(); 1609 | } 1610 | 1611 | // Test error handling example 1612 | { 1613 | // Generator syntax 1614 | const processFileGen = (path: string) => 1615 | effected(function* () { 1616 | const content = yield* readFile(path); 1617 | return yield* parseContent(content); 1618 | }).catchAll(function* (error: string, message) { 1619 | yield* logger.error(`[${error}Error] Error processing ${path}:`, message); 1620 | return null; 1621 | }); 1622 | 1623 | // Pipeline syntax 1624 | const processFilePipe = (path: string) => 1625 | readFile(path) 1626 | .andThen((content) => parseContent(content)) 1627 | .catchAll((error: string, message) => 1628 | logger.error(`[${error}Error] Error processing ${path}:`, message).as(null), 1629 | ); 1630 | 1631 | // Test successful case 1632 | const errorLogs: string[][] = []; 1633 | 1634 | const processResult = await processFileGen("config.json") 1635 | .resume("readFile", (path) => { 1636 | expect(path).toBe("config.json"); 1637 | return '{"key": "value"}'; 1638 | }) 1639 | .resume("parseContent", (content) => { 1640 | expect(content).toBe('{"key": "value"}'); 1641 | return { key: "value" }; 1642 | }) 1643 | .resume("logger.error", (...args) => { 1644 | errorLogs.push(args.map(String)); 1645 | }) 1646 | .runAsync(); 1647 | 1648 | expect(processResult).toEqual({ key: "value" }); 1649 | expect(errorLogs).toEqual([]); 1650 | 1651 | // Test error case 1652 | errorLogs.length = 0; 1653 | const errorEffect = error("parse"); 1654 | 1655 | const errorResult = await processFilePipe("bad.json") 1656 | .resume("readFile", () => "invalid-json") 1657 | .resume("parseContent", () => errorEffect("Invalid JSON format")) 1658 | .resume("logger.error", (...args) => { 1659 | errorLogs.push(args.map(String)); 1660 | }) 1661 | .catch("parse", () => { 1662 | errorLogs.push(["[parseError] Error processing bad.json: Invalid JSON format"]); 1663 | return null; 1664 | }) 1665 | .runAsync(); 1666 | 1667 | expect(errorResult).toBeNull(); 1668 | expect(errorLogs.length).toBe(1); 1669 | expect(errorLogs[0]![0]).toBe("[parseError] Error processing bad.json: Invalid JSON format"); 1670 | } 1671 | 1672 | { 1673 | type Order = { id: string; items: { id: string; quantity: number }[] }; 1674 | 1675 | // Define required effects 1676 | const askConfig = dependency("config")<{ apiUrl: string }>; 1677 | const askCurrentUser = dependency("currentUser")<{ id: number; email: string }>; 1678 | const validateOrder = effect("validateOrder")<[order: Order, user: { id: number }], void>; 1679 | const saveOrder = effect("saveOrder")<[order: Order, apiUrl: string], { orderId: string }>; 1680 | const sendNotification = effect("sendNotification")<[email: string, message: string], void>; 1681 | 1682 | // Test order 1683 | const testOrder: Order = { 1684 | id: "order-123", 1685 | items: [{ id: "item-1", quantity: 2 }], 1686 | }; 1687 | 1688 | // Implementation with generator syntax 1689 | const submitOrderGen = (order: Order) => 1690 | effected(function* () { 1691 | const [config, user] = yield* Effected.all([askConfig(), askCurrentUser()]); 1692 | yield* validateOrder(order, user); 1693 | const result = yield* saveOrder(order, config.apiUrl); 1694 | yield* sendNotification(user.email, "Order submitted"); 1695 | return result; 1696 | }); 1697 | 1698 | // Implementation with pipeline syntax 1699 | const submitOrderPipe = (order: Order) => 1700 | Effected.all([askConfig(), askCurrentUser()]).andThen(([config, user]) => 1701 | validateOrder(order, user).andThen(() => 1702 | saveOrder(order, config.apiUrl).tap(() => 1703 | sendNotification(user.email, "Order submitted").asVoid(), 1704 | ), 1705 | ), 1706 | ); 1707 | 1708 | // Test configuration and user 1709 | const testConfig = { apiUrl: "https://api.example.com/orders" }; 1710 | const testUser = { id: 42, email: "user@example.com" }; 1711 | const orderResult = { orderId: "ORD-123456" }; 1712 | 1713 | // Track effect execution for verification 1714 | const executionLog: string[] = []; 1715 | 1716 | // Test generator implementation 1717 | const genResult = await submitOrderGen(testOrder) 1718 | .provide("config", testConfig) 1719 | .provide("currentUser", testUser) 1720 | .resume("validateOrder", (order, user) => { 1721 | executionLog.push(`validateOrder: ${order.id}, userId: ${user.id}`); 1722 | }) 1723 | .resume("saveOrder", (order, apiUrl) => { 1724 | executionLog.push(`saveOrder: ${order.id}, apiUrl: ${apiUrl}`); 1725 | return orderResult; 1726 | }) 1727 | .resume("sendNotification", (email, message) => { 1728 | executionLog.push(`sendNotification: ${email}, message: ${message}`); 1729 | }) 1730 | .runAsync(); 1731 | 1732 | // Verify generator implementation results 1733 | expect(genResult).toEqual(orderResult); 1734 | expect(executionLog).toEqual([ 1735 | "validateOrder: order-123, userId: 42", 1736 | "saveOrder: order-123, apiUrl: https://api.example.com/orders", 1737 | "sendNotification: user@example.com, message: Order submitted", 1738 | ]); 1739 | 1740 | // Clear logs and test pipeline implementation 1741 | executionLog.length = 0; 1742 | 1743 | const pipeResult = await submitOrderPipe(testOrder) 1744 | .provide("config", testConfig) 1745 | .provide("currentUser", testUser) 1746 | .resume("validateOrder", (order, user) => { 1747 | executionLog.push(`validateOrder: ${order.id}, userId: ${user.id}`); 1748 | }) 1749 | .resume("saveOrder", (order, apiUrl) => { 1750 | executionLog.push(`saveOrder: ${order.id}, apiUrl: ${apiUrl}`); 1751 | return orderResult; 1752 | }) 1753 | .resume("sendNotification", (email, message) => { 1754 | executionLog.push(`sendNotification: ${email}, message: ${message}`); 1755 | }) 1756 | .runAsync(); 1757 | 1758 | // Verify pipeline implementation results 1759 | expect(pipeResult).toEqual(orderResult); 1760 | expect(executionLog).toEqual([ 1761 | "validateOrder: order-123, userId: 42", 1762 | "saveOrder: order-123, apiUrl: https://api.example.com/orders", 1763 | "sendNotification: user@example.com, message: Order submitted", 1764 | ]); 1765 | 1766 | // Verify both implementations produce the same results 1767 | expect(pipeResult).toEqual(genResult); 1768 | } 1769 | }); 1770 | 1771 | test("Example: Build a configurable logging system with effects", async () => { 1772 | const logs: [string, ...unknown[]][] = []; 1773 | const mockConsole = { 1774 | debug: (...args: unknown[]) => void logs.push(["debug", ...args]), 1775 | info: (...args: unknown[]) => void logs.push(["info", ...args]), 1776 | warn: (...args: unknown[]) => void logs.push(["warn", ...args]), 1777 | error: (...args: unknown[]) => void logs.push(["error", ...args]), 1778 | }; 1779 | 1780 | interface Logger { 1781 | debug: (...args: unknown[]) => void | Promise; 1782 | info: (...args: unknown[]) => void | Promise; 1783 | warn: (...args: unknown[]) => void | Promise; 1784 | error: (...args: unknown[]) => void | Promise; 1785 | } 1786 | type LoggerDependency = Default>; 1787 | const askLogger = dependency()("logger", () => mockConsole); 1788 | 1789 | type Logging = 1790 | | Default, never, LoggerDependency> 1791 | | Default, never, LoggerDependency> 1792 | | Default, never, LoggerDependency> 1793 | | Default, never, LoggerDependency>; 1794 | 1795 | const logLevels = ["debug", "info", "warn", "error"] as const; 1796 | type LogLevel = (typeof logLevels)[number]; 1797 | 1798 | const logEffect = (level: LogLevel): EffectFactory => 1799 | effect(`logging.${level}`, { 1800 | *defaultHandler({ resume }, ...args) { 1801 | const logger = yield* askLogger(); 1802 | const result = logger[level](...args); 1803 | if (result instanceof Promise) void result.then(resume); 1804 | else resume(); 1805 | }, 1806 | }); 1807 | 1808 | const logDebug = logEffect("debug"); 1809 | const logInfo = logEffect("info"); 1810 | const logWarn = logEffect("warn"); 1811 | const logError = logEffect("error"); 1812 | 1813 | function withPrefix(prefixFactory: (level: LogLevel) => string) { 1814 | return defineHandlerFor().with((self) => 1815 | self.handle( 1816 | (name): name is Logging["name"] => typeof name === "string" && name.startsWith("logging."), 1817 | function* ({ effect, resume }): Generator { 1818 | const prefix = prefixFactory(effect.name.slice("logging.".length) as LogLevel); 1819 | effect.payloads.splice(0, 0, prefix); 1820 | yield effect; 1821 | resume(); 1822 | }, 1823 | ), 1824 | ); 1825 | } 1826 | 1827 | function withMinimumLogLevel(level: LogLevel | "none") { 1828 | return defineHandlerFor().with((self) => { 1829 | const disabledLevels = new Set( 1830 | level === "none" ? logLevels : logLevels.slice(0, logLevels.indexOf(level)), 1831 | ); 1832 | return self.handle( 1833 | (name): name is Logging["name"] => 1834 | typeof name === "string" && 1835 | name.startsWith("logging.") && 1836 | disabledLevels.has(name.slice("logging.".length) as LogLevel), 1837 | function* ({ effect, resume }): Generator { 1838 | effect.defaultHandler = ({ resume }) => resume(); 1839 | yield effect; 1840 | resume(); 1841 | }, 1842 | ); 1843 | }); 1844 | } 1845 | 1846 | const program1 = effected(function* () { 1847 | yield* logDebug("Debug message"); 1848 | yield* logInfo("Info message"); 1849 | yield* logWarn("Warning!"); 1850 | yield* logError("Error occurred!"); 1851 | }); 1852 | await program1.runAsync(); 1853 | expect(logs).toEqual([ 1854 | ["debug", "Debug message"], 1855 | ["info", "Info message"], 1856 | ["warn", "Warning!"], 1857 | ["error", "Error occurred!"], 1858 | ]); 1859 | logs.length = 0; 1860 | 1861 | const program2 = effected(function* () { 1862 | yield* logDebug("Debug message"); 1863 | yield* logInfo("Info message"); 1864 | yield* logWarn("Warning!"); 1865 | yield* logError("Error occurred!"); 1866 | }).pipe(withMinimumLogLevel("warn")); 1867 | await program2.runAsync(); 1868 | expect(logs).toEqual([ 1869 | ["warn", "Warning!"], 1870 | ["error", "Error occurred!"], 1871 | ]); 1872 | logs.length = 0; 1873 | 1874 | const date = new Date(); 1875 | const yyyy = date.getFullYear(); 1876 | const MM = String(date.getMonth() + 1).padStart(2, "0"); 1877 | const dd = String(date.getDate()).padStart(2, "0"); 1878 | const HH = String(date.getHours()).padStart(2, "0"); 1879 | const mm = String(date.getMinutes()).padStart(2, "0"); 1880 | const ss = String(date.getSeconds()).padStart(2, "0"); 1881 | const ms = String(date.getMilliseconds()).padEnd(3, "0"); 1882 | const dateString = `${yyyy}-${MM}-${dd} ${HH}:${mm}:${ss}.${ms}`; 1883 | 1884 | function logPrefix(level: LogLevel) { 1885 | const datePart = `[${dateString}]`; 1886 | const levelPart = `[${level}]`; 1887 | return `${datePart} ${levelPart}`; 1888 | } 1889 | 1890 | const program3 = effected(function* () { 1891 | yield* logDebug("Debug message"); 1892 | yield* logInfo("Info message"); 1893 | yield* logWarn("Warning!"); 1894 | yield* logError("Error occurred!"); 1895 | }).pipe(withMinimumLogLevel("warn"), withPrefix(logPrefix)); 1896 | await program3.runAsync(); 1897 | expect(logs).toEqual([ 1898 | ["warn", `[${dateString}] [warn]`, "Warning!"], 1899 | ["error", `[${dateString}] [error]`, "Error occurred!"], 1900 | ]); 1901 | logs.length = 0; 1902 | 1903 | const fileLogs: unknown[][] = []; 1904 | function fileLogger(path: string) { 1905 | return new Proxy({} as Logger, { 1906 | get(_, prop) { 1907 | // eslint-disable-next-line @typescript-eslint/require-await 1908 | return async (...args: unknown[]) => { 1909 | fileLogs.push([path, prop, ...args]); 1910 | }; 1911 | }, 1912 | }); 1913 | } 1914 | 1915 | const program4 = effected(function* () { 1916 | yield* logDebug("Debug message"); 1917 | yield* logInfo("Info message"); 1918 | yield* logWarn("Warning!"); 1919 | yield* logError("Error occurred!"); 1920 | }) 1921 | .pipe(withMinimumLogLevel("warn"), withPrefix(logPrefix)) 1922 | .provide("logger", fileLogger("log.txt")); 1923 | await program4.runAsync(); 1924 | expect(fileLogs).toEqual([ 1925 | ["log.txt", "warn", `[${dateString}] [warn]`, "Warning!"], 1926 | ["log.txt", "error", `[${dateString}] [error]`, "Error occurred!"], 1927 | ]); 1928 | fileLogs.length = 0; 1929 | 1930 | function dualLogger(logger1: Logger, logger2: Logger) { 1931 | return new Proxy({} as Logger, { 1932 | get(_, prop, receiver) { 1933 | return (...args: unknown[]) => { 1934 | const result1 = Reflect.get(logger1, prop, receiver)(...args); 1935 | const result2 = Reflect.get(logger2, prop, receiver)(...args); 1936 | if (result1 instanceof Promise && result2 instanceof Promise) 1937 | return Promise.all([result1, result2]); 1938 | else if (result1 instanceof Promise) return result1; 1939 | else if (result2 instanceof Promise) return result2; 1940 | }; 1941 | }, 1942 | }); 1943 | } 1944 | 1945 | const program5 = effected(function* () { 1946 | yield* logDebug("Debug message"); 1947 | yield* logInfo("Info message"); 1948 | yield* logWarn("Warning!"); 1949 | yield* logError("Error occurred!"); 1950 | }) 1951 | .pipe(withMinimumLogLevel("warn"), withPrefix(logPrefix)) 1952 | .provide("logger", dualLogger(mockConsole, fileLogger("log.txt"))); 1953 | await program5.runAsync(); 1954 | expect(logs).toEqual([ 1955 | ["warn", `[${dateString}] [warn]`, "Warning!"], 1956 | ["error", `[${dateString}] [error]`, "Error occurred!"], 1957 | ]); 1958 | expect(fileLogs).toEqual([ 1959 | ["log.txt", "warn", `[${dateString}] [warn]`, "Warning!"], 1960 | ["log.txt", "error", `[${dateString}] [error]`, "Error occurred!"], 1961 | ]); 1962 | logs.length = 0; 1963 | fileLogs.length = 0; 1964 | }); 1965 | --------------------------------------------------------------------------------