Initial commit

This commit is contained in:
Eric Rumsey 2025-06-07 20:52:15 -05:00
commit d88075c169
17 changed files with 1796 additions and 0 deletions

34
.gitignore vendored Normal file
View File

@ -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

182
README.md Normal file
View File

@ -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 <a name="overview"></a>
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 <a name="either-module"></a>
Represents computations that can succeed (`Right`) or fail (`Left`).
### Types
```typescript
type Left<E> = { readonly _tag: 'Left'; readonly left: E }
type Right<A> = { readonly _tag: 'Right'; readonly right: A }
type Either<A, E> = Left<E> | Right<A>
```
### Constructors
| Function | Description | Example |
|----------|-------------|---------|
| `left<E>(e: E)` | Creates failure case | `left('error')` |
| `right<A>(a: A)` | Creates success case | `right(42)` |
### Operations
| Function | Description | Type Signature |
|----------|-------------|----------------|
| `eitherMap` | Maps over success value | `<A, B, E>(f: (a: A) => B) => (fa: Either<A, E>) => Either<B, E>` |
| `eitherChain` | Chains Either-returning functions | `<A, B, E>(f: (a: A) => Either<B, E>) => (fa: Either<A, E>) => Either<B, E>` |
| `fold` | Handles both cases | `<A, E, B>(onLeft: (e: E) => B, onRight: (a: A) => B) => (fa: Either<A, E>) => 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<number, string>
```
| 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 <a name="list-module"></a>
Immutable linked list implementation.
### Types
```typescript
type Nil = { readonly _tag: 'Nil' }
type Cons<A> = {
readonly _tag: 'Cons'
readonly head: A
readonly tail: List<A>
}
type List<A> = Nil | Cons<A>
```
### 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 | `<A, B>(f: (a: A) => B) => (fa: List<A>) => List<B>` |
| `listReduce` | Reduces list to value | `<A, B>(f: (b: B, a: A) => B, initial: B) => (fa: List<A>) => B` |
| `toArray` | Converts list to array | `<A>(fa: List<A>) => Array<A>` |
## Option Module <a name="option-module"></a>
Represents optional values (`Some` or `None`).
### Types
```typescript
type None = { readonly _tag: 'None' }
type Some<A> = { readonly _tag: 'Some'; readonly value: A }
type Option<A> = None | Some<A>
```
### 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 | `<A, B>(f: (a: A) => B) => (fa: Option<A>) => Option<B>` |
| `chain` | Chains computations | `<A, B>(f: (a: A) => Option<B>) => (fa: Option<A>) => Option<B>` |
| `getOrElse` | Default value | `<A>(defaultValue: A) => (fa: Option<A>) => 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 <a name="utility-module"></a>
### 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 <a name="bun-extensions"></a>
Optimized utilities for Bun runtime.
### File Operations
| Function | Description | Type Signature |
|----------|-------------|----------------|
| `readFileSafe` | Reads file safely | `(path: string) => Promise<Either<string, Error>>` |
| `listToFile` | Writes list to file | `(list: List<string>, path: string) => Promise<Either<void, Error>>` |
### JSON & Environment
| Function | Description |
|----------|-------------|
| `safeJsonParse` | Safe JSON parsing | `safeJsonParse<T>(data): Either<T, Error>` |
| `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<Response, Error> => {
return req.url === '/'
? right(new Response('Hello'))
: left(new Error('Not found'))
}
createServer(handleRequest)

23
bun.lock Normal file
View File

@ -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=="],
}
}

1
data.json Normal file
View File

@ -0,0 +1 @@
{"id": 1, "name": "Test"}

56
dprint.json Normal file
View File

@ -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"
]
}

0
empty.txt Normal file
View File

3
output.txt Normal file
View File

@ -0,0 +1,3 @@
Line 1
Line 2
Line 3

38
package.json Normal file
View File

@ -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"
}
}

121
src/bun/index.ts Normal file
View File

@ -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<Either<string, Error>> => {
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 = <T>(data: string): Either<T, Error> => {
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 <A, B>(
list: List<A>,
reducer: (acc: B, val: A) => Promise<B>,
initial: B,
): Promise<B> => {
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<string> => {
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<string>,
path: string,
): Promise<Either<void, Error>> => {
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<Response, Error>) => {
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})
},
})
}

158
src/either.ts Normal file
View File

@ -0,0 +1,158 @@
/**
* Represents the failure case of an Either
* @typeParam E - The type of the error
*/
export type Left<E> = {readonly _tag: 'Left', readonly left: E}
/**
* Represents the success case of an Either
* @typeParam A - The type of the value
*/
export type Right<A> = {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<A, E> = Right<A> | Left<E>
/**
* 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: E): Either<never, E> => ({
_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: A): Either<A, never> => ({
_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 =
<A, B, E>(f: (a: A) => B) => (fa: Either<A, E>): Either<B, E> =>
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 =
<A, B, E>(f: (a: A) => Either<B, E>) => (fa: Either<A, E>): Either<B, E> =>
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 =
<A, E, B>(onLeft: (e: E) => B, onRight: (a: A) => B) =>
(fa: Either<A, E>): 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<T extends Record<string, unknown>, E> {
/**
* @param either - The current Either value
*/
constructor(private readonly either: Either<T, E>) {}
/**
* 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<K extends string, A>(
key: K,
fa: Either<A, E>,
): DoEither<T & Record<K, A>, E> {
const newEither = eitherChain<T, T & Record<K, A>, E>((scope: T) =>
eitherMap<A, T & Record<K, A>, 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<B>(f: (scope: T) => B): Either<B, E> {
return eitherMap<T, B, E>(f)(this.either)
}
/**
* Returns the current scope
* @returns The current Either scope
*/
done(): Either<T, E> {
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<unknown, E>): DoEither<T, E> {
const newEither = eitherChain<T, T, E>((scope: T) =>
eitherMap<unknown, T, E>(() => 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<unknown, E>): DoEither<T, E> {
const newEither = eitherChain<T, T, E>((scope: T) =>
eitherMap<unknown, T, E>(() => 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<E>(): DoEither<{}, E> {
return new DoEither(right({}))
}
}

5
src/index.ts Normal file
View File

@ -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'

94
src/list.ts Normal file
View File

@ -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<A> = {
readonly _tag: 'Cons',
readonly head: A,
readonly tail: List<A>,
}
/**
* 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<A> = Nil | Cons<A>
/**
* 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 = <A>(head: A, tail: List<A>): List<A> => ({
_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 = <A, B>(f: (a: A) => B) => (fa: List<A>) => List<B>
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 = <A, B>(f: (b: B, a: A) => B, initial: B) => (fa: List<A>) => B
export const listReduce: ListReduce =
<A, B>(f: (b: B, a: A) => B, initial: B) => (fa: List<A>) => {
const reduceHelper = (list: List<A>, 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 = <A>(arr: Array<A>) => List<A>
export const fromArray: FromArray = <A>(arr: Array<A>) =>
arr.reduceRight(
(acc: List<A>, val: A) => cons(val, acc),
nil as List<A>,
)
/**
* 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 = <A>(fa: List<A>) => Array<A>
export const toArray: ToArray = <A>(fa: List<A>) =>
listReduce<A, Array<A>>(
(acc, val) => [...acc, val],
[] as Array<A>,
)(fa)

321
src/option.ts Normal file
View File

@ -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<A> = {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<A> = Some<A> | None
/**
* The singleton instance of None
*/
export const none: Option<never> = {_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 = <A>(value: A): Option<A> => ({
_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 = <A, B>(f: (a: A) => B) => (fa: Option<A>): Option<B> =>
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 =
<A, B>(f: (a: A) => Option<B>) => (fa: Option<A>): Option<B> =>
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 = <A>(defaultValue: A) => (fa: Option<A>): 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: A | null | undefined): Option<A> =>
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<T extends Record<string, unknown>> {
/**
* Creates a new DoOption instance
* @param option - The current Option value representing the accumulated scope
*/
constructor(private readonly option: Option<T>) {}
/**
* 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<K extends string, A>(key: K, fa: Option<A>): DoOption<T & Record<K, A>> {
const newOption = chain((scope: T) =>
map((a: A) => ({...scope, [key]: a}))(fa)
)(this.option)
return new DoOption(newOption as Option<T & Record<K, A>>)
}
/**
* 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<K extends string, A>(
key: K,
f: (scope: T) => Option<A>,
): DoOption<T & Record<K, A>> {
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<T & Record<K, A>>)
}
/**
* 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<B>(f: (scope: T) => B): Option<B> {
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<T> {
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<unknown>): DoOption<T> {
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<unknown>): DoOption<T> {
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<T extends Record<string, unknown>> {
/**
* @param promise - The promise of an Option
*/
private constructor(private readonly promise: Promise<Option<T>>) {}
/**
* 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<K extends string, A>(
key: K,
value: Promise<Option<A>>,
): AsyncDoOption<T & Record<K, A>> {
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<K, A>
>
})
.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<K extends string, A>(
key: K,
f: (scope: T) => Promise<Option<A>>,
): AsyncDoOption<T & Record<K, A>> {
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<K, A>
>
})
.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<Option<unknown>>): AsyncDoOption<T> {
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<Option<unknown>>): AsyncDoOption<T> {
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<B>(f: (scope: T) => B): Promise<Option<B>> {
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<Option<T>> {
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({})))
}
}

201
src/utility.ts Normal file
View File

@ -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: {
<A>(value: A): A,
<A, B>(value: A, fn1: (a: A) => B): B,
<A, B, C>(value: A, fn1: (a: A) => B, fn2: (b: B) => C): C,
<A, B, C, D>(
value: A,
fn1: (a: A) => B,
fn2: (b: B) => C,
fn3: (c: C) => D,
): D,
<A, B, C, D, E>(
value: A,
fn1: (a: A) => B,
fn2: (b: B) => C,
fn3: (c: C) => D,
fn4: (d: D) => E,
): E,
<A, B, C, D, E, F>(
value: A,
fn1: (a: A) => B,
fn2: (b: B) => C,
fn3: (c: C) => D,
fn4: (d: D) => E,
fn5: (e: E) => F,
): F,
<A, B, C, D, E, F, 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,
): G,
<A, B, C, D, E, F, G, 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,
): H,
<A, B, C, D, E, F, G, H, 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,
): I,
<A, B, C, D, E, F, G, H, I, 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,
): J,
<A, B, C, D, E, F, G, H, I, J, K>(
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) => A,
<A, B>(fn1: (a: A) => B): (a: A) => B,
<A, B, C>(fn2: (b: B) => C, fn1: (a: A) => B): (a: A) => C,
<A, B, C, D>(
fn3: (c: C) => D,
fn2: (b: B) => C,
fn1: (a: A) => B,
): (a: A) => D,
<A, B, C, D, E>(
fn4: (d: D) => E,
fn3: (c: C) => D,
fn2: (b: B) => C,
fn1: (a: A) => B,
): (a: A) => E,
<A, B, C, D, E, F>(
fn5: (e: E) => F,
fn4: (d: D) => E,
fn3: (c: C) => D,
fn2: (b: B) => C,
fn1: (a: A) => B,
): (a: A) => F,
<A, B, C, D, E, F, G>(
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,
<A, B, C, D, E, F, G, H>(
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,
<A, B, C, D, E, F, G, H, I>(
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,
<A, B, C, D, E, F, G, H, I, J>(
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,
<A, B, C, D, E, F, G, H, I, J, K>(
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 = <A>(f: () => A): Either<A, Error> => {
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<T> {
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<string>) => x === y._tag

2
test.txt Normal file
View File

@ -0,0 +1,2 @@
Hello
World

538
test/fp-lib.test.ts Normal file
View File

@ -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<number> =>
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<string> =>
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<string> =>
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<void> => 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<F.Option<number>> =>
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<F.Option<number>> => 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<F.Option<number>> =>
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<number, string> =>
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<string>()
.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<string>()
.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<string>()
.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<number>
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<Response, Error> => {
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()
})
})

19
tsconfig.json Normal file
View File

@ -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"]
}