5 lessons I've learned from functional programming as a TypeScript developer
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 more and more 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 in an unexpected way.
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 that you're working with values that don't affect anything else.
Languages like Rust, Elm, Haskel and Scala offer immutability by default.
Sadly, TypeScript (and JavaScript) is not great when it comes to immutability.
Even though we've got a const
keyword (which you should use), we can't just assume that a value is immutable because it's a const
.
This simple snippet shows why:
const names = ['Mark', 'Chris', 'Adam'];
const sorted = names.sort();
console.log(sorted); // ['Adam', 'Chris', 'Mark'] - ✅
console.log(names); // ['Adam', 'Chris', 'Mark'] - 💥
This code produces unexpected results. We would assume that sorted
const would contain sorted names, but we didn't expect it to mutate the original array and sort it too.
Well, someone could argue that it's not a bug, it's a feature. Knowing how JavaScript works should make it obvious that the .sort()
method will mutate the original array.
But it's super confusing for new developers and demonstrates one of the biggest flaws of JavaScript/TypeScript. It's not reliable.
Even as experienced developers, we sometimes waste hours debugging an issue, just to find out that we were accidentally mutating some value.
How to solve that issue? Make sure that we always create a new copy of the original array:
const immutableSort = <T>(arr: Array<T>): Array<T> => [...arr].sort();
const names = ['Mark', 'Chris', 'Adam'];
const sorted = immutableSort(names);
console.log(sorted); // ['Adam', 'Chris', 'Mark'] - ✅
console.log(names); // ['Mark', 'Chris', 'Adam'] - ✅
It would be nice not having to do this additional step. But for now, we don't have another option.
It's worth noting that even methods that return the new value, like array.filter()
are not completely immutable. The filter method returns a shallow copy of the array, so there is still a danger of mutating the records in an unexpected way.
What can we learn from functional programming?
Even though Typescript doesn't give us the tools to write immutable code by default, we can make sure that we are using its built-in functions properly. Whenever we need to use a method that mutates a value, just make sure to copy it first.
Alternatively, we can use libraries like lodash, ramda or Immutable.js to make sure we don't mutate any values.
2. Declarative code is easier to understand
Functional programming languages tend to prefer a declarative style over an imperative. The declarative approach lets you focus on what you want your code to accomplish, rather than how it should accomplish it.
This makes your code more abstract and easier to read and understand. In declarative code, you just specify the outcome you want, and the language or framework takes care of the details for you.
This is different from imperative programming, where you have to write out all the specific steps your code should take to achieve the outcome you want.
Let's see a simple example - we will sort an array of numbers using the declarative approach:
const numbers = [3, 5, 1, 4, 2];
const sortedNumbers = [...numbers].sort();
console.log(sortedNumbers); // [1, 2, 3, 4, 5]
That is easy. We are so used to this method, that we are even not thinking about doing that in the other way.
Just for the sake of argument, how would we implement this in an imperative style?
let numbers = [3, 5, 1, 4, 2];
let sortedNumbers = [];
while (numbers.length > 0) {
let min = numbers[0];
let minIndex = 0;
for (let i = 1; i < numbers.length; i++) {
if (numbers[i] < min) {
min = numbers[i];
minIndex = i;
}
}
sortedNumbers.push(min);
numbers.splice(minIndex, 1);
}
console.log(sortedNumbers); // [1, 2, 3, 4, 5]
Not only this code is more complex, but it also encourages mutation, which we know is not safe - especially in such a complex code.
But wait, in the declarative example we've just used built-in sort
function. Isn't that cheating?
Well, no - declarative style relies heavily on using existing abstractions - it limits the amount of code that needs to be written.
This is clearly visible with declarative libraries like React or tools like Terraform. If we were to write the same functionality in the imperative style, it would take ages and many, many lines of code.
What can we learn from functional programming?
Overall, declarative code is generally easier to read and understand than imperative code, because it is more abstract and focuses on the desired outcome rather than the specific steps needed to achieve it.
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.
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.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.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 :)