MeLightningspirit's Blog

Funtastic: a functional programming library

Recently, I created an npm package that serves as a collection of various pure functions I've written over the years. Bringing them all together into a single library made perfect sense to me, as it allows for easier reuse in other projects. While I'm aware of other similar libraries like Ramda, I wanted to create something smaller and more practical, drawing inspiration from more pragmatic languages like Rust (which, although not a functional programming language, borrows many ideas) and OCaml.

The goal of this library is to provide a lightweight, efficient, and intuitive toolset that can be easily integrated into different projects without the overhead of larger libraries. It focuses on practicality, taking cues from the simplicity of pratical languages.

Why Functional Programming?

Functional Programming (FP) offers a paradigm shift that can fundamentally transform how we approach coding, problem-solving, and software architecture. The principles of FP emphasize immutability, pure functions, and higher-order functions, which collectively foster code that is more predictable, reusable, and easier to test.

Key Benefits of Functional Programming

What is a pure function? What are side-effects?

The definition of a pure function is closely tied to the mathematical concept of a function: a mapping between two sets that follows a specific rule, where each element in the origin set (domain) is mapped to exactly one element in the destination set (codomain).

In 1936, Alonzo Church developed a formal system in mathematics known as Lambda Calculus to express any kind of computation. Lambda Calculus is Turing complete, meaning it is equivalent in power to a Turing machine. It adheres to this rule and allows for the definition of formal logical languages that operate within these constraints.

Given this definition, we can say that a pure function is a deterministic algorithm that, for a given input, always produces the same output. Computationally speaking, a pure function can be thought of as a large hash table that maps elements from the domain to the codomain. This is why computationally expensive pure functions can be memoized, and their computation can be parallelized.

When people refer to side effects, they mean that a pure function cannot access or modify any state, internal or external. Any attempt to do so compromises the purity of the function.

Funtastic

I started by defining some algebraic structures to constraint the implementations, some of them include:

I don't want to enter in many details regarding each one of them, but if you're interested in learning more, I suggest this Reddit post.

These algebraic structures are the foundation of most of the functions that are defined in the library.

identity :: x -> x

The identity function is a simple utility that takes a single argument and returns it without any modification. This function is often used as a placeholder or default function when a value needs to be returned unchanged.

// Using the identity function
const result = identity(42); // result is 42
 
const object = { key: 'value' };
const sameObject = identity(object); // sameObject is { key: 'value' }

defined :: x -> boolean

The defined function takes a single argument and returns true if the argument is not undefined. It is useful for checking the presence of a value before performing operations on it, to avoid errors caused by undefined values.

// Checking if a value is defined
const result1 = defined(42); // result1 is true
const result2 = defined(undefined); // result2 is false
const result3 = defined(null); // result3 is true
const result4 = defined(''); // result4 is true
const result5 = defined([]); // result5 is true
const result6 = defined({}); // result6 is true

declared :: this -> x -> boolean

This function evaluates the this context where it is called. If the this context is the declared function itself, it defaults to using the global context (globalThis). Otherwise, it checks if the specified property (x) is defined on the provided context.

// Example with `globalThis`
console.log(declared.call(globalThis, 'foo')); // Assuming 'foo' is not a global property, this will return false.
 
// Example with a specific object
const obj = { foo: 'bar' };
const declaredInObj = declared.bind(obj);
console.log(declaredInObj('foo'));  // true
console.log(declaredInObj('baz'));  // false
 
// Example with `globalThis` as the `this` context (when called directly)
// If `foo` is a global property, this will return true.
console.log(declared('foo'));  // This is the same as calling declared.call(globalThis, 'foo')
 
// Example with `globalThis` as the `this` context (using `apply`)
console.log(declared.apply(globalThis, ['foo']));  // As above, this will return false if 'foo' is not a global property.

is :: t -> x -> boolean

Determines if a value matches a specified type, is an instance of a constructor, strictly equal to any other value or holds the same reference to an object. The function is curried, allowing partial application of arguments. It checks if the value matches the type or is an instance of the constructor function or class. The supported types include BigInt, Boolean, Function, Number, Object, Symbol, String, Functor (a.k.a has a 'map' method), Array, undefined, and null. For other types, it uses the instanceof operator.

// Using with primitive types
is(String, 'hello'); // true
is(Number, 123); // true
is(Boolean, false); // true
is('foo', 'foo'); // true
is(42, 42); // true
 
// Using with constructors
class MyClass {}
const myInstance = new MyClass();
is(MyClass, myInstance); // true
 
// Using with curried syntax
const isString = is(String);
isString('hello'); // true
isString(123); // false

compose :: (...fn) -> fn

This function is a serious one, it takes a series of functions and returns a new function that, when called, applies the functions from right to left in sequence to a given argument.

Each function consumes the return value of the function that came before it. The result of the composition is a new function that takes an initial value and applies the composed functions to it in sequence.

// Define functions with different types
const increment = (x: number): number => x + 1;
const stringify = (x: number): string => `Value: ${x}`;
const toUpperCase = (x: string): string => x.toUpperCase();
 
// Compose these functions in right-to-left order
const composed = compose(toUpperCase, stringify, increment);
 
// Use the composed function
const finalResult = composed(5); // Result is 'VALUE: 6'

curry :: ((a, b, ...) -> c) -> a -> b -> ... -> c

A curried function allows you to call a function with a partial set of arguments, and it will return a new function that takes the remaining arguments. This process continues until all arguments have been provided, at which point the original function is invoked with all the arguments.

// A function that adds three numbers
const add = (a: number, b: number, c: number): number => a + b + c;
 
// Curry the add function
const curriedAdd = curry(add);
 
// Call the curried function with partial arguments
const add5 = curriedAdd(5);
const add5And10 = add5(10);
const result = add5And10(15); // result is 30

map :: fn -> x -> Functor | Promise

Applies a function to each element in a functor, returning a new functor with the results. This function is curried, meaning it can be partially applied. It takes a function fn that transforms elements of the functor x, and returns a new functor with the transformed elements.

// Example with an array as a functor
const numbers = [1, 2, 3];
const increment = (x: number) => x + 1;
const incrementedNumbers = map(increment, numbers);
console.log(incrementedNumbers); // Output: [2, 3, 4]
 
// Example with a Promise as a functor
const promise = Promise.resolve(5);
const double = (x: number) => x * 2;
const doubledPromise = map(double, promise);
doubledPromise.then(console.log); // Output: 10

match :: Matchers -> x -> U

Creates a matcher function that executes a handler based on the type or value of the input.

const m = match({
  'foo': 'is a foo', // Handles the case where x === 'foo'
  Array: (x: number[]) => x[0], // Handles the case where x is an array
  String: (x: string) => x + 'String', // Handles the case where x is a string
  Number: (x: number) => x + 'Number', // Handles the case where x is a number
  _: 'default' // Default case for unmatched types
});
 
console.log(m('foo')); // Output: 'is a foo'
console.log(m([1, 2, 3])); // Output: 1 (first element of the array)
console.log(m(42)); // Output: '42Number'
console.log(m({})); // Output: 'default' (matches default case)

tryCatch :: fn -> fn -> U

A higher-order function that wraps a function with try-catch block for error handling.

// A function that may throw an error
const divide = (a, b) => {
  if (b === 0) {
    throw new Error('Division by zero');
  }
  return a / b;
};
 
// Error handler function
const handleError = (error, a, b) => {
  console.error(`Error dividing ${a} by ${b}: ${error.message}`);
  return 0; // Return a default value or handle the error appropriately
};
 
// Create a safe divide function with tryCatch
const safeDivide = tryCatch(divide, handleError);
 
// Examples
console.log(safeDivide(10, 2)); // 5
console.log(safeDivide(10, 0)); // Error dividing 10 by 0: Division by zero, 0

memoize :: fn -> T -> T

Memoizes a function to cache its results based on the provided arguments. This higher-order function returns a new function that remembers the results of previous calls with the same arguments, avoiding redundant computations and improving performance for functions with expensive calculations.

const add = (a: number, b: number) => a + b;
const memoizedAdd = memoize(add);
 
console.log(memoizedAdd(1, 2)); // 3
console.log(memoizedAdd(1, 2)); // Cached result: 3

There are many other structures to mention. I will gradually update this post.