From 8d525d53924dfb3298d9bb21e856058fd1e28205 Mon Sep 17 00:00:00 2001 From: rowan Date: Wed, 26 Mar 2025 21:28:33 -0500 Subject: [PATCH] initial commit --- .gitignore | 2 ++ package-lock.json | 22 ++++++++++++++++++++++ package.json | 16 ++++++++++++++++ src/either.js | 33 ++++++++++++++++++++++++++++++++ src/identity.js | 7 +++++++ src/index.js | 7 +++++++ src/monad.js | 17 +++++++++++++++++ src/option.js | 29 ++++++++++++++++++++++++++++ src/result.js | 35 ++++++++++++++++++++++++++++++++++ src/union.js | 44 +++++++++++++++++++++++++++++++++++++++++++ src/utils.js | 3 +++ tests/index.js | 22 ++++++++++++++++++++++ tests/units/index.js | 5 +++++ tests/units/monad.js | 33 ++++++++++++++++++++++++++++++++ tests/units/option.js | 26 +++++++++++++++++++++++++ tests/units/result.js | 37 ++++++++++++++++++++++++++++++++++++ tests/units/union.js | 39 ++++++++++++++++++++++++++++++++++++++ 17 files changed, 377 insertions(+) create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/either.js create mode 100644 src/identity.js create mode 100644 src/index.js create mode 100644 src/monad.js create mode 100644 src/option.js create mode 100644 src/result.js create mode 100644 src/union.js create mode 100644 src/utils.js create mode 100755 tests/index.js create mode 100644 tests/units/index.js create mode 100644 tests/units/monad.js create mode 100644 tests/units/option.js create mode 100644 tests/units/result.js create mode 100644 tests/units/union.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d570088 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..25ef34f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,22 @@ +{ + "name": "kojima", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kojima", + "version": "1.0.0", + "license": "GPL-3.0-or-later", + "devDependencies": { + "folktest": "git+https://git.kitsu.cafe/rowan/folktest.git" + } + }, + "node_modules/folktest": { + "version": "1.0.0", + "resolved": "git+https://git.kitsu.cafe/rowan/folktest.git#1e03ea78b2fab14af89cfb7bd43ed9f384e513c6", + "dev": true, + "license": "GPL-3.0-or-later" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2903b93 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "kojima", + "type": "module", + "author": "Rowan (https://kitsu.cafe)", + "version": "1.0.0", + "description": "", + "main": "src/index.js", + "scripts": { + "test": "./tests/index.js" + }, + "keywords": ["functional", "functional programming", "fp", "monad"], + "license": "GPL-3.0-or-later", + "devDependencies": { + "folktest": "git+https://git.kitsu.cafe/rowan/folktest.git" + } +} diff --git a/src/either.js b/src/either.js new file mode 100644 index 0000000..42d7391 --- /dev/null +++ b/src/either.js @@ -0,0 +1,33 @@ +import { toList } from './utils.js' + +/** + * @template L, R + * @typedef {Left | Right} Either + */ + +/** + * @template T + * @constructor + * @param {T} value + */ +export const Left = value => ({ + ...Monad(Left, value), + bind: _f => Left(value), + map: _f => Left(value), + isLeft: () => true, + isRight: () => false, + ...Tag('Left', toList(value)) +}) + +/** + * @template T + * @constructor + * @param {T} value + */ +export const Right = value => ({ + ...Monad(Right, value), + isLeft: () => false, + isRight: () => true, + ...Tag('Right', toList(value)) +}) + diff --git a/src/identity.js b/src/identity.js new file mode 100644 index 0000000..070a20b --- /dev/null +++ b/src/identity.js @@ -0,0 +1,7 @@ +/** + * @template T + * @constructor + * @param {T} value + */ +export const Identity = value => Monad(Identity, value) + diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..acce0a2 --- /dev/null +++ b/src/index.js @@ -0,0 +1,7 @@ +export { Union, UnmatchedTagError } from './union.js' +export { Identity } from './identity.js' +export { Left, Right } from './either.js' +export { Some, None } from './option.js' +export { Ok, Err } from './result.js' + + diff --git a/src/monad.js b/src/monad.js new file mode 100644 index 0000000..aabedcf --- /dev/null +++ b/src/monad.js @@ -0,0 +1,17 @@ +/** + * @template M, T + * @constructor + * @param {(value: T) => M} lift + * @param {T} value + */ +export function Monad(lift, value) { + const name = lift.name + + return { + bind: f => f(value), + map(f) { return lift(this.bind(f)) }, + toString: () => `${name}(${value})` + } +} + + diff --git a/src/option.js b/src/option.js new file mode 100644 index 0000000..7f17c3d --- /dev/null +++ b/src/option.js @@ -0,0 +1,29 @@ +import { Monad } from './monad.js' +import { Tag } from './union.js' +import { emptyList, toList } from './utils.js' + +/** + * @template T + * @typedef {Some | None} Option + */ +export const None = Object.freeze({ + bind: _f => None, + map: _f => None, + isSome: () => false, + isNone: () => true, + ...Tag('None', emptyList), + toString: () => 'None' +}) + +/** + * @template T + * @constructor + * @param {T} value + */ +export const Some = value => ({ + ...Monad(Some, value), + isSome: () => true, + isNone: () => false, + ...Tag('Some', toList(value)) +}) + diff --git a/src/result.js b/src/result.js new file mode 100644 index 0000000..c495a95 --- /dev/null +++ b/src/result.js @@ -0,0 +1,35 @@ +import { Monad } from './monad.js' +import { Tag } from './union.js' +import { toList } from './utils.js' + +/** + * @template T, E + * @typedef {Ok | Err} Result + */ + +/** + * @template T + * @constructor + * @param {T} value + */ +export const Err = value => ({ + ...Monad(Err, value), + bind: _f => Err(value), + map: _f => Err(value), + isError: () => true, + isOk: () => false, + ...Tag('Err', toList(value)) +}) + +/** + * @template T + * @constructor + * @param {T} value + */ +export const Ok = value => ({ + ...Monad(Ok, value), + isError: () => false, + isOk: () => true, + ...Tag('Ok', toList(value)) +}) + diff --git a/src/union.js b/src/union.js new file mode 100644 index 0000000..b8b86f1 --- /dev/null +++ b/src/union.js @@ -0,0 +1,44 @@ +export class UnmatchedTagError extends Error { + constructor(tag) { + super(`unmatched tag in union: ${tag}`) + } +} + +// TODO: verify patterns against union +/** + * @template Type, Value + * @constructor + * @param {Type} type + * @param {Iterable} values + */ +export const Tag = (type, values) => ({ + /** + * @template T + * @typedef {(...args: T[]) => void}) Pattern + * @param {Object.} patterns + * @throws {UnmatchedTagError} + */ + fold: patterns => { + if (type in patterns) { + return patterns[type](...values) + } else if ('_' in patterns) { + return patterns._() + } else { + throw new UnmatchedTagError(type) + } + }, + toString: () => `${type}(${values.join(', ')})` +}) + +export const Union = types => { + const result = {} + + const len = types.length + for (let i = 0; i < len; i++) { + const type = types[i] + result[type] = (...values) => Tag(type, values) + } + + return result +} + diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..a69a0a6 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,3 @@ +export const emptyList = [] +export const toList = x => Array.isArray(x) ? x : [x] + diff --git a/tests/index.js b/tests/index.js new file mode 100755 index 0000000..5b5de71 --- /dev/null +++ b/tests/index.js @@ -0,0 +1,22 @@ +#!/usr/bin/env node + +import * as Tests from './units/index.js' + +const ap = f => f() +const fmt = ({ success, description, error }) => { + if (success) { + return `${description}: PASS` + } else { + return `${description}: FAIL\n${error}` + } +} + +const results = Object.entries(Tests).map(([name, tests]) => ({ + name, + tests: tests.map(ap).map(fmt).join('\n') +})) + .map(({ name, tests }) => `${name}\n${tests}`) + .join('\n') + +console.log(results) + diff --git a/tests/units/index.js b/tests/units/index.js new file mode 100644 index 0000000..5b84eff --- /dev/null +++ b/tests/units/index.js @@ -0,0 +1,5 @@ +export { Tests as Monad } from './monad.js' +export { Tests as Option } from './option.js' +export { Tests as Result } from './result.js' +export { Tests as Union } from './union.js' + diff --git a/tests/units/monad.js b/tests/units/monad.js new file mode 100644 index 0000000..ce8b4b0 --- /dev/null +++ b/tests/units/monad.js @@ -0,0 +1,33 @@ +import { it, assertEq } from 'folktest' +import { Monad } from '../../src/monad.js' + +const leftIdentity = (M, a, f) => { + assertEq(M(a).bind(f), f(a)) +} + +const rightIdentity = (M, m) => { + assertEq(m.bind(M), m) +} + +const associativity = (m, f, g) => { + assertEq(m.bind(f).bind(g), m.bind(x => f(x).bind(g))) +} + +export const prove = (M, m, a, f, g) => { + leftIdentity(M, a, f) + rightIdentity(M, m) + associativity(m, f, g) +} + +export const Tests = [ + it('should adhere to monadic laws', () => { + const ctr = Monad.bind(undefined, Monad) + prove( + ctr, + ctr(1), + 1, + x => ctr(x + 1), + x => ctr(x * 2) + ) + }) +] diff --git a/tests/units/option.js b/tests/units/option.js new file mode 100644 index 0000000..06316e2 --- /dev/null +++ b/tests/units/option.js @@ -0,0 +1,26 @@ +import { it, assertEq } from 'folktest' +import { Some, None } from '../../src/option.js' +import { prove } from './monad.js' + +const id = x => () => x +export const Tests = [ + it('some should pass monadic laws', () => { + prove( + Some, + Some(1), + 1, + x => Some(x + 1), + x => Some(x * 2) + ) + }), + + it('should not bind none', () => { + const opt = Some(1) + .bind(id(None)) + .bind(id(Some(1))) + .map(x => x + 1) + + assertEq(opt, None) + }), +] + diff --git a/tests/units/result.js b/tests/units/result.js new file mode 100644 index 0000000..08ee55e --- /dev/null +++ b/tests/units/result.js @@ -0,0 +1,37 @@ +import { it, assertEq } from 'folktest' +import { Ok, Err } from '../../src/result.js' +import { prove } from './monad.js' + +const id = x => () => x +export const Tests = [ + it('should pass monadic laws', () => { + prove( + Ok, + Ok(1), + 1, + x => Ok(x + 1), + x => Ok(x * 2) + ) + + const e = new Error() + prove( + Err, + Err(e), + e, + x => Err(new Error()), + x => Err(new Error()) + ) + }), + + it('should not bind err', () => { + const e = new Error() + const result = Ok(1) + .bind(id(Err(e))) + .bind(id(Ok(1))) + .map(x => x + 1) + + assertEq(result, Err(e)) + }), +] + + diff --git a/tests/units/union.js b/tests/units/union.js new file mode 100644 index 0000000..9cafac9 --- /dev/null +++ b/tests/units/union.js @@ -0,0 +1,39 @@ +import { it, assert, assertEq } from 'folktest' +import { Union } from '../../src/union.js' + +export const Tests = [ + it('should create a valid tagged union', () => { + const union = Union(['Nothing', 'Void']) + + assert(Object.hasOwn(union, 'Nothing')) + assert(Object.hasOwn(union, 'Void')) + }), + + it('can be matched', () => { + const union = Union(['Adrift', 'Nonextant', 'Dream']) + const dreaming = union.Dream('mercury') + const drifting = union.Adrift('19589') + const gone = union.Nonextant('rowan', Infinity) + + dreaming.fold({ + Dream(name) { + assertEq(name, 'mercury') + } + }) + + drifting.fold({ + Adrift(name) { + assertEq(name, '19589') + } + }) + + gone.fold({ + Nonextant(name, when) { + assertEq(name, 'rowan') + assertEq(when, Infinity) + } + }) + }) +] + +