From 16a84a60ced62f7d55925f2c2c4f290e7ad8c133 Mon Sep 17 00:00:00 2001 From: helabenkhalfallah Date: Fri, 14 Feb 2025 17:13:41 +0100 Subject: [PATCH] [FEATURE]: add functional programming patterns --- README.md | 4 + benchmark-fp.ts | 73 ++++++- .../algebraic-data-type/Match-test.ts | 59 ++++++ src/functional/algebraic-data-type/Match.ts | 44 +++++ .../algebraic-data-type/MatchProperty-test.ts | 100 ++++++++++ src/functional/index.ts | 8 + src/functional/lens/Lens-test.ts | 90 +++++++++ src/functional/lens/Lens.ts | 110 +++++++++++ src/functional/lens/LensProperty-test.ts | 184 ++++++++++++++++++ src/functional/memoize/Memoize-test.ts | 132 +++++++++++++ src/functional/memoize/Memoize.ts | 25 +++ .../memoize/MemoizeProperty-test.ts | 95 +++++++++ src/functional/monads/Effect-test.ts | 2 +- src/functional/trampoline/Trampoline-test.ts | 28 +++ src/functional/trampoline/Trampoline.ts | 22 +++ .../trampoline/TrampolineProperty-test.ts | 45 +++++ .../transducers/Transducers-test.ts | 99 ++++++++++ src/functional/transducers/Transducers.ts | 76 ++++++++ .../transducers/TransducersProperty-test.ts | 48 +++++ 19 files changed, 1239 insertions(+), 5 deletions(-) create mode 100644 src/functional/algebraic-data-type/Match-test.ts create mode 100644 src/functional/algebraic-data-type/Match.ts create mode 100644 src/functional/algebraic-data-type/MatchProperty-test.ts create mode 100644 src/functional/lens/Lens-test.ts create mode 100644 src/functional/lens/Lens.ts create mode 100644 src/functional/lens/LensProperty-test.ts create mode 100644 src/functional/memoize/Memoize-test.ts create mode 100644 src/functional/memoize/Memoize.ts create mode 100644 src/functional/memoize/MemoizeProperty-test.ts create mode 100644 src/functional/trampoline/Trampoline-test.ts create mode 100644 src/functional/trampoline/Trampoline.ts create mode 100644 src/functional/trampoline/TrampolineProperty-test.ts create mode 100644 src/functional/transducers/Transducers-test.ts create mode 100644 src/functional/transducers/Transducers.ts create mode 100644 src/functional/transducers/TransducersProperty-test.ts diff --git a/README.md b/README.md index c9b36e1..7bd543a 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,10 @@ Whether you're building a lightweight application or handling large datasets, ** - **Option**: Safe handling of optional values (Some, None) - **Result**: Error handling without exceptions (Ok, Err) - **Effect**: Deferred computations with error safety +- **Pattern Matching**: Expressive control flow using Match (matcher, case-of) +- **Lenses & Optics**: Immutable state manipulation (Lens, Prism, Traversal) +- **Trampoline**: Converts deep recursion into iteration to prevent stack overflows +- **Transducers**: Composable data transformations with high performance (map, filter, reduce fused) --- diff --git a/benchmark-fp.ts b/benchmark-fp.ts index ab60ae1..cb1f1b2 100644 --- a/benchmark-fp.ts +++ b/benchmark-fp.ts @@ -6,10 +6,14 @@ import { Ok, Option, compose, + composeTransducers, curry, + filterTransducer, + mapTransducer, partial, partialRight, pipe, + takeTransducer, uncurry, } from './src/index.ts'; @@ -28,7 +32,67 @@ function benchmark(fn: () => T, label: string, operation: string): BenchmarkR const benchmarks: BenchmarkResult[] = []; -// Function Composition +// ** Test Dataset: 100,000 Numbers ** +const numbers = Array.from({ length: 100_000 }, (_, i) => i + 1); +const double = (x: number) => x * 2; +const isEven = (x: number) => x % 2 === 0; +const takeLimit = 5000; + +// ** Traditional Array Transformation (map -> filter -> slice) ** +benchmarks.push( + benchmark( + () => { + numbers + .filter(isEven) // Keep even numbers + .map(double) // Double them + .slice(0, takeLimit); // Take first `takeLimit` results + }, + 'Traditional Array Transformation', + 'map().filter().slice()', + ), +); + +// ** Transducer-Based Transformation (reduce) ** +const transducer = composeTransducers( + filterTransducer(isEven), // Filter evens + mapTransducer(double), // Double values + takeTransducer(takeLimit), // Take first `takeLimit` +); + +benchmarks.push( + benchmark( + () => { + numbers.reduce( + transducer((acc, val) => [...acc, val]), + [], + ); + }, + 'Transducer Approach', + 'reduce() with transducers', + ), +); + +benchmarks.push( + benchmark( + () => { + const result: number[] = []; + numbers.reduce( + transducer((acc, val) => { + acc.push(val); // Mutate instead of spreading + return acc; + }), + result, + ); + }, + 'Transducer Optimized', + 'reduce() with transducers (optimized)', + ), +); + +// ** Display Benchmark Results ** +console.table(benchmarks); + +// **Existing Function Composition Benchmarks** benchmarks.push( benchmark( () => { @@ -55,7 +119,7 @@ benchmarks.push( ), ); -// Currying & Partial Application +// **Currying & Partial Application** benchmarks.push( benchmark( () => { @@ -105,7 +169,7 @@ benchmarks.push( ), ); -// Functors: CanApply +// **Functors: CanApply** benchmarks.push( benchmark( () => { @@ -119,7 +183,7 @@ benchmarks.push( ), ); -// Monads: Option, Result, and Effect +// **Monads: Option, Result, and Effect** benchmarks.push( benchmark( () => { @@ -152,4 +216,5 @@ benchmarks.push( ), ); +// **Display Benchmark Results** console.table(benchmarks); diff --git a/src/functional/algebraic-data-type/Match-test.ts b/src/functional/algebraic-data-type/Match-test.ts new file mode 100644 index 0000000..683745e --- /dev/null +++ b/src/functional/algebraic-data-type/Match-test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { match } from './Match.ts'; + +describe('match function', () => { + it('should return the correct result for number matching', () => { + const result = match(5, [ + [(n) => n === 10, () => 'Exactly ten'], + [(n) => n === 0, () => 'Zero'], + [(n) => n > 10, (n) => `Greater than 10: ${n}`], + [(n) => n > 0, (n) => `Positive: ${n}`], + ]); + + expect(result).toBe('Positive: 5'); + }); + + it('should return the correct result for string matching', () => { + const result = match('hello', [ + [(s) => s === 'world', () => 'Matched world'], + [(s) => s === 'hello', () => 'Matched hello'], + ]); + + expect(result).toBe('Matched hello'); + }); + + it('should return the correct result for object matching', () => { + const user = { name: 'Alice', age: 25 }; + + const result = match(user, [ + [(u) => u.age > 30, () => 'Older than 30'], + [(u) => u.age >= 25, () => 'Adult'], + ]); + + expect(result).toBe('Adult'); + }); + + it('should return the correct result for tuple matching', () => { + const point: [number, number] = [3, 4]; + + const result = match(point, [ + [(p) => p[0] === 0 && p[1] === 0, () => 'Origin'], + [(p) => p[0] === 0, () => 'On Y-axis'], + [(p) => p[1] === 0, () => 'On X-axis'], + // eslint-disable-next-line @typescript-eslint/no-unused-vars + [(_) => true, () => 'Somewhere else'], + ]); + + expect(result).toBe('Somewhere else'); + }); + + it('should throw an error if no match is found', () => { + expect(() => + match(100, [ + [(n) => n === 10, () => 'Exactly ten'], + [(n) => n === 0, () => 'Zero'], + ]), + ).toThrowError('No match found'); + }); +}); diff --git a/src/functional/algebraic-data-type/Match.ts b/src/functional/algebraic-data-type/Match.ts new file mode 100644 index 0000000..041626f --- /dev/null +++ b/src/functional/algebraic-data-type/Match.ts @@ -0,0 +1,44 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * Pattern matching function for TypeScript. + * + * @template T - The type of the value to be matched. + * @param {T} value - The input value to match against patterns. + * @param {Array<[ (value: T) => boolean, (value: T) => any ]>} patterns - + * An array of tuples where: + * - The first element is a predicate function that checks if the pattern applies. + * - The second element is a handler function that executes when the pattern matches. + * @returns {any} - The result of the first matching handler function. + * @throws {Error} If no pattern matches the input value. + * + * @example + * const result = match(5, [ + * [(n) => n === 10, () => "Exactly ten"], + * [(n) => n > 0, (n) => `Positive: ${n}`], + * ]); + * console.log(result); // Output: "Positive: 5" + * + * @example + * // Matching against an Option type + * type None = { type: "None" }; + * type Some = { type: "Some"; value: T }; + * type Option = Some | None; + * + * const None: None = { type: "None" }; + * const Some = (value: T): Some => ({ type: "Some", value }); + * + * const value: Option = Some(15); + * const message = match(value, [ + * [(x) => x.type === "Some", (x) => `Some value: ${x.value}`], + * [(x) => x.type === "None", () => "No value"] + * ]); + * console.log(message); // Output: "Some value: 15" + */ +export function match(value: T, patterns: [(value: T) => boolean, (value: T) => any][]): any { + for (const [predicate, handler] of patterns) { + if (predicate(value)) { + return handler(value); + } + } + throw new Error('No match found'); +} diff --git a/src/functional/algebraic-data-type/MatchProperty-test.ts b/src/functional/algebraic-data-type/MatchProperty-test.ts new file mode 100644 index 0000000..4b545b0 --- /dev/null +++ b/src/functional/algebraic-data-type/MatchProperty-test.ts @@ -0,0 +1,100 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; + +import { match } from './Match.ts'; + +describe('match function - property-based testing', () => { + it('should return the expected result for positive numbers', () => { + fc.assert( + fc.property(fc.integer({ min: 1 }), (n) => { + const result = match(n, [[(x) => x > 0, (x) => `Positive: ${x}`]]); + expect(result).toBe(`Positive: ${n}`); + }), + ); + }); + + it('should correctly match string patterns', () => { + fc.assert( + fc.property(fc.string(), (s) => { + const result = match(s, [ + [(x) => x.startsWith('A'), () => 'Starts with A'], + [(x) => x.endsWith('Z'), () => 'Ends with Z'], + [(x) => x.length === 0, () => 'Empty string'], + // eslint-disable-next-line @typescript-eslint/no-unused-vars + [(_) => true, () => 'Fallback'], + ]); + + // Property: The result should be a known output + expect(['Starts with A', 'Ends with Z', 'Empty string', 'Fallback']).toContain( + result, + ); + }), + ); + }); + + it('should correctly match objects', () => { + fc.assert( + fc.property( + fc.record({ + age: fc.integer({ min: 0, max: 120 }), + name: fc.string(), + }), + (user) => { + const result = match(user, [ + [(u) => u.age < 18, () => 'Minor'], + [(u) => u.age >= 18, () => 'Adult'], + ]); + + expect(['Minor', 'Adult']).toContain(result); + }, + ), + ); + }); + + it('should always return a value from the match cases', () => { + fc.assert( + fc.property(fc.anything(), (randomValue) => { + try { + match(randomValue, [ + [(x) => typeof x === 'number', (x) => `Number: ${x}`], + [(x) => typeof x === 'string', (x) => `String: ${x}`], + ]); + } catch (e) { + expect(e).toBeInstanceOf(Error); + } + }), + ); + }); + + it('should throw an error when no match is found', () => { + fc.assert( + fc.property(fc.anything(), (randomValue) => { + expect(() => + match(randomValue, [ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + [(x) => false, () => 'This never matches'], // Always false, so match() should always fail + ]), + ).toThrowError('No match found'); + }), + ); + }); + + it('should throw an error when no match is found', () => { + fc.assert( + fc.property(fc.anything(), (randomValue) => { + const patterns: [(value: any) => boolean, (value: any) => any][] = [ + [(x) => typeof x === 'number', () => "It's a number"], + [(x) => typeof x === 'string', () => "It's a string"], + ]; + + // Check if any predicate matches + const hasMatch = patterns.some(([predicate]) => predicate(randomValue)); + + if (!hasMatch) { + expect(() => match(randomValue, patterns)).toThrowError('No match found'); + } + }), + ); + }); +}); diff --git a/src/functional/index.ts b/src/functional/index.ts index 506497e..0a14ce2 100644 --- a/src/functional/index.ts +++ b/src/functional/index.ts @@ -2,4 +2,12 @@ export { compose, pipe } from './composition/Composition.ts'; export { curry, uncurry } from './curry/Curry.ts'; export { partial, partialRight } from './partial/Partial.ts'; export { CanApply } from './functors/CanApply.ts'; +export { match } from './algebraic-data-type/Match.ts'; +export { lens, isoLens, optionalLens, traversalLens } from './lens/Lens.ts'; +export { + composeTransducers, + mapTransducer, + filterTransducer, + takeTransducer, +} from './transducers/Transducers.ts'; export * from './monads/index.ts'; diff --git a/src/functional/lens/Lens-test.ts b/src/functional/lens/Lens-test.ts new file mode 100644 index 0000000..7087eaf --- /dev/null +++ b/src/functional/lens/Lens-test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; + +import { isoLens, lens, optionalLens, traversalLens } from './Lens.ts'; + +describe('Lens', () => { + it('should get and set a property immutably', () => { + type User = { name: string; age: number }; + + const ageLens = lens( + (u) => u.age, + (age, u) => ({ ...u, age }), + ); + + const user: User = { name: 'Alice', age: 25 }; + + expect(ageLens.get(user)).toBe(25); + + const updatedUser = ageLens.set(30, user); + expect(updatedUser.age).toBe(30); + expect(user.age).toBe(25); // Original object remains unchanged + }); + + it('should compose lenses for nested properties', () => { + type Address = { city: string; zip: string }; + type User = { name: string; address: Address }; + + const addressLens = lens( + (u) => u.address, + (address, u) => ({ ...u, address }), + ); + + const cityLens = lens( + (addr) => addr.city, + (city, addr) => ({ ...addr, city }), + ); + + const userCityLens = addressLens.compose(cityLens); + + const user: User = { name: 'Alice', address: { city: 'Paris', zip: '75001' } }; + + expect(userCityLens.get(user)).toBe('Paris'); + + const updatedUser = userCityLens.set('London', user); + expect(updatedUser.address.city).toBe('London'); + expect(user.address.city).toBe('Paris'); // Original object remains unchanged + }); +}); + +describe('TraversalLens', () => { + it('should modify all elements in an array immutably', () => { + const numberLens = traversalLens(); + + const numbers = [1, 2, 3, 4]; + + const doubledNumbers = numberLens.modify((n) => n * 2, numbers); + expect(doubledNumbers).toEqual([2, 4, 6, 8]); + expect(numbers).toEqual([1, 2, 3, 4]); // Original array remains unchanged + }); +}); + +describe('OptionalLens', () => { + it('should get and set optional properties safely', () => { + type Profile = { username?: string }; + + const usernameLens = optionalLens( + (p) => p.username ?? null, + (username, p) => ({ ...p, username }), + ); + + const profile: Profile = {}; + + expect(usernameLens.get(profile)).toBeNull(); + + const updatedProfile = usernameLens.set('newUser', profile); + expect(updatedProfile.username).toBe('newUser'); + expect(profile.username).toBeUndefined(); // Original object remains unchanged + }); +}); + +describe('IsoLens', () => { + it('should convert between two equivalent representations', () => { + const celsiusToFahrenheit = isoLens( + (c) => c * 1.8 + 32, // °C to °F + (f) => (f - 32) / 1.8, // °F to °C + ); + + expect(celsiusToFahrenheit.get(0)).toBe(32); + expect(celsiusToFahrenheit.reverseGet(32)).toBe(0); + }); +}); diff --git a/src/functional/lens/Lens.ts b/src/functional/lens/Lens.ts new file mode 100644 index 0000000..b8cd9c7 --- /dev/null +++ b/src/functional/lens/Lens.ts @@ -0,0 +1,110 @@ +/** + * Functional Lenses for Immutable State Updates. + * + * This module provides different types of lenses for working with immutable data: + * - `Lens`: Get/set operations for a single property. + * - `TraversalLens`: Transform arrays immutably. + * - `OptionalLens`: Handle optional (nullable) properties. + * - `IsoLens`: Convert between two representations. + * + * @module Lenses + */ + +/** Standard Lens for accessing and modifying a single property. */ +export type Lens = { + /** Gets a value from an object. */ + get: (obj: T) => U; + /** Returns a new object with the updated value. */ + set: (value: U, obj: T) => T; + /** Composes two lenses to create a new, nested lens. */ + compose: (other: Lens) => Lens; +}; + +/** + * Creates a lens for a specific property. + * + * @template T - The parent object type. + * @template U - The property type. + * @param {(obj: T) => U} getter - Function to get the property. + * @param {(value: U, obj: T) => T} setter - Function to set the property immutably. + * @returns {Lens} + */ +export const lens = (getter: (obj: T) => U, setter: (value: U, obj: T) => T): Lens => ({ + get: getter, + set: setter, + compose(other: Lens): Lens { + return lens( + (obj) => other.get(this.get(obj)), // Composing getters + (value, obj) => this.set(other.set(value, this.get(obj)), obj), // Composing setters + ); + }, +}); + +/** Lens for transforming all elements in an array. */ +export type TraversalLens = { + /** Returns the array itself. */ + get: (arr: T[]) => T[]; + /** Returns a new array with transformed values. */ + modify: (fn: (value: T) => T, arr: T[]) => T[]; +}; + +/** + * Creates a traversal lens for arrays. + * + * @template T - The type of array elements. + * @returns {TraversalLens} + */ +export const traversalLens = (): TraversalLens => ({ + get: (arr) => arr, + modify: (fn, arr) => arr.map(fn), +}); + +/** Lens for handling optional (nullable) properties. */ +export type OptionalLens = { + /** Gets a value from an object, or `null` if missing. */ + get: (obj: T) => U | null; + /** Returns a new object with the updated value. */ + set: (value: U, obj: T) => T; +}; + +/** + * Creates a lens for optional properties that may be missing or null. + * + * @template T - The parent object type. + * @template U - The property type. + * @param {(obj: T) => U | null} getter - Function to get the property or return `null`. + * @param {(value: U, obj: T) => T} setter - Function to set the property immutably. + * @returns {OptionalLens} + */ +export const optionalLens = ( + getter: (obj: T) => U | null, + setter: (value: U, obj: T) => T, +): OptionalLens => ({ + get: getter, + set: setter, +}); + +/** Lens for transforming values between two equivalent representations. */ +export type IsoLens = { + /** Converts a value from `T` to `U`. */ + get: (value: T) => U; + /** Converts a value from `U` back to `T`. */ + reverseGet: (value: U) => T; +}; + +/** + * Creates an isomorphic lens for converting between two equivalent data representations. + * + * @template T - The source type. + * @template U - The target type. + * @param {(value: T) => U} getter - Function to convert from `T` to `U`. + * @param {(value: U) => T} reverseGetter - Function to convert from `U` back to `T`. + * @returns {IsoLens} + */ +export const isoLens = ( + getter: (value: T) => U, + reverseGetter: (value: U) => T, +): IsoLens => ({ + get: getter, + reverseGet: reverseGetter, +}); diff --git a/src/functional/lens/LensProperty-test.ts b/src/functional/lens/LensProperty-test.ts new file mode 100644 index 0000000..31d03c2 --- /dev/null +++ b/src/functional/lens/LensProperty-test.ts @@ -0,0 +1,184 @@ +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; + +import { isoLens, lens, optionalLens, traversalLens } from './Lens.ts'; + +describe('Lens', () => { + it('should get and set a property immutably', () => { + type User = { name: string; age: number }; + + const ageLens = lens( + (u) => u.age, + (age, u) => ({ ...u, age }), + ); + + const user: User = { name: 'Alice', age: 25 }; + + expect(ageLens.get(user)).toBe(25); + + const updatedUser = ageLens.set(30, user); + expect(updatedUser.age).toBe(30); + expect(user.age).toBe(25); // Ensure immutability + expect(updatedUser).not.toBe(user); // Ensure it's a new object + }); + + it('should compose lenses for nested properties', () => { + type Address = { city: string; zip: string }; + type User = { name: string; address: Address }; + + const addressLens = lens( + (u) => u.address, + (address, u) => ({ ...u, address }), + ); + + const cityLens = lens( + (addr) => addr.city, + (city, addr) => ({ ...addr, city }), + ); + + const userCityLens = addressLens.compose(cityLens); + + const user: User = { name: 'Alice', address: { city: 'Paris', zip: '75001' } }; + + expect(userCityLens.get(user)).toBe('Paris'); + + const updatedUser = userCityLens.set('London', user); + expect(updatedUser.address.city).toBe('London'); + expect(user.address.city).toBe('Paris'); // Ensure original remains unchanged + expect(updatedUser).not.toBe(user); // Ensure new object + }); + + it('should preserve structure and immutability in deeply nested updates', () => { + type DeepUser = { profile: { contact: { email: string } } }; + + const contactLens = lens( + (u) => u.profile.contact, + (contact, u) => ({ ...u, profile: { ...u.profile, contact } }), + ); + + const emailLens = lens( + (contact) => contact.email, + (email, contact) => ({ ...contact, email }), + ); + + const deepEmailLens = contactLens.compose(emailLens); + + const user: DeepUser = { profile: { contact: { email: 'alice@example.com' } } }; + + expect(deepEmailLens.get(user)).toBe('alice@example.com'); + + const updatedUser = deepEmailLens.set('new@example.com', user); + expect(updatedUser.profile.contact.email).toBe('new@example.com'); + expect(user.profile.contact.email).toBe('alice@example.com'); // Original remains unchanged + expect(updatedUser).not.toBe(user); + }); + + it('should satisfy the Lens laws', () => { + fc.assert( + fc.property(fc.object(), (obj) => { + const idLens = lens( + (o) => o, + (_, o) => o, // Identity setter: returns the same object + ); + + // Get-Set Law: Setting a value retrieved by `get` should result in the same value + expect(idLens.set(idLens.get(obj), obj)).toEqual(obj); + + // Set-Get Law: Getting a value just set should return the set value + const modified = idLens.set(obj, obj); // Instead of `{}`, we use `obj` + expect(idLens.get(modified)).toEqual(obj); + }), + ); + }); +}); + +describe('TraversalLens', () => { + it('should modify all elements in an array immutably', () => { + const numberLens = traversalLens(); + + const numbers = [1, 2, 3, 4]; + + const doubledNumbers = numberLens.modify((n) => n * 2, numbers); + expect(doubledNumbers).toEqual([2, 4, 6, 8]); + expect(numbers).toEqual([1, 2, 3, 4]); // Original array remains unchanged + }); + + it('should maintain lens laws under traversal', () => { + fc.assert( + fc.property(fc.array(fc.integer()), (arr) => { + const arrLens = traversalLens(); + + // Mapping with identity function should not change the array + expect(arrLens.modify((x) => x, arr)).toEqual(arr); + + // Double map f(f(x)) should be same as mapping g(x) = f(f(x)) + const f = (x: number) => x * 2; + expect(arrLens.modify(f, arrLens.modify(f, arr))).toEqual( + arrLens.modify((x) => f(f(x)), arr), + ); + }), + ); + }); +}); + +describe('OptionalLens', () => { + it('should get and set optional properties safely', () => { + type Profile = { username?: string }; + + const usernameLens = optionalLens( + (p) => p.username ?? null, + (username, p) => ({ ...p, username }), + ); + + const profile: Profile = {}; + + expect(usernameLens.get(profile)).toBeNull(); + + const updatedProfile = usernameLens.set('newUser', profile); + expect(updatedProfile.username).toBe('newUser'); + expect(profile.username).toBeUndefined(); // Original remains unchanged + }); + + it('should satisfy OptionalLens properties', () => { + fc.assert( + fc.property(fc.object(), (obj) => { + const optLens = optionalLens( + (o) => o ?? null, + (_, o) => o, + ); + + // Get-Set Law + expect(optLens.set(optLens.get(obj)!, obj)).toEqual(obj); + }), + ); + }); +}); + +describe('IsoLens', () => { + it('should convert between two equivalent representations', () => { + const celsiusToFahrenheit = isoLens( + (c) => c * 1.8 + 32, // °C to °F + (f) => (f - 32) / 1.8, // °F to °C + ); + + expect(celsiusToFahrenheit.get(0)).toBe(32); + expect(celsiusToFahrenheit.reverseGet(32)).toBe(0); + }); + + it('should satisfy isomorphism laws', () => { + fc.assert( + fc.property(fc.integer(), (x) => { + const identityLens = isoLens( + (n) => n, + (n) => n, + ); + + // `get(reverseGet(x))` should return x + expect(identityLens.get(identityLens.reverseGet(x))).toBe(x); + + // `reverseGet(get(x))` should return x + expect(identityLens.reverseGet(identityLens.get(x))).toBe(x); + }), + ); + }); +}); diff --git a/src/functional/memoize/Memoize-test.ts b/src/functional/memoize/Memoize-test.ts new file mode 100644 index 0000000..1313131 --- /dev/null +++ b/src/functional/memoize/Memoize-test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { memoize } from './Memoize.ts'; + +describe('memoize', () => { + it('should cache results for primitive arguments', () => { + const mockFn = vi.fn((a: number, b: number) => a + b); + const memoizedFn = memoize(mockFn); + + // First call, should compute + expect(memoizedFn(2, 3)).toBe(5); + expect(mockFn).toHaveBeenCalledTimes(1); + + // Second call with same arguments, should use cache + expect(memoizedFn(2, 3)).toBe(5); + expect(mockFn).toHaveBeenCalledTimes(1); + + // Call with different arguments, should compute again + expect(memoizedFn(4, 5)).toBe(9); + expect(mockFn).toHaveBeenCalledTimes(2); + }); + + it('should cache results for object arguments', () => { + const mockFn = vi.fn((obj: { a: number }) => obj.a + 1); + const memoizedFn = memoize(mockFn); + + const obj1 = { a: 1 }; + const obj2 = { a: 2 }; + + // First call, should compute + expect(memoizedFn(obj1)).toBe(2); + expect(mockFn).toHaveBeenCalledTimes(1); + + // Second call with same object, should use cache + expect(memoizedFn(obj1)).toBe(2); + expect(mockFn).toHaveBeenCalledTimes(1); + + // Call with a different object, should compute again + expect(memoizedFn(obj2)).toBe(3); + expect(mockFn).toHaveBeenCalledTimes(2); + }); + + it('should handle functions with no arguments', () => { + const mockFn = vi.fn(() => 42); + const memoizedFn = memoize(mockFn); + + // First call, should compute + expect(memoizedFn()).toBe(42); + expect(mockFn).toHaveBeenCalledTimes(1); + + // Second call, should use cache + expect(memoizedFn()).toBe(42); + expect(mockFn).toHaveBeenCalledTimes(1); + }); + + it('should handle functions with mixed primitive and object arguments', () => { + const mockFn = vi.fn((a: number, obj: { b: number }) => a + obj.b); + const memoizedFn = memoize(mockFn); + + const obj1 = { b: 2 }; + const obj2 = { b: 3 }; + + // First call, should compute + expect(memoizedFn(1, obj1)).toBe(3); + expect(mockFn).toHaveBeenCalledTimes(1); + + // Second call with same arguments, should use cache + expect(memoizedFn(1, obj1)).toBe(3); + expect(mockFn).toHaveBeenCalledTimes(1); + + // Call with different object, should compute again + expect(memoizedFn(1, obj2)).toBe(4); + expect(mockFn).toHaveBeenCalledTimes(2); + }); + + it('should handle functions with array arguments', () => { + const mockFn = vi.fn((arr: number[]) => arr.reduce((sum, num) => sum + num, 0)); + const memoizedFn = memoize(mockFn); + + const arr1 = [1, 2, 3]; + const arr2 = [4, 5, 6]; + + // First call, should compute + expect(memoizedFn(arr1)).toBe(6); + expect(mockFn).toHaveBeenCalledTimes(1); + + // Second call with same array, should use cache + expect(memoizedFn(arr1)).toBe(6); + expect(mockFn).toHaveBeenCalledTimes(1); + + // Call with a different array, should compute again + expect(memoizedFn(arr2)).toBe(15); + expect(mockFn).toHaveBeenCalledTimes(2); + }); + + it('should handle functions with complex nested arguments', () => { + const mockFn = vi.fn((obj: { a: { b: number } }) => obj.a.b + 1); + const memoizedFn = memoize(mockFn); + + const obj1 = { a: { b: 1 } }; + const obj2 = { a: { b: 2 } }; + + // First call, should compute + expect(memoizedFn(obj1)).toBe(2); + expect(mockFn).toHaveBeenCalledTimes(1); + + // Second call with same object, should use cache + expect(memoizedFn(obj1)).toBe(2); + expect(mockFn).toHaveBeenCalledTimes(1); + + // Call with a different object, should compute again + expect(memoizedFn(obj2)).toBe(3); + expect(mockFn).toHaveBeenCalledTimes(2); + }); + + it('should handle functions with undefined or null arguments', () => { + const mockFn = vi.fn((a: number | undefined, b: number | null) => (a ?? 0) + (b ?? 0)); + const memoizedFn = memoize(mockFn); + + // First call, should compute + expect(memoizedFn(undefined, null)).toBe(0); + expect(mockFn).toHaveBeenCalledTimes(1); + + // Second call with same arguments, should use cache + expect(memoizedFn(undefined, null)).toBe(0); + expect(mockFn).toHaveBeenCalledTimes(1); + + // Call with different arguments, should compute again + expect(memoizedFn(1, null)).toBe(1); + expect(mockFn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/functional/memoize/Memoize.ts b/src/functional/memoize/Memoize.ts new file mode 100644 index 0000000..f13e667 --- /dev/null +++ b/src/functional/memoize/Memoize.ts @@ -0,0 +1,25 @@ +/** + * Memorizes a function for primitive arguments using a `Map`. + * + * @template Args - The function arguments. + * @template Result - The function return type. + * @param {(...args: Args) => Result} fn - The function to memoize. + * @returns {(...args: Args) => Result} - The memoized function. + */ +export const memoize = ( + fn: (...args: Args) => Return, +): ((...args: Args) => Return) => { + const cache = new Map(); + + return (...args: Args) => { + const key = JSON.stringify(args); + + if (cache.has(key)) { + return cache.get(key)!; + } + + const result = fn(...args); + cache.set(key, result); + return result; + }; +}; diff --git a/src/functional/memoize/MemoizeProperty-test.ts b/src/functional/memoize/MemoizeProperty-test.ts new file mode 100644 index 0000000..42effda --- /dev/null +++ b/src/functional/memoize/MemoizeProperty-test.ts @@ -0,0 +1,95 @@ +import * as fc from 'fast-check'; +import { describe, expect, it, vi } from 'vitest'; + +import { memoize } from './Memoize.ts'; + +describe('memoize (property-based)', () => { + it('should return the same result for the same arguments', () => { + fc.assert( + fc.property( + // Generate random functions and arguments + fc.func(fc.integer()), // A function that takes any number of arguments and returns an integer + fc.array(fc.anything()), // An array of arbitrary arguments + (fn, args) => { + const memoizedFn = memoize(fn); + + // Call the memoized function twice with the same arguments + const result1 = memoizedFn(...args); + const result2 = memoizedFn(...args); + + // Ensure the results are the same + expect(result1).toBe(result2); + }, + ), + ); + }); + + it('should call the original function only once for the same arguments', () => { + fc.assert( + fc.property( + // Generate random functions and arguments + fc.func(fc.integer()), // A function that takes any number of arguments and returns an integer + fc.array(fc.anything()), // An array of arbitrary arguments + (fn, args) => { + const mockFn = vi.fn(fn); // Wrap the function in a mock + const memoizedFn = memoize(mockFn); + + // Call the memoized function twice with the same arguments + memoizedFn(...args); + memoizedFn(...args); + + // Ensure the original function was called only once + expect(mockFn).toHaveBeenCalledTimes(1); + }, + ), + ); + }); + + it('should handle different arguments correctly', () => { + fc.assert( + fc.property( + // Generate random functions and two sets of arguments + fc.func(fc.integer()), // A function that takes any number of arguments and returns an integer + fc.array(fc.anything()), // First set of arguments + fc.array(fc.anything()), // Second set of arguments + (fn, args1, args2) => { + const memoizedFn = memoize(fn); + + // Call the memoized function with two different sets of arguments + const result1 = memoizedFn(...args1); + const result2 = memoizedFn(...args2); + + // If the arguments are the same, the results should be the same + if (JSON.stringify(args1) === JSON.stringify(args2)) { + expect(result1).toBe(result2); + } else { + // If the arguments are different, the results may or may not be the same + // (depending on the function logic, but we can't assume anything here) + expect(true).toBe(true); // Placeholder assertion + } + }, + ), + ); + }); + + it('should handle functions with no arguments', () => { + fc.assert( + fc.property( + // Generate random functions that take no arguments + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + fc.func(fc.integer(), { arity: 0 }), // A function that takes no arguments and returns an integer + (fn) => { + const memoizedFn = memoize(fn); + + // Call the memoized function twice + const result1 = memoizedFn(); + const result2 = memoizedFn(); + + // Ensure the results are the same + expect(result1).toBe(result2); + }, + ), + ); + }); +}); diff --git a/src/functional/monads/Effect-test.ts b/src/functional/monads/Effect-test.ts index 8610221..e58f334 100644 --- a/src/functional/monads/Effect-test.ts +++ b/src/functional/monads/Effect-test.ts @@ -31,7 +31,7 @@ describe('Effect', () => { throw new Error('Failure'); }).recover(() => 0); - expect(effect.run()).toStrictEqual(new Ok(0)); // ✅ Now TypeScript is happy! + expect(effect.run()).toStrictEqual(new Ok(0)); // Now TypeScript is happy! }); it('should not modify success values in recover', () => { diff --git a/src/functional/trampoline/Trampoline-test.ts b/src/functional/trampoline/Trampoline-test.ts new file mode 100644 index 0000000..defe670 --- /dev/null +++ b/src/functional/trampoline/Trampoline-test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { trampoline } from './Trampoline.ts'; + +const sumToZero = (n: number, acc: number = 0): number | (() => number) => + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + n <= 0 ? acc : () => sumToZero(n - 1, acc + n); + +const trampolinedSum = trampoline(sumToZero); + +describe('trampoline - Deeply Nested Sum', () => { + it('should correctly compute sum from 5 to 0', () => { + expect(trampolinedSum(5, 0)).toBe(15); // 5 + 4 + 3 + 2 + 1 + 0 = 15 + }); + + it('should handle very large numbers without stack overflow', () => { + expect(trampolinedSum(100000, 0)).toBe(5000050000); // Sum of first 100000 numbers + }); + + it('should return 0 when starting from 0', () => { + expect(trampolinedSum(0, 0)).toBe(0); + }); + + it('should return 0 when n is negative (acts as identity)', () => { + expect(trampolinedSum(-5, 0)).toBe(0); // Should not sum negative numbers + }); +}); diff --git a/src/functional/trampoline/Trampoline.ts b/src/functional/trampoline/Trampoline.ts new file mode 100644 index 0000000..c390304 --- /dev/null +++ b/src/functional/trampoline/Trampoline.ts @@ -0,0 +1,22 @@ +/** + * Trampoline function to optimize tail-recursive functions by converting recursion into iteration. + * Prevents stack overflow for deeply recursive calls. + * + * @template T - The return type of the function. + * @template Args - The parameter types of the function. + * @param {(...args: Args) => T | (() => T)} fn - The tail-recursive function that returns either a value or a thunk (function). + * @returns {(...args: Args) => T} - A new function that safely executes without deep recursion. + */ +export const trampoline = ( + fn: (...args: Args) => T | (() => T), +): ((...args: Args) => T) => { + return (...args: Args): T => { + let result: T | (() => T) = fn(...args); + + while (typeof result === 'function') { + result = (result as () => T)(); // Explicitly asserting that result is a function before calling it + } + + return result; + }; +}; diff --git a/src/functional/trampoline/TrampolineProperty-test.ts b/src/functional/trampoline/TrampolineProperty-test.ts new file mode 100644 index 0000000..8d63c3b --- /dev/null +++ b/src/functional/trampoline/TrampolineProperty-test.ts @@ -0,0 +1,45 @@ +import fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; + +import { trampoline } from './Trampoline.ts'; + +const sumToZero = (n: number, acc: number = 0): number | (() => number) => + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + n <= 0 ? acc : () => sumToZero(n - 1, acc + n); + +const trampolinedSum = trampoline(sumToZero); + +describe('trampoline - fast-check property-based tests', () => { + it('should return the correct sum for any positive number', () => { + fc.assert( + fc.property(fc.integer({ min: 0, max: 100000 }), (n) => { + const expectedSum = (n * (n + 1)) / 2; // Sum formula + expect(trampolinedSum(n, 0)).toBe(expectedSum); + }), + ); + }); + + it('should handle negative numbers by returning 0', () => { + fc.assert( + fc.property(fc.integer({ min: -100000, max: -1 }), (n) => { + expect(trampolinedSum(n, 0)).toBe(0); + }), + ); + }); + + it('should match the standard recursive implementation for small values', () => { + const recursiveSum = (n: number, acc: number = 0): number => + n <= 0 ? acc : recursiveSum(n - 1, acc + n); + + fc.assert( + fc.property(fc.integer({ min: 0, max: 500 }), (n) => { + expect(trampolinedSum(n, 0)).toBe(recursiveSum(n, 0)); + }), + ); + }); + + it('should not throw stack overflow errors for large numbers', () => { + expect(() => trampolinedSum(100000, 0)).not.toThrow(); + }); +}); diff --git a/src/functional/transducers/Transducers-test.ts b/src/functional/transducers/Transducers-test.ts new file mode 100644 index 0000000..a1075d0 --- /dev/null +++ b/src/functional/transducers/Transducers-test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; + +import { + composeTransducers, + filterTransducer, + mapTransducer, + takeTransducer, +} from './Transducers.ts'; + +describe('Transducers', () => { + it('should correctly apply mapTransducer', () => { + const double = (x: number) => x * 2; + const numbers = [1, 2, 3, 4]; + + const transducer = mapTransducer(double); + const result = numbers.reduce( + transducer((acc, val) => [...acc, val]), + [], + ); + + expect(result).toEqual([2, 4, 6, 8]); + }); + + it('should correctly apply filterTransducer', () => { + const isEven = (x: number) => x % 2 === 0; + const numbers = [1, 2, 3, 4, 5, 6]; + + const transducer = filterTransducer(isEven); + const result = numbers.reduce( + transducer((acc, val) => [...acc, val]), + [], + ); + + expect(result).toEqual([2, 4, 6]); + }); + + it('should correctly apply takeTransducer', () => { + const numbers = [1, 2, 3, 4, 5]; + + const transducer = takeTransducer(3); + const result = numbers.reduce( + transducer((acc, val) => [...acc, val]), + [], + ); + + expect(result).toEqual([1, 2, 3]); + }); + + it('should compose multiple transducers correctly', () => { + const double = (x: number) => x * 2; + const isEven = (x: number) => x % 2 === 0; + + const numbers = [1, 2, 3, 4, 5, 6]; + + const transducer = composeTransducers( + filterTransducer(isEven), // Keep even numbers + mapTransducer(double), // Double them + takeTransducer(2), // Take first 2 results + ); + + const result = numbers.reduce( + transducer((acc, val) => [...acc, val]), + [], + ); + + expect(result).toEqual([4, 8]); // [2*2, 4*2] (first two even numbers doubled) + }); + + it('should work with an empty array', () => { + const double = (x: number) => x * 2; + const numbers: number[] = []; + + const transducer = composeTransducers(mapTransducer(double)); + const result = numbers.reduce( + transducer((acc, val) => [...acc, val]), + [], + ); + + expect(result).toEqual([]); + }); + + it('should correctly process large datasets', () => { + const numbers = Array.from({ length: 1000 }, (_, i) => i + 1); + + const transducer = composeTransducers( + filterTransducer((x) => x % 3 === 0), // Keep multiples of 3 + mapTransducer((x) => x * 2), // Double them + takeTransducer(10), // Take first 10 results + ); + + const result = numbers.reduce( + transducer((acc, val) => [...acc, val]), + [], + ); + + expect(result.length).toBe(10); + expect(result).toEqual([6, 12, 18, 24, 30, 36, 42, 48, 54, 60]); + }); +}); diff --git a/src/functional/transducers/Transducers.ts b/src/functional/transducers/Transducers.ts new file mode 100644 index 0000000..982d3db --- /dev/null +++ b/src/functional/transducers/Transducers.ts @@ -0,0 +1,76 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * Functional Transducers for Efficient Data Processing. + * + * Transducers allow transformation pipelines (map, filter, etc.) without creating intermediate collections. + * + * @module Transducers + */ + +/** + * A Transducer is a higher-order function that takes a reducing function + * and returns an enhanced reducing function. + * + * @template T - The input type. + * @template U - The output type. + */ +export type Transducer = ( + reducer: (acc: U[], value: U) => U[], +) => (acc: U[], value: T) => U[]; + +/** + * Creates a transducer that applies a mapping function to each element. + * + * @template T - The input type. + * @template U - The output type after transformation. + * @param {(x: T) => U} fn - The transformation function. + * @returns {Transducer} - A transducer that applies `fn` to each value. + */ +export const mapTransducer = + (fn: (x: T) => U): Transducer => + (reducer: (acc: U[], value: U) => U[]) => + (acc: U[], value: T): U[] => + reducer(acc, fn(value)); + +/** + * Creates a transducer that filters values based on a predicate function. + * + * @template T - The type of elements. + * @param {(x: T) => boolean} predicate - Function to determine if a value should be kept. + * @returns {Transducer} - A transducer that filters elements. + */ +export const filterTransducer = + (predicate: (x: T) => boolean): Transducer => + (reducer: (acc: T[], value: T) => T[]) => + (acc: T[], value: T): T[] => + predicate(value) ? reducer(acc, value) : acc; + +/** + * Composes multiple transducers into a single transformation pipeline. + * + * @template A - The input type. + * @template Z - The final output type. + * @param {...Transducer[]} transducers - The transducers to compose. + * @returns {Transducer} - A transducer that applies all transformations in sequence. + */ +export const composeTransducers = + (...transducers: Array>): Transducer => + (reducer: (acc: Z[], value: Z) => Z[]) => + transducers.reduceRight( + (acc, transducer) => transducer(acc), + reducer as (acc: any[], value: any) => any[], + ); + +/** + * Limits the number of values processed by the transducer. + * + * @template T - The type of elements. + * @param {number} count - The maximum number of elements to process. + * @returns {Transducer} - A transducer that stops after `count` elements. + */ +export const takeTransducer = + (count: number): Transducer => + (reducer: (acc: T[], value: T) => T[]) => { + let taken = 0; + return (acc: T[], value: T): T[] => (taken++ < count ? reducer(acc, value) : acc); + }; diff --git a/src/functional/transducers/TransducersProperty-test.ts b/src/functional/transducers/TransducersProperty-test.ts new file mode 100644 index 0000000..961ffbe --- /dev/null +++ b/src/functional/transducers/TransducersProperty-test.ts @@ -0,0 +1,48 @@ +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; + +import { + composeTransducers, + filterTransducer, + mapTransducer, + takeTransducer, +} from './Transducers.ts'; + +describe('Transducers', () => { + it('should satisfy transducer laws (identity, composition)', () => { + fc.assert( + fc.property(fc.array(fc.integer()), (numbers) => { + const identity = (x: T) => x; + + const identityTransducer = composeTransducers(mapTransducer(identity)); + + // Identity Law: Mapping with identity function should not change array + expect( + numbers.reduce( + identityTransducer((acc, val) => [...acc, val]), + [], + ), + ).toEqual(numbers); + }), + ); + }); + + it('should correctly apply transducers on large randomized data', () => { + fc.assert( + fc.property(fc.array(fc.integer({ min: -1000, max: 1000 })), (numbers) => { + const transducer = composeTransducers( + filterTransducer((x) => x % 2 === 0), // Keep evens + mapTransducer((x) => x * 2), // Double them + takeTransducer(5), // Take first 5 + ); + + const result = numbers.reduce( + transducer((acc, val) => [...acc, val]), + [], + ); + + expect(result.length).toBeLessThanOrEqual(5); + }), + ); + }); +});