From d88075c16997ad34163fc74b51229038866419f9 Mon Sep 17 00:00:00 2001 From: Eric Rumsey Date: Sat, 7 Jun 2025 20:52:15 -0500 Subject: [PATCH] Initial commit --- .gitignore | 34 +++ README.md | 182 +++++++++++++++ bun.lock | 23 ++ data.json | 1 + dprint.json | 56 +++++ empty.txt | 0 output.txt | 3 + package.json | 38 ++++ src/bun/index.ts | 121 ++++++++++ src/either.ts | 158 +++++++++++++ src/index.ts | 5 + src/list.ts | 94 ++++++++ src/option.ts | 321 ++++++++++++++++++++++++++ src/utility.ts | 201 +++++++++++++++++ test.txt | 2 + test/fp-lib.test.ts | 538 ++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 19 ++ 17 files changed, 1796 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bun.lock create mode 100644 data.json create mode 100644 dprint.json create mode 100644 empty.txt create mode 100644 output.txt create mode 100644 package.json create mode 100644 src/bun/index.ts create mode 100644 src/either.ts create mode 100644 src/index.ts create mode 100644 src/list.ts create mode 100644 src/option.ts create mode 100644 src/utility.ts create mode 100644 test.txt create mode 100644 test/fp-lib.test.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..df833eb --- /dev/null +++ b/README.md @@ -0,0 +1,182 @@ +# Functional Programming Library Documentation + +## Table of Contents +1. [Overview](#overview) +2. [Either Module](#either-module) +3. [List Module](#list-module) +4. [Option Module](#option-module) +5. [Utility Module](#utility-module) +6. [Bun Extensions](#bun-extensions) + +## Overview +This zero-dependency functional programming library provides type-safe implementations of common FP constructs optimized for Bun runtime. Core features include: + +- `Either` for error handling +- `Option` for nullable values +- Immutable linked `List` +- Do-notation for monadic composition +- Bun-specific file and environment utilities + +## Either Module +Represents computations that can succeed (`Right`) or fail (`Left`). + +### Types +```typescript +type Left = { readonly _tag: 'Left'; readonly left: E } +type Right = { readonly _tag: 'Right'; readonly right: A } +type Either = Left | Right +``` + +### Constructors +| Function | Description | Example | +|----------|-------------|---------| +| `left(e: E)` | Creates failure case | `left('error')` | +| `right(a: A)` | Creates success case | `right(42)` | + +### Operations +| Function | Description | Type Signature | +|----------|-------------|----------------| +| `eitherMap` | Maps over success value | `(f: (a: A) => B) => (fa: Either) => Either` | +| `eitherChain` | Chains Either-returning functions | `(f: (a: A) => Either) => (fa: Either) => Either` | +| `fold` | Handles both cases | `(onLeft: (e: E) => B, onRight: (a: A) => B) => (fa: Either) => B` | + +### Do Notation +```typescript +const result = DoEither.start<{ x: number }, string>() + .bind('x', right(5)) + .bind('y', right(10)) + .return(scope => scope.x + scope.y) + +// Returns Either +``` + +| Method | Description | +|--------|-------------| +| `bind(key, either)` | Binds named value from Either | +| `return(f)` | Transforms final scope | +| `do(either)` | Executes effect without binding | +| `doL(f)` | Executes scope-dependent effect | + +## List Module +Immutable linked list implementation. + +### Types +```typescript +type Nil = { readonly _tag: 'Nil' } +type Cons = { + readonly _tag: 'Cons' + readonly head: A + readonly tail: List +} +type List = Nil | Cons +``` + +### Constructors +| Function | Description | Example | +|----------|-------------|---------| +| `nil` | Empty list | `nil` | +| `cons` | Creates list node | `cons(1, cons(2, nil))` | +| `fromArray` | Converts array to list | `fromArray([1, 2, 3])` | + +### Operations +| Function | Description | Type Signature | +|----------|-------------|----------------| +| `listMap` | Transforms list elements | `(f: (a: A) => B) => (fa: List) => List` | +| `listReduce` | Reduces list to value | `(f: (b: B, a: A) => B, initial: B) => (fa: List) => B` | +| `toArray` | Converts list to array | `(fa: List) => Array` | + +## Option Module +Represents optional values (`Some` or `None`). + +### Types +```typescript +type None = { readonly _tag: 'None' } +type Some = { readonly _tag: 'Some'; readonly value: A } +type Option = None | Some +``` + +### Constructors +| Function | Description | Example | +|----------|-------------|---------| +| `none` | Empty value | `none` | +| `some` | Wraps a value | `some(42)` | +| `fromNullable` | Converts nullable | `fromNullable(null) → none` | + +### Operations +| Function | Description | Type Signature | +|----------|-------------|----------------| +| `map` | Transforms value | `(f: (a: A) => B) => (fa: Option) => Option` | +| `chain` | Chains computations | `(f: (a: A) => Option) => (fa: Option) => Option` | +| `getOrElse` | Default value | `(defaultValue: A) => (fa: Option) => A` | + +### Do Notation (Sync) +```typescript +DoOption.start() + .bind('user', getUser()) + .bind('profile', getProfile()) + .return(scope => `${scope.user.name}: ${scope.profile.bio}`) +``` + +### Async Do Notation +```typescript +AsyncDoOption.start() + .bind('user', fetchUser()) + .bindL('posts', scope => fetchPosts(scope.user.id)) + .return(scope => renderPage(scope)) +``` + +| Async Method | Description | +|--------------|-------------| +| `bind(key, promise)` | Binds async Option | +| `bindL(key, f)` | Binds scope-dependent async Option | +| `do(promise)` | Executes async effect | +| `doL(f)` | Executes scope-dependent async effect | + +## Utility Module + +### Function Composition +| Function | Description | Example | +|----------|-------------|---------| +| `pipe` | Left-to-right composition | `pipe(1, x => x+1, x => x*2) → 4` | +| `compose` | Right-to-left composition | `compose(x => x*2, x => x+1)(1) → 4` | + +### Error Handling +| Function | Description | Example | +|----------|-------------|---------| +| `tryCatch` | Safely execute function | `tryCatch(() => JSON.parse(str))` | + +### Type Utilities +| Function | Description | +|----------|-------------| +| `hasTag` | Creates tag predicate | `list.filter(hasTag('Cons'))` | + +## Bun Extensions +Optimized utilities for Bun runtime. + +### File Operations +| Function | Description | Type Signature | +|----------|-------------|----------------| +| `readFileSafe` | Reads file safely | `(path: string) => Promise>` | +| `listToFile` | Writes list to file | `(list: List, path: string) => Promise>` | + +### JSON & Environment +| Function | Description | +|----------|-------------| +| `safeJsonParse` | Safe JSON parsing | `safeJsonParse(data): Either` | +| `envOption` | Environment variable as Option | `envOption('PORT')` | + +### Server Utilities +| Function | Description | Example | +|----------|-------------|---------| +| `createServer` | Creates HTTP server | `createServer(handleRequest)` | +| `asyncReduce` | Async list reduction | `asyncReduce(list, reducer, initial)` | + +### Server Example +```typescript +const handleRequest = (req: Request): Either => { + return req.url === '/' + ? right(new Response('Hello')) + : left(new Error('Not found')) +} + +createServer(handleRequest) diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..9373bba --- /dev/null +++ b/bun.lock @@ -0,0 +1,23 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "fp-lib", + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.0.4", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.2.15", "", { "dependencies": { "bun-types": "1.2.15" } }, "sha512-U1ljPdBEphF0nw1MIk0hI7kPg7dFdPyM7EenHsp6W5loNHl7zqy6JQf/RKCgnUn2KDzUpkBwHPnEJEjII594bA=="], + + "@types/node": ["@types/node@22.15.29", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LNdjOkUDlU1RZb8e1kOIUpN1qQUlzGkEtbVNo53vbrwDg5om6oduhm4SiUaPW5ASTXhAiP0jInWG8Qx9fVlOeQ=="], + + "bun-types": ["bun-types@1.2.15", "", { "dependencies": { "@types/node": "*" } }, "sha512-NarRIaS+iOaQU1JPfyKhZm4AsUOrwUOqRNHY0XxI8GI8jYxiLXLcdjYMG9UKS+fwWasc1uw1htV9AX24dD+p4w=="], + + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + } +} diff --git a/data.json b/data.json new file mode 100644 index 0000000..846068f --- /dev/null +++ b/data.json @@ -0,0 +1 @@ +{"id": 1, "name": "Test"} \ No newline at end of file diff --git a/dprint.json b/dprint.json new file mode 100644 index 0000000..8ffe56c --- /dev/null +++ b/dprint.json @@ -0,0 +1,56 @@ +{ + "typescript": { + "lineWidth": 80, + "indentWidth": 2, + "useTabs": false, + "semiColons": "asi", + "quoteStyle": "alwaysSingle", + "trailingCommas": "onlyMultiLine", + "operatorPosition": "nextLine", + "arrowFunction.useParentheses": "maintain", + "binaryExpression.linePerExpression": false, + "memberExpression.linePerExpression": false, + "bracePosition": "sameLineUnlessHanging", + "spaceSurroundingProperties": false, + "objectExpression.spaceSurroundingProperties": false, + "objectPattern.spaceSurroundingProperties": false, + "typeLiteral.spaceSurroundingProperties": false, + "functionDeclaration.spaceBeforeParentheses": false, + "method.spaceBeforeParentheses": false, + "constructor.spaceBeforeParentheses": false, + "typeAnnotation.spaceBeforeColon": false, + "jsx.quoteStyle": "preferSingle", + "jsxSelfClosingElement.spaceBeforeSlash": false, + "useBraces": "whenNotSingleLine", + "preferHanging": false, + "conditionalExpression.linePerExpression": false, + + "arguments.preferHanging": "never", + "arguments.spaceAround": false, + + "parameters.spaceAround": false, + "parameters.trailingCommas": "onlyMultiLine", + "parameters.preferSingleLine": true, + "parameters.preferHanging": "onlySingleItem", + + "typeLiteral.separatorKind": "comma", + + "parentheses.preferSingleLine": true + }, + "json": {}, + "markdown": {}, + "toml": {}, + "malva": {}, + "markup": {}, + "yaml": {}, + "excludes": ["**/node_modules", "**/*-lock.json"], + "plugins": [ + "https://plugins.dprint.dev/typescript-0.95.4.wasm", + "https://plugins.dprint.dev/json-0.20.0.wasm", + "https://plugins.dprint.dev/markdown-0.18.0.wasm", + "https://plugins.dprint.dev/toml-0.7.0.wasm", + "https://plugins.dprint.dev/g-plane/malva-v0.12.1.wasm", + "https://plugins.dprint.dev/g-plane/markup_fmt-v0.20.0.wasm", + "https://plugins.dprint.dev/g-plane/pretty_yaml-v0.5.1.wasm" + ] +} diff --git a/empty.txt b/empty.txt new file mode 100644 index 0000000..e69de29 diff --git a/output.txt b/output.txt new file mode 100644 index 0000000..6ad36e5 --- /dev/null +++ b/output.txt @@ -0,0 +1,3 @@ +Line 1 +Line 2 +Line 3 diff --git a/package.json b/package.json new file mode 100644 index 0000000..1330bc9 --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "fp-lib", + "version": "1.0.0", + "description": "Zero-dependency functional programming library optimized for Bun", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "bun run clean && tsc", + "clean": "rm -rf dist", + "test": "bun test", + "prepublishOnly": "bun run build" + }, + "keywords": [ + "functional programming", + "bun", + "typescript", + "option", + "either", + "list" + ], + "author": "Your Name", + "license": "MIT", + "devDependencies": { + "typescript": "^5.0.4", + "@types/bun": "latest" + }, + "engines": { + "bun": ">=1.0.0" + }, + "files": [ + "dist/**/*" + ], + "exports": { + ".": "./dist/index.js", + "./bun": "./dist/bun/index.js" + } +} diff --git a/src/bun/index.ts b/src/bun/index.ts new file mode 100644 index 0000000..505b82c --- /dev/null +++ b/src/bun/index.ts @@ -0,0 +1,121 @@ +/** + * Bun-specific extensions for enhanced performance and integration + */ +import { type Either, left, right } from '../either' +import { type List } from '../list' +import { none, type Option, some } from '../option' + +/** + * Optimized file reader that returns an Either + * @param path - Path to the file + * @returns Either with file contents or Error + */ +export const readFileSafe = + async (path: string): Promise> => { + try { + const file = Bun.file(path) + const exists = await file.exists() + return exists + ? right(await file.text()) + : left(new Error('File not found')) + } catch (e) { + return left(e instanceof Error ? e : new Error(String(e))) + } + } + +/** + * Optimized JSON parser that returns an Either + * @param data - JSON string to parse + * @returns Either with parsed object or Error + */ +export const safeJsonParse = (data: string): Either => { + try { + return right(JSON.parse(data) as unknown as T) + } catch (e) { + return left(e instanceof Error ? e : new Error(String(e))) + } +} + +/** + * High-performance async reduce for large lists + * @param list - List to process + * @param reducer - Reduce function + * @param initial - Initial value + * @returns Promise with reduced value + */ +export const asyncReduce = async ( + list: List, + reducer: (acc: B, val: A) => Promise, + initial: B, +): Promise => { + let result = initial + let current = list + + while (current._tag === 'Cons') { + result = await reducer(result, current.head) + current = current.tail + } + + return result +} + +/** + * Bun-specific option from environment variable + * @param key - Environment variable name + * @returns Option with value + */ +export const envOption = (key: string): Option => { + const value = Bun.env[key] + return value !== undefined ? some(value) : none +} + +/** + * Converts a list to a Bun file + * @param list - List of strings + * @param path - File path + * @returns Either with success or error + */ +export const listToFile = async ( + list: List, + path: string, +): Promise> => { + try { + const writer = Bun.file(path).writer() + let current = list + + while (current._tag === 'Cons') { + writer.write(current.head + '\n') + current = current.tail + } + + await writer.end() + return right(undefined) + } catch (e) { + return left(e instanceof Error ? e : new Error(String(e))) + } +} + +/** + * Bun-optimized HTTP server using functional patterns + * @param handler - Request handler function + * @returns Bun server instance + */ +export const createServer = + (handler: (req: Request) => Either) => { + return Bun.serve({ + port: 3000, + fetch(req) { + try { + const result = handler(req) + return result._tag === 'Right' + ? result.right + : new Response('Server error', {status: 500}) + } catch (e) { + return new Response('Internal error', {status: 500}) + } + }, + error(error) { + return new Response('Internal error', {status: 500}) + }, + }) + } diff --git a/src/either.ts b/src/either.ts new file mode 100644 index 0000000..3e3e03c --- /dev/null +++ b/src/either.ts @@ -0,0 +1,158 @@ +/** + * Represents the failure case of an Either + * @typeParam E - The type of the error + */ +export type Left = {readonly _tag: 'Left', readonly left: E} + +/** + * Represents the success case of an Either + * @typeParam A - The type of the value + */ +export type Right = {readonly _tag: 'Right', readonly right: A} + +/** + * A type that represents either a success (Right) or failure (Left) + * @typeParam A - The success value type + * @typeParam E - The error type + */ +export type Either = Right | Left + +/** + * Constructs a Left value + * @typeParam E - The error type + * @param e - The error value + * @returns A Left containing the error + */ +export const left = (e: E): Either => ({ + _tag: 'Left', + left: e, +}) + +/** + * Constructs a Right value + * @typeParam A - The value type + * @param a - The value + * @returns A Right containing the value + */ +export const right = (a: A): Either => ({ + _tag: 'Right', + right: a, +}) + +/** + * Maps a function over the Right side of an Either + * @typeParam A - The input value type + * @typeParam B - The output value type + * @typeParam E - The error type + * @param f - The mapping function + * @returns A function that takes an Either and returns a new Either + */ +export const eitherMap = + (f: (a: A) => B) => (fa: Either): Either => + fa._tag === 'Right' ? right(f(fa.right)) : fa + +/** + * Chains computations that may return Either values + * @typeParam A - The input value type + * @typeParam B - The output value type + * @typeParam E - The error type + * @param f - The function that returns an Either + * @returns A function that takes an Either and returns a new Either + */ +export const eitherChain = + (f: (a: A) => Either) => (fa: Either): Either => + fa._tag === 'Right' ? f(fa.right) : fa + +/** + * Folds an Either into a single value by providing handlers for both cases + * @typeParam A - The success value type + * @typeParam E - The error type + * @typeParam B - The output type + * @param onLeft - Function to handle Left case + * @param onRight - Function to handle Right case + * @returns A function that takes an Either and returns the folded value + */ +export const fold = + (onLeft: (e: E) => B, onRight: (a: A) => B) => + (fa: Either): B => + fa._tag === 'Left' ? onLeft(fa.left) : onRight(fa.right) + +/** + * A class for working with Either in a do-notation style + * @typeParam T - The type of the accumulated scope + * @typeParam E - The error type + */ +export class DoEither, E> { + /** + * @param either - The current Either value + */ + constructor(private readonly either: Either) {} + + /** + * Binds a new value to the scope + * @typeParam K - The key to bind to + * @typeParam A - The type of the value to bind + * @param key - The property name to bind to + * @param fa - The Either value to bind + * @returns A new DoEither instance with the extended scope + */ + bind( + key: K, + fa: Either, + ): DoEither, E> { + const newEither = eitherChain, E>((scope: T) => + eitherMap, E>((a: A) => ({...scope, [key]: a}))(fa) + )(this.either) + return new DoEither(newEither) + } + + /** + * Returns the final result from the scope + * @typeParam B - The return type + * @param f - The function to transform the scope + * @returns An Either containing the result + */ + return(f: (scope: T) => B): Either { + return eitherMap(f)(this.either) + } + + /** + * Returns the current scope + * @returns The current Either scope + */ + done(): Either { + return this.either + } + + /** + * Executes an effect without modifying the scope + * @param fa - The Either effect to execute + * @returns A new DoEither instance with the same scope + */ + do(fa: Either): DoEither { + const newEither = eitherChain((scope: T) => + eitherMap(() => scope)(fa) + )(this.either) + return new DoEither(newEither) + } + + /** + * Executes an effect that depends on the current scope + * @param f - The function that returns an Either effect + * @returns A new DoEither instance with the same scope + */ + doL(f: (scope: T) => Either): DoEither { + const newEither = eitherChain((scope: T) => + eitherMap(() => scope)(f(scope)) + )(this.either) + return new DoEither(newEither) + } + + /** + * Starts a new DoEither chain with an empty scope + * @returns A new DoEither instance with an empty scope + */ + static start(): DoEither<{}, E> { + return new DoEither(right({})) + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..defcd94 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,5 @@ +export * as bun from './bun' // Namespaced Bun-specific exports +export * from './either' +export * from './list' +export * from './option' +export * from './utility' diff --git a/src/list.ts b/src/list.ts new file mode 100644 index 0000000..ce72018 --- /dev/null +++ b/src/list.ts @@ -0,0 +1,94 @@ +/** + * Represents an empty list + */ +export type Nil = {readonly _tag: 'Nil'} + +/** + * Represents a non-empty list node + * @typeParam A - The type of elements in the list + */ +export type Cons = { + readonly _tag: 'Cons', + readonly head: A, + readonly tail: List, +} + +/** + * A recursive list type that is either Nil (empty) or Cons (non-empty) + * @typeParam A - The type of elements in the list + */ +export type List = Nil | Cons + +/** + * The singleton instance of an empty list + */ +export const nil: Nil = {_tag: 'Nil'} + +/** + * Constructs a new list node + * @typeParam A - The type of elements in the list + * @param head - The first element of the list + * @param tail - The rest of the list + * @returns A new Cons node + */ +export const cons = (head: A, tail: List): List => ({ + _tag: 'Cons', + head, + tail, +}) + +/** + * Maps a function over all elements of a list + * @typeParam A - The input element type + * @typeParam B - The output element type + * @param f - The mapping function + * @returns A function that takes a list and returns a new list + */ +type ListMap = (f: (a: A) => B) => (fa: List) => List +export const listMap: ListMap = (f) => (fa) => + fa._tag === 'Cons' ? cons(f(fa.head), listMap(f)(fa.tail)) : nil + +/** + * Reduces a list to a single value + * @typeParam A - The element type + * @typeParam B - The accumulator type + * @param f - The reducer function + * @param initial - The initial accumulator value + * @returns A function that takes a list and returns the reduced value + */ +type ListReduce = (f: (b: B, a: A) => B, initial: B) => (fa: List) => B +export const listReduce: ListReduce = + (f: (b: B, a: A) => B, initial: B) => (fa: List) => { + const reduceHelper = (list: List, acc: B): B => { + return list._tag === 'Cons' + ? reduceHelper(list.tail, f(acc, list.head)) + : acc + } + return reduceHelper(fa, initial) + } + +/** + * Creates a list from an array + * @typeParam A - The element type + * @param arr - The source array + * @returns A list containing the same elements + */ +type FromArray = (arr: Array) => List +export const fromArray: FromArray = (arr: Array) => + arr.reduceRight( + (acc: List, val: A) => cons(val, acc), + nil as List, + ) + +/** + * Converts a list to an array + * @typeParam A - The element type + * @param fa - The list to convert + * @returns An array containing the same elements + */ +type ToArray = (fa: List) => Array +export const toArray: ToArray = (fa: List) => + listReduce>( + (acc, val) => [...acc, val], + [] as Array, + )(fa) diff --git a/src/option.ts b/src/option.ts new file mode 100644 index 0000000..84d678a --- /dev/null +++ b/src/option.ts @@ -0,0 +1,321 @@ +/** + * Represents the absence of a value + */ +export type None = {readonly _tag: 'None'} + +/** + * Represents the presence of a value + * @typeParam A - The type of the contained value + */ +export type Some = {readonly _tag: 'Some', readonly value: A} + +/** + * A type that represents an optional value: either Some with a value or None + * @typeParam A - The type of the contained value + */ +export type Option = Some | None + +/** + * The singleton instance of None + */ +export const none: Option = {_tag: 'None'} + +/** + * Constructs a Some value + * @typeParam A - The type of the value + * @param value - The value to wrap + * @returns A Some containing the value + */ +export const some = (value: A): Option => ({ + _tag: 'Some', + value, +}) + +/** + * Maps a function over an optional value + * @typeParam A - The input value type + * @typeParam B - The output value type + * @param f - The mapping function + * @returns A function that takes an Option and returns a new Option + */ +export const map = (f: (a: A) => B) => (fa: Option): Option => + fa._tag === 'Some' ? some(f(fa.value)) : none + +/** + * Chains computations that may return optional values + * @typeParam A - The input value type + * @typeParam B - The output value type + * @param f - The function that returns an Option + * @returns A function that takes an Option and returns a new Option + */ +export const chain = + (f: (a: A) => Option) => (fa: Option): Option => + fa._tag === 'Some' ? f(fa.value) : none + +/** + * Extracts the value from an Option or returns a default + * @typeParam A - The value type + * @param defaultValue - The value to return if Option is None + * @returns A function that takes an Option and returns a value + */ +export const getOrElse = (defaultValue: A) => (fa: Option): A => + fa._tag === 'Some' ? fa.value : defaultValue + +/** + * Converts a nullable value to an Option + * @typeParam A - The value type + * @param a - The value that might be null or undefined + * @returns Some(value) if value exists, None otherwise + */ +export const fromNullable = (a: A | null | undefined): Option => + a == null ? none : some(a) + +/** + * A class for working with Option in a do-notation style + * @typeParam T - The type of the accumulated scope + */ +/** + * A class for working with Option in a do-notation style + * @typeParam T - The type of the accumulated scope + */ +export class DoOption> { + /** + * Creates a new DoOption instance + * @param option - The current Option value representing the accumulated scope + */ + constructor(private readonly option: Option) {} + + /** + * Binds a static Option value to the scope + * @typeParam K - The key to bind to + * @typeParam A - The type of the value to bind + * @param key - The property name to bind to + * @param fa - The Option value to bind (does not depend on current scope) + * @returns A new DoOption instance with the extended scope + * @example + * DoOption.start() + * .bind('id', some(1)) + * .bind('name', some('Alice')) + */ + bind(key: K, fa: Option): DoOption> { + const newOption = chain((scope: T) => + map((a: A) => ({...scope, [key]: a}))(fa) + )(this.option) + return new DoOption(newOption as Option>) + } + + /** + * Binds a value to the scope using a scope-dependent computation + * @typeParam K - The key to bind to + * @typeParam A - The type of the value to bind + * @param key - The property name to bind to + * @param f - A function that returns an Option value based on current scope + * @returns A new DoOption instance with the extended scope + * @example + * DoOption.start() + * .bind('id', some(1)) + * .bindL('profile', scope => getUserProfile(scope.id)) + */ + bindL( + key: K, + f: (scope: T) => Option, + ): DoOption> { + const newOption = chain((scope: T) => { + const nextOption = f(scope) + return map((a: A) => ({...scope, [key]: a}))(nextOption) + })(this.option) + + return new DoOption(newOption as Option>) + } + + /** + * Transforms the final scope into a result value + * @typeParam B - The return type + * @param f - The transformation function for the scope + * @returns An Option containing the final result + * @example + * DoOption.start() + * .bind('a', some(2)) + * .bind('b', some(3)) + * .return(scope => scope.a * scope.b) // Returns some(6) + */ + return(f: (scope: T) => B): Option { + return map(f)(this.option) + } + + /** + * Returns the current scope without transformation + * @returns The current Option scope + * @example + * const result = DoOption.start() + * .bind('id', some(1)) + * .done() // Returns some({ id: 1 }) + */ + done(): Option { + return this.option + } + + /** + * Executes an effect without modifying the scope + * @param fa - The Option effect to execute (does not depend on scope) + * @returns A new DoOption instance with the same scope + * @example + * DoOption.start() + * .bind('id', some(1)) + * .do(logAction('ID set')) + * .return(scope => scope.id) + */ + do(fa: Option): DoOption { + const newOption = chain((scope: T) => map(() => scope)(fa))(this.option) + return new DoOption(newOption) + } + + /** + * Executes a scope-dependent effect without modifying the scope + * @param f - A function that returns an Option effect based on current scope + * @returns A new DoOption instance with the same scope + * @example + * DoOption.start() + * .bind('id', some(1)) + * .doL(scope => logAction(`ID set to ${scope.id}`)) + * .return(scope => scope.id) + */ + doL(f: (scope: T) => Option): DoOption { + const newOption = chain((scope: T) => map(() => scope)(f(scope)))( + this.option, + ) + return new DoOption(newOption) + } + + /** + * Starts a new DoOption chain with an empty scope + * @returns A new DoOption instance with an empty scope + * @example + * DoOption.start() + * .bind('id', some(1)) + * .return(scope => scope.id) + */ + static start(): DoOption<{}> { + return new DoOption(some({})) + } +} +/** + * A class for working with asynchronous Option computations + * @typeParam T - The type of the accumulated scope + */ +export class AsyncDoOption> { + /** + * @param promise - The promise of an Option + */ + private constructor(private readonly promise: Promise>) {} + + /** + * Binds an asynchronous value to the scope + * @typeParam K - The key to bind to + * @typeParam A - The type of the value to bind + * @param key - The property name to bind to + * @param value - The promise of an Option value + * @returns A new AsyncDoOption instance with the extended scope + */ + bind( + key: K, + value: Promise>, + ): AsyncDoOption> { + const newPromise = this.promise + .then(async (option) => { + if (option._tag === 'None') return none + const nextOption = await value + if (nextOption._tag === 'None') return none + return some({...option.value, [key]: nextOption.value}) as Option< + T & Record + > + }) + .catch(() => none) + return new AsyncDoOption(newPromise) + } + + /** + * Binds an asynchronous value that depends on the current scope + * @typeParam K - The key to bind to + * @typeParam A - The type of the value to bind + * @param key - The property name to bind to + * @param f - The function that returns a promise of an Option + * @returns A new AsyncDoOption instance with the extended scope + */ + bindL( + key: K, + f: (scope: T) => Promise>, + ): AsyncDoOption> { + const newPromise = this.promise + .then(async (option) => { + if (option._tag === 'None') return none + const nextOption = await f(option.value) + if (nextOption._tag === 'None') return none + return some({...option.value, [key]: nextOption.value}) as Option< + T & Record + > + }) + .catch(() => none) + return new AsyncDoOption(newPromise) + } + + /** + * Executes an asynchronous effect without modifying the scope + * @param effect - The promise of an Option effect + * @returns A new AsyncDoOption instance with the same scope + */ + do(effect: Promise>): AsyncDoOption { + const newPromise = this.promise + .then(async (option) => { + if (option._tag === 'None') return none + const result = await effect + return result._tag === 'Some' ? option : none + }) + .catch(() => none) + return new AsyncDoOption(newPromise) + } + + /** + * Executes an asynchronous effect that depends on the current scope + * @param f - The function that returns a promise of an Option + * @returns A new AsyncDoOption instance with the same scope + */ + doL(f: (scope: T) => Promise>): AsyncDoOption { + const newPromise = this.promise + .then(async (option) => { + if (option._tag === 'None') return none + const result = await f(option.value) + return result._tag === 'Some' ? option : none + }) + .catch(() => none) + return new AsyncDoOption(newPromise) + } + + /** + * Returns the final result from the scope + * @typeParam B - The return type + * @param f - The function to transform the scope + * @returns A promise of an Option containing the result + */ + return(f: (scope: T) => B): Promise> { + return this.promise.then((option) => + option._tag === 'Some' ? some(f(option.value)) : none + ) + } + + /** + * Returns the current scope + * @returns A promise of the current Option scope + */ + done(): Promise> { + return this.promise + } + + /** + * Starts a new AsyncDoOption chain with an empty scope + * @returns A new AsyncDoOption instance with an empty scope + */ + static start(): AsyncDoOption<{}> { + return new AsyncDoOption(Promise.resolve(some({}))) + } +} diff --git a/src/utility.ts b/src/utility.ts new file mode 100644 index 0000000..16bfbed --- /dev/null +++ b/src/utility.ts @@ -0,0 +1,201 @@ +import { type Either, left, right } from './either' + +/** + * Pipes a value through a series of functions (up to 10) + * @param value - The initial value + * @param fns - The functions to apply + * @returns The result of applying all functions + */ +export const pipe: { + (value: A): A, + (value: A, fn1: (a: A) => B): B, + (value: A, fn1: (a: A) => B, fn2: (b: B) => C): C, + ( + value: A, + fn1: (a: A) => B, + fn2: (b: B) => C, + fn3: (c: C) => D, + ): D, + ( + value: A, + fn1: (a: A) => B, + fn2: (b: B) => C, + fn3: (c: C) => D, + fn4: (d: D) => E, + ): E, + ( + value: A, + fn1: (a: A) => B, + fn2: (b: B) => C, + fn3: (c: C) => D, + fn4: (d: D) => E, + fn5: (e: E) => F, + ): F, + ( + value: A, + fn1: (a: A) => B, + fn2: (b: B) => C, + fn3: (c: C) => D, + fn4: (d: D) => E, + fn5: (e: E) => F, + fn6: (f: F) => G, + ): G, + ( + value: A, + fn1: (a: A) => B, + fn2: (b: B) => C, + fn3: (c: C) => D, + fn4: (d: D) => E, + fn5: (e: E) => F, + fn6: (f: F) => G, + fn7: (g: G) => H, + ): H, + ( + value: A, + fn1: (a: A) => B, + fn2: (b: B) => C, + fn3: (c: C) => D, + fn4: (d: D) => E, + fn5: (e: E) => F, + fn6: (f: F) => G, + fn7: (g: G) => H, + fn8: (h: H) => I, + ): I, + ( + value: A, + fn1: (a: A) => B, + fn2: (b: B) => C, + fn3: (c: C) => D, + fn4: (d: D) => E, + fn5: (e: E) => F, + fn6: (f: F) => G, + fn7: (g: G) => H, + fn8: (h: H) => I, + fn9: (i: I) => J, + ): J, + ( + value: A, + fn1: (a: A) => B, + fn2: (b: B) => C, + fn3: (c: C) => D, + fn4: (d: D) => E, + fn5: (e: E) => F, + fn6: (f: F) => G, + fn7: (g: G) => H, + fn8: (h: H) => I, + fn9: (i: I) => J, + fn10: (j: J) => K, + ): K, +} = (value: unknown, ...fns: Function[]): unknown => { + return fns.reduce((acc, fn) => fn(acc), value) +} + +/** + * Composes multiple functions into a single function (right-to-left, up to 10) + * @param fns - The functions to compose + * @returns The composed function + */ +export const compose: { + (): (a: A) => A, + (fn1: (a: A) => B): (a: A) => B, + (fn2: (b: B) => C, fn1: (a: A) => B): (a: A) => C, + ( + fn3: (c: C) => D, + fn2: (b: B) => C, + fn1: (a: A) => B, + ): (a: A) => D, + ( + fn4: (d: D) => E, + fn3: (c: C) => D, + fn2: (b: B) => C, + fn1: (a: A) => B, + ): (a: A) => E, + ( + fn5: (e: E) => F, + fn4: (d: D) => E, + fn3: (c: C) => D, + fn2: (b: B) => C, + fn1: (a: A) => B, + ): (a: A) => F, + ( + fn6: (f: F) => G, + fn5: (e: E) => F, + fn4: (d: D) => E, + fn3: (c: C) => D, + fn2: (b: B) => C, + fn1: (a: A) => B, + ): (a: A) => G, + ( + fn7: (g: G) => H, + fn6: (f: F) => G, + fn5: (e: E) => F, + fn4: (d: D) => E, + fn3: (c: C) => D, + fn2: (b: B) => C, + fn1: (a: A) => B, + ): (a: A) => H, + ( + fn8: (h: H) => I, + fn7: (g: G) => H, + fn6: (f: F) => G, + fn5: (e: E) => F, + fn4: (d: D) => E, + fn3: (c: C) => D, + fn2: (b: B) => C, + fn1: (a: A) => B, + ): (a: A) => I, + ( + fn9: (i: I) => J, + fn8: (h: H) => I, + fn7: (g: G) => H, + fn6: (f: F) => G, + fn5: (e: E) => F, + fn4: (d: D) => E, + fn3: (c: C) => D, + fn2: (b: B) => C, + fn1: (a: A) => B, + ): (a: A) => J, + ( + fn10: (j: J) => K, + fn9: (i: I) => J, + fn8: (h: H) => I, + fn7: (g: G) => H, + fn6: (f: F) => G, + fn5: (e: E) => F, + fn4: (d: D) => E, + fn3: (c: C) => D, + fn2: (b: B) => C, + fn1: (a: A) => B, + ): (a: A) => K, +} = (...fns: Function[]) => { + return (value: unknown) => fns.reduceRight((acc, fn) => fn(acc), value) +} + +/** + * Safely executes a function that might throw + * @typeParam A - The return type + * @param f - The function to execute + * @returns An Either containing the result or an Error + */ +export const tryCatch = (f: () => A): Either => { + try { + return right(f()) + } catch (e) { + return left(e instanceof Error ? e : new Error(String(e))) + } +} + +/** + * Represents a tagged type + * @typeParam T - The tag type + */ +export interface Tag { + readonly _tag: T +} + +/** + * Creates a predicate that checks if an object has a specific tag + * @param x - The tag to check for + * @returns A predicate function + */ +export const hasTag = (x: string) => (y: Tag) => x === y._tag diff --git a/test.txt b/test.txt new file mode 100644 index 0000000..f9264f7 --- /dev/null +++ b/test.txt @@ -0,0 +1,2 @@ +Hello +World diff --git a/test/fp-lib.test.ts b/test/fp-lib.test.ts new file mode 100644 index 0000000..873d3c3 --- /dev/null +++ b/test/fp-lib.test.ts @@ -0,0 +1,538 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, +} from 'bun:test' +import * as F from '../src' + +// Mock Bun environment +beforeAll(() => { + Bun.env.TEST_PORT = '3000' + Bun.env.EMPTY_VAR = '' +}) + +afterEach(() => { + delete Bun.env.MISSING_VAR +}) + +describe('Option Module', () => { + test('creates Some and None', () => { + expect(F.some(5)).toEqual({_tag: 'Some', value: 5}) + expect(F.none).toEqual({_tag: 'None'}) + }) + + test('fromNullable handles null/undefined', () => { + expect(F.fromNullable(null)).toEqual(F.none) + expect(F.fromNullable(undefined)).toEqual(F.none) + expect(F.fromNullable(10)).toEqual(F.some(10)) + expect(F.fromNullable(0)).toEqual(F.some(0)) // Edge: falsy but not null + expect(F.fromNullable('')).toEqual(F.some('')) // Edge: empty string + }) + + test('map transforms values', () => { + const double = (n: number) => n * 2 + expect(F.map(double)(F.some(5))).toEqual(F.some(10)) + expect(F.map(double)(F.none)).toEqual(F.none) + expect(F.map(() => null)(F.some(5))).toEqual(F.some(null)) // Edge: mapping to null + }) + + test('chain handles nested options', () => { + const safeInverse = (n: number): F.Option => + n === 0 ? F.none : F.some(1 / n) + + expect(F.chain(safeInverse)(F.some(2))).toEqual(F.some(0.5)) + expect(F.chain(safeInverse)(F.some(0))).toEqual(F.none) // Edge: division by zero + expect(F.chain(safeInverse)(F.none)).toEqual(F.none) + }) + + test('getOrElse provides defaults', () => { + const withDefault = F.getOrElse(0) + expect(withDefault(F.some(42))).toBe(42) + expect(withDefault(F.none)).toBe(0) + expect(withDefault(F.some(null))).toBe(null) // Edge: null value + }) + + test('DoOption composition', () => { + const result = F.DoOption.start() + .bind('a', F.some(3)) + .bind('b', F.some(4)) + .return(scope => scope.a * scope.b) + + expect(result).toEqual(F.some(12)) + }) + + test('DoOption.bindL uses scope for computation', () => { + const getUser = (id: number): F.Option => + id === 1 ? F.some('Alice') : F.none + + const result = F.DoOption.start() + .bind('id', F.some(1)) + .bindL('name', scope => getUser(scope.id)) + .return(scope => `User: ${scope.name}`) + + expect(result).toEqual(F.some('User: Alice')) + }) + + test('DoOption.bindL short-circuits on None', () => { + const getUser = (id: number): F.Option => + id === 1 ? F.some('Alice') : F.none + + const result = F.DoOption.start() + .bind('id', F.some(2)) // Invalid ID + .bindL('name', scope => getUser(scope.id)) + .bind('age', F.some(30)) // Shouldn't execute + .return(scope => `User: ${scope.name}, Age: ${scope.age}`) + + expect(result).toEqual(F.none) + }) + + test('DoOption.bindL handles complex dependencies', () => { + const getProfile = (user: string): F.Option<{age: number}> => + user === 'Alice' ? F.some({age: 30}) : F.none + + const result = F.DoOption.start() + .bind('id', F.some(1)) + .bindL('name', scope => scope.id === 1 ? F.some('Alice') : F.none) + .bindL('profile', scope => getProfile(scope.name)) + .return(scope => `${scope.name} is ${scope.profile.age} years old`) + + expect(result).toEqual(F.some('Alice is 30 years old')) + }) + + test('DoOption.doL executes scoped effects', () => { + const log = (msg: string): F.Option => F.some(undefined) + let loggedMessage = '' + + const result = F.DoOption.start() + .bind('id', F.some(1)) + .doL(scope => { + loggedMessage = `Processing user ${scope.id}` + return log(loggedMessage) + }) + .bind('name', F.some('Alice')) + .return(scope => scope.name) + + expect(result).toEqual(F.some('Alice')) + expect(loggedMessage).toBe('Processing user 1') + }) + + test('DoOption.doL short-circuits on None', () => { + let effectRun = false + + const result = F.DoOption.start() + .bind('id', F.some(1)) + .doL(scope => { + effectRun = true + return scope.id === 1 ? F.none : F.some(undefined) + }) + .bind('name', F.some('Alice')) // Shouldn't execute + .return(scope => scope.name) + + expect(result).toEqual(F.none) + expect(effectRun).toBe(true) + }) + + test('DoOption.doL with multiple effects', () => { + const effects: string[] = [] + + const result = F.DoOption.start() + .bind('id', F.some(1)) + .doL(scope => { + effects.push(`Starting processing for ${scope.id}`) + return F.some(undefined) + }) + .bindL('name', scope => { + effects.push(`Fetching name for ${scope.id}`) + return scope.id === 1 ? F.some('Alice') : F.none + }) + .doL(scope => { + effects.push(`Completed processing for ${scope.name}`) + return F.some(undefined) + }) + .return(scope => scope.name) + + expect(result).toEqual(F.some('Alice')) + expect(effects).toEqual([ + 'Starting processing for 1', + 'Fetching name for 1', + 'Completed processing for Alice', + ]) + }) + + test('DoOption short-circuits on None', () => { + const result = F.DoOption.start() + .bind('a', F.some(10)) + .bind('b', F.none) + .bind('c', F.some(30)) + .return(scope => scope.a + scope.b + scope.c) + + expect(result).toEqual(F.none) + }) + + test('AsyncDoOption handles promises', async () => { + const fetchNumber = (n: number): Promise> => + Promise.resolve(F.some(n * 2)) + + const result = await F.AsyncDoOption.start() + .bind('a', fetchNumber(10)) + .bind('b', fetchNumber(20)) + .return(({a, b}) => a + b) + + expect(result).toEqual(F.some(60)) + }) + + test('AsyncDoOption handles empty promise results', async () => { + const emptyFetch = (): Promise> => Promise.resolve(F.none) + + const result = await F.AsyncDoOption.start() + .bind('a', Promise.resolve(F.some(10))) + .bind('b', emptyFetch()) + .return(({a, b}) => a + b) + + expect(result).toEqual(F.none) + }) + + test('AsyncDoOption handles promise rejections', async () => { + const failingFetch = (): Promise> => + Promise.reject(new Error('Failed')) + + const result = await F.AsyncDoOption.start() + .bind('a', Promise.resolve(F.some(10))) + .bind('b', failingFetch()) + .return(({a, b}) => a + b) + + expect(result).toEqual(F.none) + }) +}) + +describe('Either Module', () => { + test('creates Right and Left', () => { + expect(F.right(5)).toEqual({_tag: 'Right', right: 5}) + expect(F.left('error')).toEqual({_tag: 'Left', left: 'error'}) + }) + + test('eitherMap transforms values', () => { + const double = (n: number) => n * 2 + expect(F.eitherMap(double)(F.right(5))).toEqual(F.right(10)) + expect(F.eitherMap(double)(F.left('error'))).toEqual(F.left('error')) + expect(F.eitherMap(() => null)(F.right(5))).toEqual(F.right(null)) // Edge: mapping to null + }) + + test('eitherChain handles sequential operations', () => { + const safeDivide = (a: number, b: number): F.Either => + b === 0 ? F.left('Division by zero') : F.right(a / b) + + expect(F.eitherChain((n: number) => safeDivide(100, n))(F.right(2))) + .toEqual(F.right(50)) + expect(F.eitherChain((n: number) => safeDivide(100, n))(F.left('error'))) + .toEqual(F.left('error')) + expect(F.eitherChain(() => F.left('forced error'))(F.right(5))).toEqual( + F.left('forced error'), + ) + }) + + test('fold handles both cases', () => { + const handleSuccess = F.fold( + (e: string) => `Error: ${e}`, + (n: number) => `Result: ${n}`, + ) + + expect(handleSuccess(F.right(42))).toBe('Result: 42') + expect(handleSuccess(F.left('failure'))).toBe('Error: failure') + expect(handleSuccess(F.right(0))).toBe('Result: 0') // Edge: falsy success value + }) + + test('DoEither composition', () => { + const result = F.DoEither.start() + .bind('a', F.right(10)) + .bind('b', F.right(20)) + .return(({a, b}) => a + b) + + expect(result).toEqual(F.right(30)) + }) + + test('DoEither short-circuits on failure', () => { + const result = F.DoEither.start() + .bind('a', F.right(10)) + .bind('b', F.left('error')) + .bind('c', F.right(30)) + .return(({a, b, c}) => a + b + c) + + expect(result).toEqual(F.left('error')) + }) + + test('DoEither handles effects', () => { + const result = F.DoEither.start() + .bind('a', F.right(10)) + .do(F.right(undefined)) // Execute effect + .bind('b', F.right(20)) + .return(({a, b}) => a + b) + + expect(result).toEqual(F.right(30)) + }) +}) + +describe('List Module', () => { + let list: F.List + + beforeEach(() => { + list = F.cons(1, F.cons(2, F.cons(3, F.nil))) + }) + + test('constructs lists properly', () => { + expect(list._tag).toBe('Cons') + expect(list.head).toBe(1) + expect(list.tail._tag).toBe('Cons') + expect(list.tail.head).toBe(2) + expect(F.nil._tag).toBe('Nil') // Edge: empty list + }) + + test('fromArray and toArray conversions', () => { + const arr = [4, 5, 6] + const newList = F.fromArray(arr) + expect(F.toArray(newList)).toEqual(arr) + expect(F.toArray(F.nil)).toEqual([]) // Edge: empty array + }) + + test('listMap transforms elements', () => { + const double = (n: number) => n * 2 + const doubled = F.listMap(double)(list) + expect(F.toArray(doubled)).toEqual([2, 4, 6]) + + // Edge: empty list + expect(F.toArray(F.listMap(double)(F.nil))).toEqual([]) + + // Edge: mapping to different types + const toString = F.listMap((n: number) => n.toString()) + expect(F.toArray(toString(list))).toEqual(['1', '2', '3']) + }) + + test('listReduce sums values', () => { + const sum = F.listReduce((acc: number, val: number) => acc + val, 0)(list) + expect(sum).toBe(6) + + // Edge: empty list + expect(F.listReduce((acc, val) => acc + val, 0)(F.nil)).toBe(0) + + // Edge: different initial type + const asString = F.listReduce((acc: string, val: number) => acc + val, '')( + list, + ) + expect(asString).toBe('123') + }) + + test('handles large lists', async () => { + // Edge: performance test for large list + const largeArray = Array.from({length: 10000}, (_, i) => i) + const largeList = F.fromArray(largeArray) + + const sum = F.listReduce((acc, n) => acc + n, 0)(largeList) + expect(sum).toBe(49995000) + }) +}) + +describe('Utility Functions', () => { + test('pipe chains functions left-to-right', () => { + const result = F.pipe( + 2, + n => n * 3, + n => n + 1, + n => `${n}`, + ) + expect(result).toBe('7') + + // Edge: single function + expect(F.pipe(5, n => n * 2)).toBe(10) + }) + + test('compose chains functions right-to-left', () => { + const sum = (a: number) => (b: number) => a + b + const func = F.compose( + (s: string) => s.toUpperCase(), + (n: number) => `Result: ${n}`, + sum(2), + ) + expect(func(3)).toBe('RESULT: 5') + + // Edge: single function + const double = F.compose((n: number) => n * 2) + expect(double(5)).toBe(10) + }) + + test('tryCatch captures errors', () => { + const success = F.tryCatch(() => 42) + const failure = F.tryCatch(() => { + throw new Error('Failed') + }) + const throwsString = F.tryCatch(() => { + throw 'String error' + }) + + expect(success).toEqual(F.right(42)) + expect(failure._tag).toBe('Left') + expect((failure as any).left.message).toBe('Failed') + expect(throwsString._tag).toBe('Left') + expect((throwsString as any).left.message).toBe('String error') + }) + + test('hasTag creates type guards', () => { + const isCons = F.hasTag('Cons') + const list = F.cons(1, F.nil) + + expect(isCons(list)).toBe(true) + expect(isCons(F.nil)).toBe(false) + expect(isCons(F.some(1))).toBe(false) // Different tagged type + }) +}) + +describe('Bun Extensions', () => { + const TEST_FILE = 'test.txt' + const JSON_FILE = 'data.json' + + beforeAll(async () => { + // Create test files + await Bun.write(TEST_FILE, 'Hello\nWorld\n') + await Bun.write(JSON_FILE, '{"id": 1, "name": "Test"}') + }) + + afterEach(async () => { + // Clean up all test-generated files + const filesToClean = [ + 'output.txt', + 'empty.txt', + 'protected.txt', + ] + + for (const file of filesToClean) { + try { + if (await Bun.file(file).exists()) { + await Bun.rm(file) + } + } catch {} + } + }) + + test('readFileSafe reads files', async () => { + const result = await F.bun.readFileSafe(TEST_FILE) + expect(result).toEqual(F.right('Hello\nWorld\n')) + }) + + test('readFileSafe handles missing files', async () => { + const result = await F.bun.readFileSafe('missing.txt') + expect(result._tag).toBe('Left') + expect((result as any).left.message).toBe('File not found') + }) + + test('readFileSafe handles directory paths', async () => { + const result = await F.bun.readFileSafe('./') + expect(result._tag).toBe('Left') + expect((result as any).left).toBeInstanceOf(Error) + }) + + test('safeJsonParse parses valid JSON', () => { + const json = '{"id": 1, "name": "Test"}' + const result = F.bun.safeJsonParse<{id: number, name: string}>(json) + expect(result).toEqual(F.right({id: 1, name: 'Test'})) + }) + + test('safeJsonParse handles invalid JSON', () => { + const invalidJson = '{invalid}' + const result = F.bun.safeJsonParse(invalidJson) + expect(result._tag).toBe('Left') + expect((result as any).left).toBeInstanceOf(Error) + }) + + test('safeJsonParse handles empty strings', () => { + const result = F.bun.safeJsonParse('') + expect(result._tag).toBe('Left') + expect((result as any).left).toBeInstanceOf(Error) + }) + + test('asyncReduce processes lists', async () => { + const list = F.fromArray([1, 2, 3]) + const asyncSum = async (acc: number, val: number) => acc + val + const result = await F.bun.asyncReduce(list, asyncSum, 0) + expect(result).toBe(6) + }) + + test('asyncReduce handles empty lists', async () => { + const asyncSum = async (acc: number, val: number) => acc + val + const result = await F.bun.asyncReduce(F.nil, asyncSum, 10) + expect(result).toBe(10) + }) + + test('envOption reads environment variables', () => { + // Update: envOption should return some for empty strings + const port = F.bun.envOption('TEST_PORT') + const missing = F.bun.envOption('MISSING_VAR') + const empty = F.bun.envOption('EMPTY_VAR') + + expect(port).toEqual(F.some('3000')) + expect(missing).toEqual(F.none) + expect(empty).toEqual(F.some('')) // Empty string should be Some("") + }) + + test('listToFile writes lists to files', async () => { + const list = F.fromArray(['Line 1', 'Line 2', 'Line 3']) + const result = await F.bun.listToFile(list, 'output.txt') + + expect(result).toEqual(F.right(undefined)) + const content = await Bun.file('output.txt').text() + expect(content).toBe('Line 1\nLine 2\nLine 3\n') + }) + + test('listToFile handles empty lists', async () => { + // Use unique filename to prevent conflicts + const result = await F.bun.listToFile(F.nil, 'empty.txt') + expect(result).toEqual(F.right(undefined)) + const content = await Bun.file('empty.txt').text() + expect(content).toBe('') + }) + + test('listToFile handles write errors', async () => { + // Try to write to protected directory + const list = F.fromArray(['Test']) + const result = await F.bun.listToFile(list, '/root/test.txt') + expect(result._tag).toBe('Left') + expect((result as any).left).toBeInstanceOf(Error) + }) + + test('createServer handles requests', async () => { + const handler = (req: Request): F.Either => { + return req.url.endsWith('/hello') + ? F.right(new Response('Hello World')) + : F.left(new Error('Not found')) + } + + const server = F.bun.createServer(handler) + + // Test valid request + const response1 = await server.fetch('http://localhost/hello') + expect(await response1.text()).toBe('Hello World') + + // Test invalid request + const response2 = await server.fetch('http://localhost/not-found') + expect(response2.status).toBe(500) + expect(await response2.text()).toBe('Server error') + + server.stop() + }) + + test('createServer handles thrown errors', async () => { + // This should be caught by the server's error handler + const handler = () => { + throw new Error('Unexpected error') + } + + const server = F.bun.createServer(handler as any) + const response = await server.fetch('http://localhost/') + expect(response.status).toBe(500) + expect(await response.text()).toBe('Internal error') + + server.stop() + }) +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fea0e89 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "skipLibCheck": true, + "jsx": "preserve", + "types": ["@types/bun"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +}