├── .gitignore ├── README.md ├── babel.config.js ├── package.json ├── src ├── InferredGenerator.ts ├── index.ts └── util.ts ├── test ├── javascript.test.js └── typescript.test.ts ├── tsconfig.json └── tslint.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | node_modules/ 4 | dist/ 5 | tsconfig.tsbuildinfo 6 | yarn.lock 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Algebraify [![npm](https://img.shields.io/npm/v/algebraify?style=for-the-badge)](https://www.npmjs.com/package/algebraify) [![GitHub issues](https://img.shields.io/github/issues/protowalker/algebraify?style=for-the-badge)](https://github.com/Protowalker/algebraify/issues) 2 | 3 | ## Algebraic Effects Are Here! (sort of) 4 | 5 | (If you're unfamiliar with algebraic effects, here's a great article: [Algebraic Effects for the Rest of Us](https://overreacted.io/algebraic-effects-for-the-rest-of-us/)) 6 | 7 | This is a single-goal library that utilizes generators to add algebraic effects to javascript and typescript. 8 | 9 | ## Usage 10 | Add it to your project by using `npm install algebraify` or `yarn add algebraify`, in case you didn't know. 11 | 12 | ## Examples 13 | 14 | ### Javascript 15 | #### sync: 16 | ```js 17 | import algebra from "algebraify"; 18 | const getUser = algebra(function* getUser(_, id) { 19 | 20 | const name = getNameOfUser(id) ?? (yield "name"); 21 | const age = getAgeOfUser(id) ?? (yield "age"); 22 | 23 | return `USER ${name}: ${age} years old`; 24 | }); 25 | 26 | const userString = getUser(100) 27 | .case("name", "John Smith") 28 | .case("age", 18) 29 | .do(); 30 | // userString will fallback to using the name john smith and the age 18 if those respective calls fail 31 | 32 | ``` 33 | 34 | 35 | #### async: 36 | ```js 37 | // Just change to an async generator function 38 | const getUser = algebra(async function* getUser(_, id) { 39 | 40 | const name = await getNameOfUser(id) ?? (yield "name"); 41 | const age = await getAgeOfUser(id) ?? (yield "age"); 42 | 43 | return `USER ${name}: ${age} years old`; 44 | }); 45 | 46 | // And then await the promise returned by do() 47 | const userString = await getUser(100) 48 | .case("name", "John Smith") 49 | .case("age", 18) 50 | .do(); 51 | ``` 52 | 53 | 54 | ### Typescript 55 | ```ts 56 | import algebra from "algebraify"; 57 | const getUser = algebra(function* getUser(request, id: number) { 58 | 59 | // Note the calls to request and subsequent calls to as 60 | const name = getNameOfUser(id) ?? (yield* request("name").as()); 61 | const age = getAgeOfUser(id) ?? (yield* request("age").as()); 62 | 63 | return `USER ${name}: ${age} years old`; 64 | }); 65 | 66 | const userString = getUser(100) 67 | .case("name", "John Smith") 68 | .case("age", 18) 69 | .do(); 70 | 71 | // userString will have the type `USER: ${string}: ${number} years old` 72 | 73 | // Async changes are identical in ts 74 | ``` 75 | 76 | The request parameter is a function that returns a narrowly typed iterator. You don't need to know the details of how it works to use it; `yield* request("name").as()` is basically the same as doing `yield "name"`, but using some type magic to tell typescript to trust us on the return type. 77 | 78 | ## Future Plans 79 | I'm trying to find an ergonomic way to pass effects up the stack -- currently I don't have many ideas, and the ones I do have are vague. I also worry about coloring the stack a lot ([What Color Is Your Function?](https://journal.stuffwithstuff.com/2015/02/01/what-color-is-your-function/)). If you have any ideas, no matter how unsure you are of them, please feel free to make an issue for discussion or even send me a message on discord (@protowalker). 80 | 81 | ## Contributing 82 | Please contribute! I've never made a library that I wanted other people to use before, and my experience with JS/TS is small compared to my programming career. I'd appreciate it a ton! 83 | 84 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ["@babel/preset-env", { targets: { node: "current" } }], 4 | "@babel/preset-typescript", 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "algebraify", 3 | "version": "0.2.2", 4 | "main": "./dist/index.js", 5 | "types": "./dist/index.d.ts", 6 | "license": "MIT", 7 | "typings": "./dist/index.d.ts", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/Protowalker/algebraify" 11 | }, 12 | "files": [ 13 | "/dist" 14 | ], 15 | "scripts": { 16 | "start": "tsc && node ./dist/index.js", 17 | "build": "tsc", 18 | "lint": "tslint -c tslint.json src/**/*.ts", 19 | "prepublish": "npm run build" 20 | }, 21 | "devDependencies": { 22 | "@babel/core": "^7.16.0", 23 | "@babel/preset-env": "^7.16.4", 24 | "@babel/preset-typescript": "^7.16.0", 25 | "@types/jest": "^27.0.3", 26 | "@types/node": "^16.11.7", 27 | "babel-jest": "^27.3.1", 28 | "jest": "^27.3.1", 29 | "tslint": "^6.1.3", 30 | "typescript": "^4.4.4" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/InferredGenerator.ts: -------------------------------------------------------------------------------- 1 | type MaybeAsyncGenerator = 2 | | Generator 3 | | AsyncGenerator; 4 | 5 | type YieldType = Gen extends AsyncGenerator< 6 | unknown, 7 | infer Return, 8 | unknown 9 | > 10 | ? Promise 11 | : Gen extends Generator 12 | ? Return 13 | : never; 14 | 15 | type EffectRequest = Key & { 16 | readonly __type: _Val; 17 | }; 18 | type RequestKey> = 19 | E extends EffectRequest ? `${K}` : never; 20 | 21 | class AlgebraBuilder< 22 | Reqs extends EffectRequest, 23 | FilledReqs extends RequestKey, 24 | Args extends unknown[], 25 | Effector extends ( 26 | request: InstanceType["baseRequest"], 27 | ...args: Args 28 | ) => MaybeAsyncGenerator, 29 | Return 30 | > { 31 | private cases: Partial<{ [Req in Reqs as RequestKey]: Req["__type"] }> = 32 | {}; 33 | 34 | private firstRequest = true; 35 | private key = ""; 36 | private requestIterator: Iterator< 37 | EffectRequest, 38 | unknown, 39 | unknown 40 | > = { 41 | next: (val: unknown) => { 42 | if (this.firstRequest) { 43 | this.firstRequest = false; 44 | return { 45 | done: false as const, 46 | value: this.key as EffectRequest, 47 | }; 48 | } 49 | this.firstRequest = true; 50 | return { done: true as const, value: val }; 51 | }, 52 | }; 53 | 54 | baseRequest = ( 55 | key: Key 56 | ): { 57 | [Symbol.iterator]: () => Iterator, Value, Value>; 58 | as: () => { 59 | [Symbol.iterator]: () => Iterator, Value>; 60 | }; 61 | } => { 62 | this.key = key; 63 | return { 64 | [Symbol.iterator]: () => 65 | this.requestIterator as Iterator< 66 | EffectRequest, 67 | Value, 68 | Value 69 | >, 70 | as: () => ({ 71 | [Symbol.iterator]: () => 72 | this.requestIterator as Iterator, Value>, 73 | }), 74 | }; 75 | }; 76 | 77 | //static baseRequest = (key: Key) => ({ 78 | // *[Symbol.iterator](): Generator, Value, Value> { 79 | // return yield key as EffectRequest; 80 | // }, 81 | // *as(): Generator, Value, Value> { 82 | // return yield key as EffectRequest; 83 | // }, 84 | //}); 85 | // static *baseRequest( 86 | // key: Key 87 | // ): Generator, Value, Value> { 88 | // return yield { key } as EffectRequest; 89 | // } 90 | 91 | constructor(private algebraicEffector: Effector, private args: Args) {} 92 | 93 | public case, FilledReqs>>( 94 | key: Key, 95 | value: (Reqs & Key)["__type"] 96 | ): AlgebraBuilder { 97 | this.cases[key] = value; 98 | return this; 99 | } 100 | 101 | public do(): YieldType>; 102 | public do(): Promise | Return { 103 | let generator = this.algebraicEffector(this.baseRequest, ...this.args); 104 | let currentKey = generator.next(); 105 | if (currentKey instanceof Promise) { 106 | const promise = (async () => { 107 | currentKey = await currentKey; 108 | while (!currentKey.done) { 109 | if (!this.cases.hasOwnProperty(currentKey.value)) { 110 | throw new Error(`missing case "${currentKey.value}"`); 111 | } 112 | 113 | currentKey = await generator.next( 114 | (this.cases as { [key: string]: Reqs["__type"] })[currentKey.value] 115 | ); 116 | } 117 | 118 | return currentKey.value; 119 | })(); 120 | return promise; 121 | } 122 | 123 | generator = generator as Generator; 124 | 125 | while (!currentKey.done) { 126 | if (!this.cases.hasOwnProperty(currentKey.value)) { 127 | throw new Error(`missing case "${currentKey.value}"`); 128 | } 129 | 130 | currentKey = generator.next( 131 | (this.cases as { [key: string]: Reqs["__type"] })[currentKey.value] 132 | ); 133 | } 134 | 135 | return currentKey.value; 136 | } 137 | } 138 | 139 | /** Takes a generator function and returns a builder for an algebraic effector. */ 140 | export function algebra< 141 | Args extends unknown[], 142 | Reqs extends EffectRequest, 143 | Return 144 | >( 145 | algebraicEffector: ( 146 | request: InstanceType["baseRequest"], 147 | ...args: Args 148 | ) => MaybeAsyncGenerator 149 | ): ( 150 | ...args: Args 151 | ) => AlgebraBuilder { 152 | return function (...args: Args) { 153 | return new AlgebraBuilder(algebraicEffector, args); 154 | }; 155 | } 156 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { algebra } from "./InferredGenerator"; 2 | export { algebra }; 3 | export default algebra; 4 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | export function hasProperty( 2 | obj: Obj, 3 | prop: Key 4 | ): obj is Obj & { [key in Key]: unknown } { 5 | return obj.hasOwnProperty(prop); 6 | } 7 | -------------------------------------------------------------------------------- /test/javascript.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | import algebra from "../dist/index.js"; 3 | 4 | test("Should return string interpolated with fallback values", () => { 5 | const getNameOfUser = jest.fn((_) => undefined); 6 | const getAgeOfUser = jest.fn((_) => undefined); 7 | 8 | const getUser = algebra(function* getUser(_, id) { 9 | const name = getNameOfUser(id) ?? (yield "name"); 10 | const age = getAgeOfUser(id) ?? (yield "age"); 11 | 12 | return `USER ${name}: ${age} years old`; 13 | }); 14 | const userString = getUser(100) 15 | .case("name", "John Smith") 16 | .case("age", 18) 17 | .do(); 18 | expect(userString).toBe("USER John Smith: 18 years old"); 19 | }); 20 | 21 | test("Should return string interpolated with fallback values -- ASYNC", async () => { 22 | const getNameOfUser = jest.fn(async (_) => undefined); 23 | const getAgeOfUser = jest.fn(async (_) => undefined); 24 | 25 | const getUser = algebra(async function* getUser(_, id) { 26 | const name = (await getNameOfUser(id)) ?? (yield "name"); 27 | const age = (await getAgeOfUser(id)) ?? (yield "age"); 28 | 29 | return `USER ${name}: ${age} years old`; 30 | }); 31 | const userString = await getUser(100) 32 | .case("name", "John Smith") 33 | .case("age", 18) 34 | .do(); 35 | expect(userString).toBe("USER John Smith: 18 years old"); 36 | }); 37 | 38 | test("Should fail with missing case error", () => { 39 | const getNameOfUser = jest.fn((_) => undefined); 40 | const getAgeOfUser = jest.fn((_) => undefined); 41 | 42 | const getUser = algebra(function* getUser(_, id) { 43 | const name = getNameOfUser(id) ?? (yield "name"); 44 | const age = getAgeOfUser(id) ?? (yield "age"); 45 | const gender = yield "gender"; 46 | 47 | return `USER ${name}: is ${gender} and ${age} years old`; 48 | }); 49 | expect(() => 50 | getUser(100).case("name", "John Smith").case("age", 18).do() 51 | ).toThrow('missing case "gender"'); 52 | }); 53 | 54 | test("Should fail with missing case error -- ASYNC", async () => { 55 | const getNameOfUser = jest.fn((_) => undefined); 56 | const getAgeOfUser = jest.fn((_) => undefined); 57 | 58 | const getUser = algebra(async function* getUser(_, id) { 59 | const name = getNameOfUser(id) ?? (yield "name"); 60 | const age = getAgeOfUser(id) ?? (yield "age"); 61 | const gender = yield "gender"; 62 | 63 | return `USER ${name}: is ${gender} and ${age} years old`; 64 | }); 65 | expect.assertions(1); 66 | await getUser(100) 67 | .case("name", "John Smith") 68 | .case("age", 18) 69 | .do() 70 | .catch((e) => expect(e.message).toBe('missing case "gender"')); 71 | }); 72 | -------------------------------------------------------------------------------- /test/typescript.test.ts: -------------------------------------------------------------------------------- 1 | import algebra from "../dist/index"; 2 | import "jest"; 3 | 4 | test("Should return string interpolated with fallback values", () => { 5 | const getNameOfUser = jest.fn((id): string | undefined => undefined); 6 | const getAgeOfUser = jest.fn((id): number | undefined => undefined); 7 | 8 | const getUser = algebra(function* getUser(request, id: number) { 9 | const name = getNameOfUser(id) ?? (yield* request("name").as()); 10 | const age = getAgeOfUser(id) ?? (yield* request("age").as()); 11 | return `USER ${name}: ${age} years old`; 12 | }); 13 | const userString = getUser(100) 14 | .case("name", "John Smith") 15 | .case("age", 18) 16 | .do(); 17 | expect(userString).toBe("USER John Smith: 18 years old"); 18 | }); 19 | test("Should return string interpolated with fallback values -- ASYNC", async () => { 20 | const getNameOfUser = jest.fn( 21 | async (id): Promise => undefined 22 | ); 23 | const getAgeOfUser = jest.fn( 24 | async (id): Promise => undefined 25 | ); 26 | 27 | const getUser = algebra(async function* getUser(request, id: number) { 28 | const name = 29 | (await getNameOfUser(id)) ?? (yield* request("name").as()); 30 | const age = 31 | (await getAgeOfUser(id)) ?? (yield* request("age").as()); 32 | return `USER ${name}: ${age} years old`; 33 | }); 34 | const userString = await getUser(100) 35 | .case("name", "John Smith") 36 | .case("age", 18) 37 | .do(); 38 | expect(userString).toBe("USER John Smith: 18 years old"); 39 | }); 40 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "lib": ["dom", "es2021"], 7 | 8 | "rootDir": "src", 9 | "outDir": "dist", 10 | "allowJs": true, 11 | "incremental": true, 12 | "strict": true, 13 | "alwaysStrict": true, 14 | "strictFunctionTypes": true, 15 | "strictNullChecks": true, 16 | "strictPropertyInitialization": true, 17 | 18 | "forceConsistentCasingInFileNames": true, 19 | "noImplicitAny": true, 20 | "noImplicitReturns": true, 21 | "noImplicitThis": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUnusedLocals": true, 24 | "noUnusedParameters": true, 25 | 26 | "emitDecoratorMetadata": true, 27 | "experimentalDecorators": true, 28 | "downlevelIteration": true, 29 | "declaration": true, 30 | 31 | "pretty": true 32 | }, 33 | "include": ["typings/**/*", "src/**/*"] 34 | } 35 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:latest" 3 | } 4 | --------------------------------------------------------------------------------