MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Functional Programming in TypeScript

Apply FP in TypeScript: pipe, compose, Option, Either, and immutability patterns.

TypeScriptFunctional ProgrammingArchitecture

By MinhVo

Introduction

Functional programming represents one of the most powerful paradigms available to modern software developers, and TypeScript provides an exceptional platform for applying FP principles in real-world applications. Unlike purely functional languages like Haskell or Elm, TypeScript offers a pragmatic hybrid approach that lets developers adopt functional patterns incrementally without abandoning existing object-oriented codebases.

The rise of functional programming in mainstream development is no accident. As applications grow in complexity, the need for predictable, testable, and maintainable code becomes paramount. FP addresses these concerns through immutability, pure functions, and composition—principles that eliminate entire categories of bugs related to shared mutable state and side effects. Libraries like fp-ts, Effect, and Ramda have brought mature functional tooling to the TypeScript ecosystem, making it easier than ever to write robust, type-safe functional code.

In this comprehensive guide, we will explore how to apply functional programming principles in TypeScript projects. We will cover fundamental concepts like pure functions and immutability, advanced patterns like Option and Either types, and practical techniques like pipe and compose for building elegant data transformation pipelines. By the end, you will have a solid foundation for writing cleaner, more reliable TypeScript code using functional techniques.

Functional programming concepts illustrated with code flow diagrams

Understanding Functional Programming: Core Concepts

Functional programming is a programming paradigm that treats computation as the evaluation of mathematical functions and avoids changing state and mutable data. The roots of FP trace back to lambda calculus, developed by Alonzo Church in the 1930s, which provided a formal system for expressing computation based on function abstraction and application.

The core principles of FP include pure functions, immutability, first-class functions, higher-order functions, and function composition. A pure function always returns the same output for the same input and produces no side effects. This property, known as referential transparency, makes code dramatically easier to reason about, test, and parallelize. When a function has no hidden dependencies or mutations, you can understand its behavior by examining its signature alone.

Immutability ensures that data structures cannot be modified after creation. Instead of mutating existing objects, functional programs create new instances with the desired changes. This eliminates race conditions in concurrent code and makes state changes explicit and traceable. TypeScript supports immutability through the readonly modifier, Readonly<T> mapped types, and libraries like Immer that provide structural sharing for efficient immutable updates.

First-class functions mean that functions can be assigned to variables, passed as arguments, and returned from other functions. This enables powerful abstractions like higher-order functions—functions that operate on other functions. Map, filter, and reduce are classic examples of higher-order functions that transform data declaratively rather than imperatively.

TypeScript code editor showing functional patterns

Architecture and Design Patterns

Functional programming in TypeScript naturally leads to a layered architecture where pure business logic is separated from side-effectful operations like I/O, HTTP calls, and database access. This separation creates what functional programmers call the "functional core, imperative shell" pattern—a design where the core of your application is entirely pure and testable, while side effects are pushed to the boundaries.

The Functional Core

The functional core contains all business logic expressed as pure functions. These functions take data as input and return transformed data as output, with no side effects. This makes them trivially testable—you never need mocks, stubs, or complex setup for pure functions.

// Pure function: no side effects, deterministic output
interface Order {
  items: Array<{ price: number; quantity: number }>;
  discount: number;
}
 
const calculateTotal = (order: Order): number => {
  const subtotal = order.items.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  );
  return subtotal * (1 - order.discount);
};
 
// Pure function: validation logic
const validateOrder = (order: Order): string[] => {
  const errors: string[] = [];
  if (order.items.length === 0) errors.push("Order must have items");
  if (order.discount < 0 || order.discount > 1) errors.push("Invalid discount");
  order.items.forEach((item, i) => {
    if (item.price <= 0) errors.push(`Item ${i} has invalid price`);
    if (item.quantity <= 0) errors.push(`Item ${i} has invalid quantity`);
  });
  return errors;
};

The Imperative Shell

The imperative shell handles all side effects: reading from databases, writing to files, making HTTP calls, and interacting with the outside world. It calls into the functional core for business logic decisions and is responsible for orchestrating the flow of data.

Algebraic Data Types

TypeScript's discriminated unions provide excellent support for algebraic data types (ADTs), which are foundational to FP. ADTs let you model domain states precisely, making illegal states unrepresentable at the type level.

// Discriminated union: making illegal states unrepresentable
type Result<T, E> =
  | { readonly _tag: "Success"; readonly value: T }
  | { readonly _tag: "Failure"; readonly error: E };
 
const success = <T>(value: T): Result<T, never> => ({
  _tag: "Success",
  value,
});
 
const failure = <E>(error: E): Result<never, E> => ({
  _tag: "Failure",
  error,
});
 
// Pattern matching using exhaustive switch
const fold = <T, E, R>(
  result: Result<T, E>,
  onSuccess: (value: T) => R,
  onFailure: (error: E) => R
): R => {
  switch (result._tag) {
    case "Success":
      return onSuccess(result.value);
    case "Failure":
      return onFailure(result.error);
  }
};

Step-by-Step Implementation

Let us build a comprehensive functional toolkit for TypeScript that covers the most essential FP patterns. We will implement pipe, compose, Option, Either, and validation utilities from scratch to understand how they work under the hood.

Pipe and Compose

Pipe and compose are the fundamental building blocks of functional data transformation. Pipe passes a value through a series of functions from left to right, while compose does the same from right to left. Pipe is generally preferred for readability since it matches the natural reading order.

// Pipe: left-to-right function composition
const pipe = <A>(value: A, ...fns: Array<(arg: any) => any>): any =>
  fns.reduce((acc, fn) => fn(acc), value);
 
// Usage: transform data through a pipeline
const processUser = (user: { name: string; age: number }) =>
  pipe(
    user,
    (u) => ({ ...u, name: u.name.trim().toLowerCase() }),
    (u) => ({ ...u, ageGroup: u.age >= 18 ? "adult" : "minor" }),
    (u) => `${u.name} (${u.ageGroup})`
  );
 
// Compose: right-to-left function composition
const compose = <A, B, C>(
  f: (b: B) => C,
  g: (a: A) => B
): ((a: A) => C) => (a) => f(g(a));
 
// Generic compose for multiple functions
const composeAll = <T>(...fns: Array<(arg: T) => T>) =>
  (value: T): T =>
    fns.reduceRight((acc, fn) => fn(acc), value);

The Option Type

The Option type (also called Maybe) represents a value that may or may not exist. Instead of using null or undefined, which can cause runtime errors, Option forces you to handle the absence of a value explicitly at the type level.

type Option<A> =
  | { readonly _tag: "Some"; readonly value: A }
  | { readonly _tag: "None" };
 
const some = <A>(value: A): Option<A> => ({ _tag: "Some", value });
const none: Option<never> = { _tag: "None" };
 
const isSome = <A>(opt: Option<A>): opt is { _tag: "Some"; value: A } =>
  opt._tag === "Some";
 
const map = <A, B>(opt: Option<A>, fn: (a: A) => B): Option<B> =>
  isSome(opt) ? some(fn(opt.value)) : none;
 
const flatMap = <A, B>(opt: Option<A>, fn: (a: A) => Option<B>): Option<B> =>
  isSome(opt) ? fn(opt.value) : none;
 
const getOrElse = <A>(opt: Option<A>, defaultValue: A): A =>
  isSome(opt) ? opt.value : defaultValue;
 
// Practical usage: safely accessing nested properties
interface User {
  address?: {
    city?: string;
  };
}
 
const getCity = (user: User): Option<string> =>
  user.address?.city ? some(user.address.city) : none;
 
const cityLength = pipe(
  getCity({ address: { city: "Hanoi" } }),
  (opt) => map(opt, (city) => city.length),
  (opt) => getOrElse(opt, 0)
);
// Result: 5

The Either Type

Either represents a computation that can fail with one of two types: a Left (typically representing an error) or a Right (representing a successful value). Unlike throwing exceptions, Either makes error handling explicit and composable.

type Either<E, A> =
  | { readonly _tag: "Left"; readonly left: E }
  | { readonly _tag: "Right"; readonly right: A };
 
const left = <E>(error: E): Either<E, never> => ({ _tag: "Left", left: error });
const right = <A>(value: A): Either<never, A> => ({ _tag: "Right", right: value });
 
const mapEither = <E, A, B>(
  either: Either<E, A>,
  fn: (a: A) => B
): Either<E, B> =>
  either._tag === "Right" ? right(fn(either.right)) : either;
 
const flatMapEither = <E, A, B>(
  either: Either<E, A>,
  fn: (a: A) => Either<E, B>
): Either<E, B> =>
  either._tag === "Right" ? fn(either.right) : either;
 
// Chaining multiple operations that can fail
const parseNumber = (input: string): Either<string, number> => {
  const n = Number(input);
  return isNaN(n) ? left(`"${input}" is not a number`) : right(n);
};
 
const positiveOnly = (n: number): Either<string, number> =>
  n > 0 ? right(n) : left(`${n} is not positive`);
 
const processInput = (input: string): Either<string, number> =>
  pipe(
    parseNumber(input),
    (e) => flatMapEither(e, positiveOnly),
    (e) => mapEither(e, (n) => n * 2)
  );

Functional programming patterns in modern development

Real-World Use Cases

Use Case 1: API Response Handling

When building applications that consume APIs, you need to handle various failure modes: network errors, invalid responses, missing data, and authentication failures. The Either type provides an elegant way to model these scenarios and compose error-prone operations.

type ApiError =
  | { type: "NetworkError"; message: string }
  | { type: "ParseError"; raw: unknown }
  | { type: "AuthError"; statusCode: number };
 
const fetchUser = async (id: string): Promise<Either<ApiError, User>> => {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (response.status === 401)
      return left({ type: "AuthError", statusCode: 401 });
    if (!response.ok)
      return left({ type: "NetworkError", message: response.statusText });
    const data = await response.json();
    return right(data as User);
  } catch (error) {
    return left({ type: "NetworkError", message: String(error) });
  }
};

Use Case 2: Form Validation

Form validation is a perfect use case for functional programming. Each validation rule is a pure function that takes a value and returns either a valid result or an error message. These rules compose naturally using functional combinators.

type Validator<T> = (value: T) => Option<string>;
 
const required: Validator<string> = (value) =>
  value.trim().length > 0 ? none : some("Field is required");
 
const minLength = (min: number): Validator<string> => (value) =>
  value.length >= min ? none : some(`Must be at least ${min} characters`);
 
const maxLength = (max: number): Validator<string> => (value) =>
  value.length <= max ? none : some(`Must be at most ${max} characters`);
 
const validate = <T>(value: T, ...validators: Validator<T>[]): string[] =>
  validators
    .map((v) => v(value))
    .filter(isSome)
    .map((opt) => opt.value);
 
const errors = validate("hello", required, minLength(3), maxLength(50));
// errors: [] (empty - valid!)

Use Case 3: Data Transformation Pipelines

ETL (Extract, Transform, Load) processes are natural candidates for functional pipelines. Each step in the pipeline is a pure function, and the pipe operator chains them together into a readable, maintainable data transformation flow.

interface RawTransaction {
  amount: string;
  currency: string;
  date: string;
  type: string;
}
 
interface ProcessedTransaction {
  amountUSD: number;
  date: Date;
  category: string;
}
 
const parseAmount = (raw: RawTransaction): number => parseFloat(raw.amount);
 
const convertToUSD = (amount: number, currency: string): number => {
  const rates: Record<string, number> = { EUR: 1.1, GBP: 1.27, JPY: 0.0067 };
  return amount * (rates[currency] ?? 1);
};
 
const categorize = (type: string): string => {
  const map: Record<string, string> = {
    purchase: "Expense",
    refund: "Income",
    transfer: "Transfer",
  };
  return map[type] ?? "Other";
};
 
const processTransaction = (raw: RawTransaction): ProcessedTransaction =>
  pipe(
    raw,
    (r) => ({ amountUSD: convertToUSD(parseAmount(r), r.currency), r }),
    ({ amountUSD, r }) => ({
      amountUSD,
      date: new Date(r.date),
      category: categorize(r.type),
    })
  );

Use Case 4: Configuration Management

Functional programming excels at configuration management where you need to merge defaults, validate settings, and transform configuration objects. Using Option and Either types ensures that missing or invalid configuration values are handled gracefully without null checks scattered throughout the codebase.

Best Practices for Production

  1. Start with pure functions: Begin by identifying pure business logic in your codebase and extract it into standalone functions. These are immediately testable and reusable.
  2. Use readonly everywhere: Apply readonly to all interfaces and type declarations. Use ReadonlyArray<T> instead of Array<T> to prevent accidental mutations.
  3. Model domain with ADTs: Use discriminated unions to represent domain states. This catches invalid state transitions at compile time rather than runtime.
  4. Prefer pipe over nested calls: Nested function calls read inside-out and become unreadable quickly. Pipe reads left-to-right and clearly shows data flow.
  5. Handle errors with Either: Replace try-catch blocks with Either returns for predictable error handling. Reserve exceptions for truly exceptional situations.
  6. Use fp-ts for production: While building from scratch is educational, fp-ts provides battle-tested implementations of Option, Either, Task, and other FP abstractions with excellent TypeScript integration.
  7. Keep side effects at boundaries: Structure applications so pure logic is in the center and side effects (I/O, DOM, network) are at the edges.
  8. Document with types: Leverage TypeScript's type system to make function signatures self-documenting. The types should communicate intent without needing comments.

Common Pitfalls and Solutions

PitfallImpactSolution
Mutating array methods (push, splice)Unpredictable state changesUse spread operator or array methods that return new arrays (map, filter, concat)
Deep nesting of callbacksCode becomes unreadableUse pipe or compose to flatten the transformation chain
Over-abstraction with FP utilitiesTeam members unfamiliar with FP cannot maintain codeIntroduce patterns gradually and document well
Ignoring TypeScript strict modeType safety benefits are reduced significantlyAlways enable strict mode in tsconfig.json
Creating new objects for every operationPerformance overhead in hot pathsUse structural sharing libraries like Immer for complex state
Mixing FP and OOP randomlyInconsistent codebase that confuses developersEstablish team conventions for when to use each pattern

Performance Optimization

Functional programming's emphasis on immutability can introduce performance overhead from creating new objects. However, several techniques mitigate this concern effectively.

// Structural sharing with Immer for efficient immutable updates
import { produce } from "immer";
 
interface AppState {
  users: Map<string, User>;
  settings: Settings;
  notifications: Notification[];
}
 
const updateUser = produce(
  (draft: AppState, id: string, updates: Partial<User>) => {
    const user = draft.users.get(id);
    if (user) Object.assign(user, updates);
  }
);
 
// Memoization for expensive pure computations
const memoize = <Args extends unknown[], R>(
  fn: (...args: Args) => R,
  keyFn: (...args: Args) => string = (...args) => JSON.stringify(args)
): ((...args: Args) => R) => {
  const cache = new Map<string, R>();
  return (...args: Args): R => {
    const key = keyFn(...args);
    if (cache.has(key)) return cache.get(key)!;
    const result = fn(...args);
    cache.set(key, result);
    return result;
  };
};

Comparison with Alternatives

FeatureFP in TypeScriptOOP in TypeScriptFP in Haskell
Type SafetyExcellent with strict modeGood with interfacesBest (algebraic types)
Learning CurveModerateLowSteep
ImmutabilityManual enforcementOptionalEnforced by default
Side Effect ControlManual disciplineManual disciplineMonadic (IO type)
EcosystemLarge (npm)Large (npm)Smaller but mature
Production ReadinessExcellentExcellentExcellent
Team AdoptionIncrementalNatural for mostRequires retraining
Null SafetyOption/Either typesOptional chainingMaybe type built-in

Advanced Patterns

Higher-Kinded Types with fp-ts

TypeScript lacks native higher-kinded types (HKTs), but fp-ts emulates them using module augmentation and type classes. This enables writing generic code that works with any container type.

import * as O from "fp-ts/Option";
import * as E from "fp-ts/Either";
import { pipe } from "fp-ts/function";
 
const double = (n: number): number => n * 2;
 
const resultOption = pipe(O.some(5), O.map(double)); // O.some(10)
const resultEither = pipe(E.right(5), E.map(double)); // E.right(10)
 
// TaskEither for async error handling
import * as TE from "fp-ts/TaskEither";
 
const fetchUserSafely = (id: string): TE.TaskEither<Error, User> =>
  TE.tryCatch(
    () => fetch(`/api/users/${id}`).then((r) => r.json()),
    (error) => new Error(`Failed to fetch user: ${error}`)
  );
 
const program = pipe(
  fetchUserSafely("123"),
  TE.chain((user) => fetchUserSafely(user.managerId)),
  TE.map((manager) => ({
    user: manager.name,
    department: manager.department,
  }))
);

Reader Pattern for Dependency Injection

The Reader monad provides a functional approach to dependency injection without frameworks. Dependencies are passed implicitly through a computation chain, keeping function signatures clean while maintaining testability.

Testing Strategies

Functional code is inherently easier to test because pure functions require no mocks, no setup, and no teardown. You simply call the function with inputs and verify the outputs.

import { describe, it, expect } from "vitest";
 
describe("calculateTotal", () => {
  it("applies discount correctly", () => {
    const order: Order = {
      items: [{ price: 100, quantity: 2 }],
      discount: 0.1,
    };
    expect(calculateTotal(order)).toBe(180);
  });
 
  it("handles empty order", () => {
    const order: Order = { items: [], discount: 0 };
    expect(calculateTotal(order)).toBe(0);
  });
});
 
// Property-based testing with fast-check
import fc from "fast-check";
 
describe("calculateTotal properties", () => {
  it("total is never negative with valid discount", () => {
    fc.assert(
      fc.property(
        fc.array(
          fc.record({
            price: fc.float({ min: 0.01 }),
            quantity: fc.nat({ min: 1 }),
          })
        ),
        fc.float({ min: 0, max: 1 }),
        (items, discount) => calculateTotal({ items, discount }) >= 0
      )
    );
  });
});

Future Outlook

The future of functional programming in TypeScript looks incredibly promising. The TypeScript team continues to improve the type system with features like const type parameters, template literal types, and conditional types that make FP patterns more expressive. The Effect ecosystem is gaining significant traction, offering a comprehensive functional effect system that rivals ZIO in Scala. As more teams adopt TypeScript for large-scale applications, the need for the predictability and maintainability that FP provides will only grow.

Conclusion

Functional programming in TypeScript is not about dogmatic adherence to academic principles—it is about writing better, more predictable software. By embracing pure functions, you eliminate entire categories of bugs. By using Option and Either, you make error handling explicit and composable. By leveraging pipe and compose, you create readable data transformation pipelines that are easy to understand and modify.

The key takeaways from this guide are: start small by extracting pure functions from existing code, use the Option type to eliminate null reference errors, adopt Either for composable error handling, and build transformation pipelines with pipe. As you grow more comfortable with these patterns, explore fp-ts or Effect for production-ready functional abstractions.

For further learning, explore the fp-ts documentation, read "Professor Frisby's Mostly Adequate Guide to Functional Programming," and practice by refactoring existing imperative code into functional style. The investment in learning FP will pay dividends in code quality and developer productivity for years to come.