Functional Programming in TypeScript: Advanced Level
Functional programming (FP) is a paradigm that has been actively conquering the world of JavaScript and TypeScript in recent years. For developers already familiar with the basics (pure functions, data immutability), a new layer of possibilities opens up: working with monads, functors, applicatives, and complex compositions. In this article, we will break down advanced FP concepts in TypeScript with practical examples.
1. Immutability and Structural Sharing
Immutability is a cornerstone of FP. In TypeScript, we can guarantee immutability at the type level using readonly and ReadonlyArray. However, for efficient work with large data structures (e.g., Redux store), structural sharing is used — libraries like Immer or Immutable.js.
Consider an example using readonly and a function to update a nested object without mutation:
type User = { readonly id: number; readonly name: string; readonly address: { readonly city: string; readonly zip: string; };};
const updateUserCity = (user: User, newCity: string): User => ({ ...user, address: { ...user.address, city: newCity, },});
const user: User = { id: 1, name: 'Alice', address: { city: 'NYC', zip: '10001' } };const updatedUser = updateUserCity(user, 'LA');console.log(user.address.city); // NYC (original unchanged)console.log(updatedUser.address.city); // LANote: we create a new object but reuse unchanged parts (e.g., id and name). This is manual structural sharing. For deep nesting, it is better to use libraries.
2. Functors and Their Implementation
A functor is a container that implements the map method, allowing a function to be applied to the container's contents without breaking its structure. In TypeScript, a typical functor is Array. But we can create our own functor, for example, Maybe, to avoid errors with null and undefined.
Let's implement a simple Maybe functor:
class Maybe<T> { private constructor(private value: T | null) {}
static of<U>(val: U | null | undefined): Maybe<U> { return new Maybe(val ?? null); }
map<U>(fn: (val: T) => U): Maybe<U> { if (this.value === null) { return Maybe.of<U>(null); } return Maybe.of(fn(this.value)); }
getOrElse(defaultValue: T): T { return this.value !== null ? this.value : defaultValue; }}
// Usageconst result = Maybe.of('hello') .map(s => s.toUpperCase()) .map(s => s + ' world') .getOrElse('fallback');console.log(result); // "HELLO WORLD"
const empty = Maybe.of<string>(null) .map(s => s.toUpperCase()) .getOrElse('default');console.log(empty); // "default"The Maybe functor allows safe chaining of function calls without worrying about null. Each map returns a new Maybe, preserving the context.
3. Monads: Chains of Operations with Side Effects
A monad is a functor that also implements the flatMap method (often called bind or chain). It allows "unwrapping" nested containers. A classic example of a monad is Promise or Array. Let's look at the Either monad for error handling without exceptions.
type Either<L, R> = Left<L> | Right<R>;
class Left<L> { readonly value: L; constructor(value: L) { this.value = value; } isLeft(): this is Left<L> { return true; } isRight(): this is Right<ne