pwlmc.dev

When Breaking The Rules Is Actually OK

This post is a dev diary about a design decision in OKFP, my functional programming library for TypeScript. You can check it out here: https://pwlmc.github.io/ok-fp/

In classical functional programming, especially in languages like Haskell, functions are expected to be pure. Let’s recap – a pure function:

  • has no side effects
  • always returns the same output for the same input

In general, pure functions are desirable. They require no mocking, so testing them is a breeze. They lead to predictable and maintainable programs. That said, in real-world applications, escaping impurity is seemingly impossible, as there is always some state to keep and transform. As a matter of fact, it’s quite common to have programs that are extremely stateful, where accessing state (from a database, network, or filesystem), updating it, and saving it are a big part of their business. However, the sheer fact that our program is stateful doesn’t automatically mean we can’t keep our functions pure.

Separating Data From Operations

If you look closely, the definition of a pure function doesn’t forbid interaction with state. It does, however, forbid pure functions from relying on implicit persistent external or internal state. To better understand what I mean by implicit state, take a look at the following function:

function createInc() {
  let i = 0;
  return function inc() {
    i++;
    return i;
  };
}

const inc = createInc();
inc(); // 1
inc(); // 2
inc(); // 3

The inc function returned from createInc is not pure because it does not return the same output for the same input. But there’s an easy way to fix it. If we separate state from the calculations, all of a sudden the function can be considered pure:

return function inc(x: number) {
  return x + 1;
};

let x = 1;
x = inc(x); // x = 2
x = inc(x); // x = 3

Pure functions don’t depend on any internal or external state. Data and operations on that data are separated. In TypeScript, however, there is an issue with this approach, and you don’t have to look far to spot it.

Let’s say we want to build an API that performs operations on a bank account. We want to be able to withdraw and deposit money, freeze, unfreeze, and close the account. One important note: any of those operations can fail, and we need to take that into consideration when designing our API. The traditional OOP way of modeling failure is to throw an exception, but there is a better option: the Either effect, and we are going to use that.

Either Effect

Feel free to jump to the next section if you are familiar with the Either monad.

The Either<T, E> effect is a wrapper type (here: Either<Error, Account>). You can think of it as a container holding Error or Account, but while the value is inside, you don’t need to care which one it is. Every Either<Error, Account> needs to have a flatMap operation defined. Here, we are going to name it flatMapE to make it clear it’s a flatMap for Either effect:

function flatMapE(
  e: Either<Error, Account>,
  fn: (account: Account) => Either<Error, Account>,
): Either<Error, Account>;

The point of flatMapE is that it will run fn only when Either contains Account. It will unwrap Account from Either automatically and pass it as an argument to fn. The return value is also Either<Error, Account> which allows fn to fail the operation by returning Error wrapped in Either.

The important bit to remember for flatMapE is that it will do nothing if the Either passed as the first argument contains Error. You can also think of it as mapping or flat-mapping over an empty array. After all, [].map((x) => console.log(x)) is a no-op as well.

We are not going to dive deep into how to unwrap values from Either. For the purpose of this article, you should know that wrapping the non-error data in Either is traditionally done by calling the rightE function:

function rightE<E, T>(value: T): Either<E, T>;

What other operations can be defined for the Either effect is beyond the scope of this article. If you want to learn more about this and other useful effects, please refer to the OKFP docs: https://pwlmc.github.io/ok-fp/either.html.

Now, let’s get back to modeling our Account API.

Account API with pure functions

If we model operations as pure functions, we might end up with an API that resembles the one below:

function deposit(account: Account, amount: number): Either<Error, Account> {}
function withdraw(account: Account, amount: number): Either<Error, Account> {}
function freeze(account: Account): Either<Error, Account> {}
function unfreeze(account: Account): Either<Error, Account> {}

Now, if we wanted to perform a series of operations on a single account, we can do this:

const account = {}; // account shape left out for brevity
let accountE = rightE(account); // wrap account in Either effect
accountE = flatMapE(accountE, (account) => deposit(account, 10));
accountE = flatMapE(accountE, (account) => withdraw(account, 5));
accountE = flatMapE(accountE, (account) => freeze(account));
accountE = flatMapE(accountE, (account) => unfreeze(account));

This is not inherently bad, but it’s definitely not ergonomic since we have to repeat accountE twice for every operation. Another way to perform the same set of operations on Account is to nest calls to subsequent operations:

const account = {}; // account shape left out for brevity
const accountE = flatMapE(
  flatMapE(
    flatMapE(
      flatMapE(rightE(account), (account) => deposit(account, 10)),
      (account) => withdraw(account, 5),
    ),
    (account) => freeze(account),
  ),
  (account) => unfreeze(account),
);

I think we can agree that this pyramid of doom is even worse than the previous example. So the question arises: how do functional programming languages deal with it?

FP way of dealing with nested calls

Many functional programming languages provide language-level support to make pure functional style ergonomic.

Haskell offers do notation:

let result =
  do
    account <- Right account
    account <- deposit account 10
    account <- withdraw account 5
    account <- freeze account
    account <- unfreeze account
    pure account

In Scala we have for-comprehensions:

val result: Either[AccountError, Account] =
  for
    account <- Right(account)
    account <- deposit(account, 10)
    account <- withdraw(account, 5)
    account <- freeze(account)
    account <- unfreeze(account)
  yield account

A similar effect can also be achieved in C# with LINQ query syntax.

What is important to note in both examples is that each language has a built-in syntax for a sequence of transformations, which allows it to take advantage of the most ergonomic syntax possible, while our previous TypeScript examples are bloated at best and plain unreadable at worst.

Now, can we bring the same functionality to TypeScript?

What Happens If We Try To Add It To TypeScript

Most functional programming libraries in TypeScript that I tried attempt to replicate the approach known from functional programming. They define free functions, such as:

mapE(either, fn);
flatMapE(either, fn);
filterE(either, predicate);

These functions are pure and live independently from the values they operate on. It mirrors the design found in Haskell or Scala, but in TypeScript, as we already saw, it quickly becomes awkward for sequences of data transformations. To alleviate this issue, we must introduce a custom pipe function to simulate ergonomic composition:

pipe(
  rightE(account),
  flatMapE((account) => deposit(account, 10)),
  flatMapE((account) => withdraw(account, 5)),
  flatMapE((account) => freeze(account)),
  flatMapE((account) => unfreeze(account)),
);

Note: for simplicity’s sake, I won’t go into how pipe works. Let’s just focus on the final effect for the sequential transformations of our account data, as in the previous examples.

The resulting code looks very similar to what we could see in Haskell and Scala examples, but is it worth it? The crux of the matter is: when your TS library functions are pure, you are forced to introduce some form of pipe function to keep the ergonomics reasonable. Without it, you won’t find many people willing to use your tool.

In my opinion, it’s a mistake. While the style is mathematically pure, for many TypeScript engineers it will feel unfamiliar and heavy. It starts to look like an embedded language, which will eventually become a barrier to entry for your library. For that reason, OKFP does things differently.

The Decision in OKFP

I decided to break the rule. Instead of relying only on free functions, effects in OKFP are objects with methods. You still construct them with pure functions:

right(5); // Either effect containing 5
left(new Error("Some error")); // Either effect with Error failure

But once you have the value, you operate on it like this:

const result = right(account)
  .flatMap((account) => deposit(account, 10))
  .flatMap((account) => withdraw(account, 5))
  .flatMap((account) => freeze(account))
  .flatMap((account) => unfreeze(account));

No pipe, no nested functions, just chaining calls. From a strict functional programming perspective, this approach is controversial because the operations are implemented as methods on objects, which technically introduces stateful behavior. That breaks the traditional FP preference for pure free functions. But in practice, what changes is simply the API surface. The resulting code remains immutable, data transformations are deterministic, and the flow is explicit.

Why I Think This Matters

My goal with OKFP was not to reproduce functional programming patterns from Haskell or Scala. The goal was simple:

  • Make functional programming approachable to TypeScript engineers

JavaScript and TypeScript developers are already comfortable with method chaining, so leaning into natural patterns makes the library feel native to the language.

Functional programming has incredible ideas that are still waiting for wider adoption, but I believe those ideas should adapt to the language they live in. Sometimes the right choice is not strict purity, but ergonomics and clarity. OKFP breaks one of the core rules of functional programming, but I believe it does so for the right reason.

<- Back