From b1ac745d70b700389b12bb977d9f1e1e8eb6972a Mon Sep 17 00:00:00 2001 From: Oli Sturm Date: Fri, 24 Apr 2026 18:04:54 +0100 Subject: [PATCH] add js-fp3 --- js-fp3/application/accountApp.js | 47 +++++++++++ js-fp3/domain/account.js | 72 +++++++++++++++++ js-fp3/domain/money.js | 16 ++++ js-fp3/index.js | 54 +++++++++++++ js-fp3/infrastructure/repository.js | 69 ++++++++++++++++ js-fp3/library/logging.js | 59 ++++++++++++++ js-fp3/library/result.js | 119 ++++++++++++++++++++++++++++ js-fp3/package.json | 10 +++ 8 files changed, 446 insertions(+) create mode 100644 js-fp3/application/accountApp.js create mode 100644 js-fp3/domain/account.js create mode 100644 js-fp3/domain/money.js create mode 100644 js-fp3/index.js create mode 100644 js-fp3/infrastructure/repository.js create mode 100644 js-fp3/library/logging.js create mode 100644 js-fp3/library/result.js create mode 100644 js-fp3/package.json diff --git a/js-fp3/application/accountApp.js b/js-fp3/application/accountApp.js new file mode 100644 index 0000000..9d29091 --- /dev/null +++ b/js-fp3/application/accountApp.js @@ -0,0 +1,47 @@ +import { bind, ok, tap, tapError, pipe } from '../library/result.js'; +import { output, outputError } from '../library/logging.js'; +import { withdraw } from '../domain/account.js'; +import { createMoney } from '../domain/money.js'; + +/** + * @typedef {import('../infrastructure/repository.js').Repository} Repository + * @typedef {import('../domain/account.js').Account} Account + * @typedef {import('../domain/account.js').AccountError} AccountError + * @typedef {import('../infrastructure/repository.js').AppError} AppError + * @typedef {import('../library/result.js').Result} AccountResult + * @typedef {(accountId: string, amount: number) => AccountResult} WithdrawMoneyFn + * @typedef {import('../library/result.js').Result} CreateWithdrawMoneyResult + */ + +/** + * @param {string} src + * @param {string} msg + * @returns {(result: import('../library/result.js').Result) => import('../library/result.js').Result} + */ +const log = (src, msg) => (result) => + tap((x) => output(src, msg)(x))(tapError((e) => outputError(src, e))(result)); + +/** + * @param {string} src + * @param {(x: T) => string} renderText + * @returns {(result: import('../library/result.js').Result) => import('../library/result.js').Result} + */ +const logWith = (src, renderText) => (result) => + tap((x) => output(src, renderText(x))(x))(tapError((e) => outputError(src, e))(result)); + +/** + * @param {Repository} repo + * @returns {CreateWithdrawMoneyResult} + */ +export const createWithdrawMoney = (repo) => + ok((accountId, amount) => + pipe( + repo.loadAccount(accountId), + log('App load', 'Account loaded'), + log('App exec wdrwl', `[App] Executing withdrawal of ${amount.toFixed(2)}...`), + bind((account) => withdraw(account, createMoney(amount))), + logWith('App wdrwl done', (a) => `Withdrawal applied. New balance: ${a.balance.amount.toFixed(2)}`), + bind(repo.saveAccount), + log('App acc svd', '[App] Account persisted.') + ) + ); diff --git a/js-fp3/domain/account.js b/js-fp3/domain/account.js new file mode 100644 index 0000000..0590bd8 --- /dev/null +++ b/js-fp3/domain/account.js @@ -0,0 +1,72 @@ +import { ok, fail } from '../library/result.js'; + +/** + * @typedef {Object} Account + * @property {string} id + * @property {import('./money.js').Money} balance + */ + +/** @typedef {{ type: 'OpeningBalanceMustBeNonNegative' }} OpeningBalanceMustBeNonNegative */ +/** @typedef {{ type: 'AmountMustBePositive' }} AmountMustBePositive */ +/** @typedef {{ type: 'InsufficientBalance', balance: import('./money.js').Money, amount: import('./money.js').Money }} InsufficientBalance */ +/** @typedef {{ type: 'AccountNotFound', accountId: string }} AccountNotFound */ + +/** @typedef {OpeningBalanceMustBeNonNegative | AmountMustBePositive | InsufficientBalance | AccountNotFound} AccountError */ + +/** + * @type {OpeningBalanceMustBeNonNegative} + */ +export const openingBalanceMustBeNonNegative = { type: 'OpeningBalanceMustBeNonNegative' }; + +/** + * @type {AmountMustBePositive} + */ +export const amountMustBePositive = { type: 'AmountMustBePositive' }; + +/** + * @param {import('./money.js').Money} balance + * @param {import('./money.js').Money} amount + * @returns {InsufficientBalance} + */ +export const insufficientBalance = (balance, amount) => ({ + type: 'InsufficientBalance', + balance, + amount, +}); + +/** + * @param {string} accountId + * @returns {AccountNotFound} + */ +export const accountNotFound = (accountId) => ({ + type: 'AccountNotFound', + accountId, +}); + +/** + * @param {string} id + * @param {import('./money.js').Money} openingBalance + * @returns {import('../library/result.js').Result} + */ +export const openAccount = (id, openingBalance) => + openingBalance.amount < 0 + ? fail(openingBalanceMustBeNonNegative) + : ok({ id, balance: openingBalance }); + +/** + * @param {Account} account + * @param {import('./money.js').Money} amount + * @returns {import('../library/result.js').Result} + */ +export const withdraw = (account, amount) => { + if (amount.amount <= 0) { + return fail(amountMustBePositive); + } + if (account.balance.amount < amount.amount) { + return fail(insufficientBalance(account.balance, amount)); + } + return ok({ + ...account, + balance: { amount: account.balance.amount - amount.amount }, + }); +}; diff --git a/js-fp3/domain/money.js b/js-fp3/domain/money.js new file mode 100644 index 0000000..b83ed64 --- /dev/null +++ b/js-fp3/domain/money.js @@ -0,0 +1,16 @@ +/** + * @typedef {Object} Money + * @property {number} amount + */ + +/** + * @param {number} amount + * @returns {Money} + */ +export const createMoney = (amount) => ({ amount }); + +/** + * @param {Money} money + * @returns {string} + */ +export const formatMoney = (money) => money.amount.toFixed(2); diff --git a/js-fp3/index.js b/js-fp3/index.js new file mode 100644 index 0000000..fab48db --- /dev/null +++ b/js-fp3/index.js @@ -0,0 +1,54 @@ +import { randomUUID } from 'crypto'; +import { catchResult, bind, tap, tapError, mapError, pipe } from './library/result.js'; +import { output, outputError } from './library/logging.js'; +import { createInMemoryRepository, innerAccountError } from './infrastructure/repository.js'; +import { openAccount } from './domain/account.js'; +import { createMoney } from './domain/money.js'; +import { createWithdrawMoney } from './application/accountApp.js'; + +/** + * @param {string} src + * @param {string} msg + * @returns {(result: import('./library/result.js').Result) => import('./library/result.js').Result} + */ +const log = (src, msg) => (result) => + tap((x) => output(src, msg)(x))(tapError((e) => outputError(src, e))(result)); + +/** + * @param {string} src + * @param {(x: T) => string} renderText + * @returns {(result: import('./library/result.js').Result) => import('./library/result.js').Result} + */ +const logWith = (src, renderText) => (result) => + tap((x) => output(src, renderText(x))(x))(tapError((e) => outputError(src, e))(result)); + +console.log('[js-fp3] Starting withdraw money demo...'); + +const accountId = randomUUID(); + +// Try changing this to 250 to see an error being handled +const withdrawalAmount = 100; + +pipe( + catchResult( + createInMemoryRepository, + (ex) => ({ type: 'RepositoryCreationFailed', message: ex.message }) + ), + bind((repo) => + pipe( + createWithdrawMoney(repo), + bind((withdrawMoney) => + pipe( + openAccount(accountId, createMoney(200)), + logWith('js-fp3 seed', (account) => `Seeding account ${account.id} with opening balance ${account.balance.amount.toFixed(2)}`), + bind(repo.saveAccount), + log('js-fp3 exec', `Executing withdrawal ${withdrawalAmount.toFixed(2)} from account ${accountId}`), + bind(() => withdrawMoney(accountId, withdrawalAmount)), + logWith('js-fp3 new balance', (account) => `New balance is ${account.balance.amount.toFixed(2)}`), + mapError((ae) => innerAccountError(ae)) + ) + ) + ) + ), + log('js-fp3 done', 'Demo completed.') +); diff --git a/js-fp3/infrastructure/repository.js b/js-fp3/infrastructure/repository.js new file mode 100644 index 0000000..0a26435 --- /dev/null +++ b/js-fp3/infrastructure/repository.js @@ -0,0 +1,69 @@ +import { ok, fail } from '../library/result.js'; +import { logReturn } from '../library/logging.js'; +import { accountNotFound } from '../domain/account.js'; + +/** + * @typedef {Object} Repository + * @property {(id: string) => import('../library/result.js').Result} loadAccount + * @property {(account: import('../domain/account.js').Account) => import('../library/result.js').Result} saveAccount + */ + +/** @typedef {{ type: 'RepositoryCreationFailed', message: string }} AppErrorRepositoryCreationFailed */ +/** @typedef {{ type: 'InnerAccountError', innerError: import('../domain/account.js').AccountError }} AppErrorInnerAccountError */ + +/** @typedef {AppErrorRepositoryCreationFailed | AppErrorInnerAccountError} AppError */ + +/** + * @param {string} message + * @returns {AppErrorRepositoryCreationFailed} + */ +export const repositoryCreationFailed = (message) => ({ + type: 'RepositoryCreationFailed', + message, +}); + +/** + * @param {import('../domain/account.js').AccountError} innerError + * @returns {AppErrorInnerAccountError} + */ +export const innerAccountError = (innerError) => ({ + type: 'InnerAccountError', + innerError, +}); + +/** + * @returns {Repository} + */ +export const createInMemoryRepository = () => { + /** @type {Map} */ + const store = new Map(); + + /** + * @param {string} id + * @returns {import('../library/result.js').Result} + */ + const getById = (id) => + store.has(id) + ? logReturn( + `[Repo] Loaded account ${id}`, + ok(store.get(id)) + ) + : logReturn( + `[Repo] Account ${id} not found`, + fail(accountNotFound(id)) + ); + + /** + * @param {import('../domain/account.js').Account} account + * @returns {import('../library/result.js').Result} + */ + const save = (account) => { + store.set(account.id, account); + return logReturn( + `[Repo] Saved account ${account.id} with balance ${account.balance.amount.toFixed(2)}`, + ok(account) + ); + }; + + return { loadAccount: getById, saveAccount: save }; +}; diff --git a/js-fp3/library/logging.js b/js-fp3/library/logging.js new file mode 100644 index 0000000..f54496e --- /dev/null +++ b/js-fp3/library/logging.js @@ -0,0 +1,59 @@ +import { tap, tapError } from './result.js'; + +/** + * @template T + * @param {string} message + * @param {T} r + * @returns {T} + */ +export const logReturn = (message, r) => { + console.log(message); + return r; +}; + +/** + * @template T + * @param {string} src + * @param {string} message + * @returns {(x: T) => void} + */ +export const output = (src, message) => (x) => { + const value = typeof x === 'object' ? JSON.stringify(x) : x; + console.log(`[${src}] ${message} | ${value}`); +}; + +/** + * @template T + * @param {string} src + * @param {(x: T) => string} renderText + * @returns {(x: T) => void} + */ +export const outputWith = (src, renderText) => (x) => { + console.log(`[${src}] ${renderText(x)}`); +}; + +/** + * @template E + * @param {string} src + * @param {E} error + */ +export const outputError = (src, error) => { + const value = typeof error === 'object' ? JSON.stringify(error) : error; + console.error(`\x1b[1;31m[${src} ERROR]\x1b[0m ${value}`); +}; + +/** + * @template T, E + * @param {string} src + * @param {string} message + * @returns {(result: import('./result.js').Result) => import('./result.js').Result} + */ +export const log = (src, message) => tap(output(src, message)); + +/** + * @template T, E + * @param {string} src + * @param {(x: T) => string} renderText + * @returns {(result: import('./result.js').Result) => import('./result.js').Result} + */ +export const logWith = (src, renderText) => tap(outputWith(src, renderText)); diff --git a/js-fp3/library/result.js b/js-fp3/library/result.js new file mode 100644 index 0000000..a2b4a44 --- /dev/null +++ b/js-fp3/library/result.js @@ -0,0 +1,119 @@ +/** + * @template T, E + * @typedef {Object} Result + * @property {boolean} isSuccess + * @property {boolean} isFailure + * @property {T} [value] - Present when isSuccess is true + * @property {E} [error] - Present when isFailure is true + */ + +/** + * @template T, E + * @param {T} value + * @returns {Result} + */ +export const ok = (value) => ({ + isSuccess: true, + isFailure: false, + value, +}); + +/** + * @template T, E + * @param {E} error + * @returns {Result} + */ +export const fail = (error) => ({ + isSuccess: false, + isFailure: true, + error, +}); + +/** + * @template T, E + * @param {() => T} f + * @param {(err: Error) => E} exceptionMapper + * @returns {Result} + */ +export const catchResult = (f, exceptionMapper) => { + try { + return ok(f()); + } catch (e) { + return fail(exceptionMapper(e)); + } +}; + +/** + * @template TIn, TOut, E + * @param {(value: TIn) => Result} binder + * @returns {(result: Result) => Result} + */ +export const bind = (binder) => (result) => + result.isSuccess ? binder(result.value) : fail(result.error); + +/** + * @template TIn, TOut, E + * @param {(value: TIn) => TOut} mapper + * @returns {(result: Result) => Result} + */ +export const map = (mapper) => (result) => + result.isSuccess ? ok(mapper(result.value)) : fail(result.error); + +/** + * @template T, E + * @param {(value: T) => void} action + * @returns {(result: Result) => Result} + */ +export const tap = (action) => (result) => { + if (result.isSuccess) action(result.value); + return result; +}; + +/** + * @template T, E + * @param {(error: E) => void} action + * @returns {(result: Result) => Result} + */ +export const tapError = (action) => (result) => { + if (result.isFailure) action(result.error); + return result; +}; + +/** + * @template T, EIn, EOut + * @param {(error: EIn) => EOut} mapper + * @returns {(result: Result) => Result} + */ +export const mapError = (mapper) => (result) => + result.isSuccess ? ok(result.value) : fail(mapper(result.error)); + +/** + * @template T, E, TResult + * @param {(value: T) => TResult} onSuccess + * @param {(error: E) => TResult} onFailure + * @returns {(result: Result) => TResult} + */ +export const match = (onSuccess, onFailure) => (result) => + result.isSuccess ? onSuccess(result.value) : onFailure(result.error); + +/** + * @template T, E + * @param {(value: T) => void} onSuccess + * @param {(error: E) => void} onFailure + * @returns {(result: Result) => void} + */ +export const switch_ = (onSuccess, onFailure) => (result) => { + if (result.isSuccess) onSuccess(result.value); + else onFailure(result.error); +}; + +/** + * Pipe a result through a series of functions (left-to-right composition). + * This allows chaining without extending the prototype. + * @template T, E + * @param {Result} result + * @param {...Function} fns + * @returns {Result} + */ +export const pipe = (result, ...fns) => + fns.reduce((acc, fn) => fn(acc), result); diff --git a/js-fp3/package.json b/js-fp3/package.json new file mode 100644 index 0000000..139587d --- /dev/null +++ b/js-fp3/package.json @@ -0,0 +1,10 @@ +{ + "name": "js-fp3", + "version": "1.0.0", + "description": "Functional DDD demo with intense FP patterns", + "type": "module", + "main": "index.js", + "scripts": { + "start": "node index.js" + } +}