add js-fp3

This commit is contained in:
Oli Sturm
2026-04-24 18:04:54 +01:00
parent e61b4d4c11
commit b1ac745d70
8 changed files with 446 additions and 0 deletions
+47
View File
@@ -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<Account, AccountError>} AccountResult
* @typedef {(accountId: string, amount: number) => AccountResult} WithdrawMoneyFn
* @typedef {import('../library/result.js').Result<WithdrawMoneyFn, AppError>} CreateWithdrawMoneyResult
*/
/**
* @param {string} src
* @param {string} msg
* @returns {<T, E>(result: import('../library/result.js').Result<T, E>) => import('../library/result.js').Result<T, E>}
*/
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 {<T, E>(result: import('../library/result.js').Result<T, E>) => import('../library/result.js').Result<T, E>}
*/
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.')
)
);
+72
View File
@@ -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<Account, AccountError>}
*/
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<Account, AccountError>}
*/
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 },
});
};
+16
View File
@@ -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);
+54
View File
@@ -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 {<T, E>(result: import('./library/result.js').Result<T, E>) => import('./library/result.js').Result<T, E>}
*/
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 {<T, E>(result: import('./library/result.js').Result<T, E>) => import('./library/result.js').Result<T, E>}
*/
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.')
);
+69
View File
@@ -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<import('../domain/account.js').Account, import('../domain/account.js').AccountError>} loadAccount
* @property {(account: import('../domain/account.js').Account) => import('../library/result.js').Result<import('../domain/account.js').Account, import('../domain/account.js').AccountError>} 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<string, import('../domain/account.js').Account>} */
const store = new Map();
/**
* @param {string} id
* @returns {import('../library/result.js').Result<import('../domain/account.js').Account, import('../domain/account.js').AccountError>}
*/
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<import('../domain/account.js').Account, import('../domain/account.js').AccountError>}
*/
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 };
};
+59
View File
@@ -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<T, E>) => import('./result.js').Result<T, E>}
*/
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<T, E>) => import('./result.js').Result<T, E>}
*/
export const logWith = (src, renderText) => tap(outputWith(src, renderText));
+119
View File
@@ -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<T, E>}
*/
export const ok = (value) => ({
isSuccess: true,
isFailure: false,
value,
});
/**
* @template T, E
* @param {E} error
* @returns {Result<T, E>}
*/
export const fail = (error) => ({
isSuccess: false,
isFailure: true,
error,
});
/**
* @template T, E
* @param {() => T} f
* @param {(err: Error) => E} exceptionMapper
* @returns {Result<T, E>}
*/
export const catchResult = (f, exceptionMapper) => {
try {
return ok(f());
} catch (e) {
return fail(exceptionMapper(e));
}
};
/**
* @template TIn, TOut, E
* @param {(value: TIn) => Result<TOut, E>} binder
* @returns {(result: Result<TIn, E>) => Result<TOut, E>}
*/
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<TIn, E>) => Result<TOut, E>}
*/
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<T, E>) => Result<T, E>}
*/
export const tap = (action) => (result) => {
if (result.isSuccess) action(result.value);
return result;
};
/**
* @template T, E
* @param {(error: E) => void} action
* @returns {(result: Result<T, E>) => Result<T, E>}
*/
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<T, EIn>) => Result<T, EOut>}
*/
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<T, E>) => 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<T, E>) => 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<T, E>} result
* @param {...Function} fns
* @returns {Result<any, any>}
*/
export const pipe = (result, ...fns) =>
fns.reduce((acc, fn) => fn(acc), result);
+10
View File
@@ -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"
}
}