commit d88075c16997ad34163fc74b51229038866419f9 Author: Eric Rumsey Date: Sat Jun 7 20:52:15 2025 -0500 Initial commit 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"] +}