Skip to main content

Command Palette

Search for a command to run...

5 lessons I've learned from functional programming as a TypeScript developer

Updated
9 min read
5 lessons I've learned from functional programming as a TypeScript developer

UPDATE 2025:
Updated the text to mention immutable methods introduced to Javascript.

Over my 10 years of working as a software engineer, I've tried many different programming languages. In my professional career, I started with PHP, later switched to JavaScript and now I'm mostly working with TypeScript.

But, like many other developers, I spend my free time learning new and interesting ideas, that may improve my programming abilities, helping me to find better solutions to the problems I encounter in my everyday job.

I believe my approach to writing code has changed since I started learning a new programming paradigm - functional programming.

This programming style focuses on building simpler programs, that are easier to reason about. Functional programming also sets a few boundaries (like immutability or purity) that lead to fewer bugs introduced in the applications.

But isn't functional programming a very mathematical and convoluted paradigm? Well, it depends on who you ask.

While it's true that it has its roots in advanced maths, like Category Theory or Lambda calculus, it doesn't mean that you need to know it to reap the benefits it offers.

By simply making a few changes in your programming style, you can write better code! Even if you're a die-hard fan of OOP.

So, what are those benefits offered by functional programming, and how to apply them in TypeScript?

1. Immutability leads to fewer bugs

Functional programming favours immutable code. As our codebase grows, it's increasingly difficult to ensure that mutable code is safe. By treating all variables and objects as immutable, we eliminate the possibility that some other part of the code will overwrite the value unexpectedly.

Besides safety, it offers one more advantage. You don't need to think about the context of the variable being changed. You can focus on here and now, knowing you're working with values that don't affect anything else.

Languages like Rust, Elm, Haskell and Scala offer immutability by default.

Sadly, TypeScript (and JavaScript) historically struggled with immutability. Consider:

const names = ['Mark', 'Chris', 'Adam'];
const sorted = names.sort();

console.log(sorted); // ['Adam', 'Chris', 'Mark'] - ✅
console.log(names); // ['Adam', 'Chris', 'Mark'] - 💥

As we see, even though the names is declared as a const - it’s being mutated by the sort function. Thankfully, modern JavaScript introduces immutable methods:

const names = ['Mark', 'Chris', 'Adam'];
const sorted = names.toSorted();

console.log(sorted); // ['Adam', 'Chris', 'Mark'] - ✅
console.log(names); // ['Mark', 'Chris', 'Adam'] - ✅

Nowadays, working with immutable data in TypeScript has become significantly easier, especially with modern language features and libraries. Alongside native JavaScript improvements, libraries like Immutable.js and Immer.js facilitate working with immutable structures in a more ergonomic way. Immer.js, for instance, allows writing seemingly mutable code that internally maintains immutability, significantly simplifying state management.

Additionally, TypeScript's advanced type system, with features like Readonly and ReadonlyArray, helps enforce immutability at the type level, ensuring greater reliability and safety in applications.

What can we learn from functional programming?

  • Always prefer built-in immutable methods provided by modern JavaScript (e.g., .toSorted(), .toReversed())

  • Libraries like Immer.js and Immutable.js significantly simplify working with immutable data.

  • Leverage TypeScript’s type system (Readonly, ReadonlyArray) to enforce immutability explicitly.

  • Immutable data structures make code easier to reason about and help avoid subtle, hard-to-debug bugs.

2. Declarative code is easier to understand

Functional programming languages — and tools inspired by them — favour a declarative style over an imperative one. A declarative approach lets you describe what you want your code to achieve rather than how to achieve it.

Because it operates at a higher level of abstraction, declarative code is typically easier to read and reason about. You express the desired outcome and rely on the language runtime or framework to handle the mechanics for you.

In contrast, imperative programming forces you to spell out every step in the process.

Example – sorting numbers

const numbers = [3, 5, 1, 4, 2];
const sortedNumbers = numbers.toSorted((a, b) => a - b);

console.log(sortedNumbers); // [1, 2, 3, 4, 5]

toSorted() returns a new array and leaves numbers unchanged. Passing the comparator (a, b) => a - b avoids the default lexical string comparison.

We’re so accustomed to calling toSorted/sort that we rarely stop to think how the same task would look when written imperatively.

function selectionSort(arr: number[]): number[] {
  const result = [...arr];        // copy to avoid mutating the caller
  for (let i = 0; i < result.length - 1; i++) {
    let minIdx = i;
    for (let j = i + 1; j < result.length; j++) {
      if (result[j] < result[minIdx]) minIdx = j;
    }
    [result[i], result[minIdx]] = [result[minIdx], result[i]];
  }
  return result;
}

console.log(selectionSort([3, 5, 1, 4, 2])); // [1, 2, 3, 4, 5]

Not only is the imperative version far more verbose, it mutates state several times — increasing the chances of subtle bugs.

Abstractions amplify productivity

Declarative style thrives on re‑using well‑designed abstractions such as React components or Terraform resources. Leveraging those abstractions limits the amount of code you must write and maintain, freeing you to focus on domain logic rather than plumbing.


Take‑away: Declarative code emphasises intent. By focusing on outcomes instead of step‑by‑step mechanics, it yields programs that are more concise, safer, and easier to understand.

3. Write pure functions and group side effects together

Functional programming encourages writing pure functions. Some languages, like Haskell or Elm, will not allow you to perform side effects unless you explicitly state that it is a side effect.

There are several reasons why it is important to write pure functions.

  1. Pure functions are easier to understand and debug.
    They have well-defined inputs and outputs and do not introduce external dependencies or mutable state into the program.

  2. Pure functions are easier to test and maintain.
    They are isolated from the external environment and do not produce any side effects, which makes them easier to test in isolation and prevents them from being affected by changes in the external environment.

  3. Pure functions are more modular and reusable.
    They are self-contained and do not rely on external state or mutable data, which makes them easy to extract and reuse in different contexts.

Let's see some examples:

// Pure function
function square(x: number): number {
    return x * x;
}

// Impure function
function logSquare(x: number): void {
    console.log(square(x));
}

// Another impure function
function generateId(): number {
    return Math.floor(Math.random() * 1000000);
}

Of course, we cannot write a useful application with only pure functions - in most cases, we have to interact with the outer world.

Side effects, like logging into the console, fetching data from the server, calling a database or manipulating DOM are crucial to building something that users will be actually able to use.

What can we do? In functional programming, side effects can be handled by a design pattern called "monads".

It's one of the scarier terms in functional programming, but don't worry.

In typescript, we can use a similar approach, without having to learn what a monad is - we can just group side effects in intentionally impure functions.

By separating the pure and impure functions we can avoid mixing side effects with the pure logic of the program.

What can we learn from functional programming?

Overall, separating side effects from the rest of a program and writing pure functions can improve its understandability, testability, maintainability, and reusability, and make it easier to reason about and work with.

4. Parse, don't validate

Parsing and validating data are two separate but related concepts.

Parsing refers to the process of converting raw data from its external representation (such as a JSON string or a CSV file) into a more structured and usable format (such as a JavaScript object or an array of objects).

Validating refers to the process of checking the data against a set of constraints or rules (such as a schema or a type) to ensure that it is correct and valid.

Let's consider the following example:

type User = {
  id: string
  name: string
  age: number
  address: {
    city: string
    street: string
    zipCode: string
  }
}

const fetchUserData = async (id: string): Promise<User> => {
  const response = await fetch(`/api/user/${id}`)
  const data: User = await response.json()

  // how do we know if we got correct User type?
  return data 
}

One solution is to validate the object that we got:

type User = {
  id: string;
  name: string;
  age: number;
  address: {
    city: string;
    street: string;
    zipCode: string;
  };
};

const fetchUserData = async (id: string): Promise<User> => {
  const response = await fetch(`/api/user/${id}`);
  const data = await response.json();

  if (isUser(data)) {
    return data;
  }

  return null;
};

const isUser = (data: any): data is User => {
  if (!data.id || typeof data.id !== 'string') return false;
  if (!data.name || typeof data.name !== 'string') return false;
  if (!data.age || typeof data.age !== 'string') return false;
  if (!data.address || typeof data.address !== 'string') return false;
  if (!data.address.city || typeof data.address.city !== 'string') return false;
  if (!data.address.street || typeof data.address.street !== 'string')
    return false;
  if (!data.address.zipCode || typeof data.address.zipCode !== 'string')
    return false;

  return true;
};

It's not the most elegant solution, but it gets the job done.

However, it has some more flaws. If we change the type, we will have to update our validation function manually. And it's a very common source of bugs.

Moreover, we are violating another rule that we've mentioned in this article - our code is imperative - we are specifying each step of the validation.

Let's see if we can do better. We will use a library called zod.

import { z } from "zod";

const User = z.object({
  id: z.string(),
  name: z.string(),
  age: z.number(),
  address: z.object({
    city: z.string(),
    street: z.string(),
    zipCode: z.string(),
  })
});

type User = z.infer<typeof User>

const fetchUserData = async (id: string): Promise<User> => {
  const response = await fetch(`/api/user/${id}`);
  const data = await response.json();

  try {
    return User.parse(data)
  } catch (e) {
    return null
  }
};

Alright, this code is much cleaner than the example above.

Moreover, we get type safety for free - if the object doesn't match the schema - the parse method will throw an error.

It's a very common approach in functional programming. A very good example of that would be JSON Decoders in Elm.

What can we learn from functional programming?

When validating the data, avoid the imperative style. It's much better to describe the schema (or the rules) in a declarative way, and just parse it.

In Typescript, it can be easily done with zod library.

5. Learn to think differently

This one is less technical. Even though I'm still learning functional programming (still have no idea what category theory is all about), I can see how it improved the quality of my code and helped me to choose the right tool for a specific problem.

In the beginning, functional programming can seem scary, with all the mathematical concepts and a completely new approach to solving problems.

But when you put an effort into understanding why this new approach is reasonable, you can broaden your horizons.

In my everyday work, I still write normal Typescript. But I'm able to write code that is easier to understand, easier to test and less prone to bugs.

I'm aware that there is so much more to learn from this paradigm.

That's why I'm doing most of my after-work coding in functional languages or implementing functional concepts in TypeScript.

If I were to recommend you a functional language to learn these would be my suggestions:

  • Elm - if you are a frontend developer

  • Elixir - if you come from Ruby, Python or Node.js

  • Scala - if you already know Java


That's it for today! I really hope you liked this post. If so, remember to subscribe, so that you won't miss the next article on this blog :)