Functional TypeScript in Production Systems

Featured on Hashnode

Functional programming has enjoyed a massive surge in popularity amongst JavaScript developers in recent years. This is likely partly due to the popularity of libraries like Redux and React which promote functional programming styles, and have proven themselves both viable and in fact extremely valuable.

With TypeScript, functional programming isn't just nice, it's extremely beneficial in production systems for highly scalable business critical applications, both frontend and backend. How do I know? In my current day job, we run a billion dollar plus product on Functional TypeScript, Node, and React, serving tens of millions of requests per day.

What is Functional Programming?

I'll try to keep this section brief, since functional programming has a lot more mindshare these days, let's just make sure we align on what exactly we mean by functional programming in this context before we move on.

Functional Programming works well in JavaScript largely because it was originally pitched as Scheme in the browser. This is why functions are first-class citizens in JavaScript and thus TypeScript.

Functional Programming isn't…

It isn't just using functions!

Let's start by listing some things we don't do, or at the very least endeavour to avoid in our functional code.

  • Object mutations such as result.value = 5;
  • Variable mutations, so no var or let
  • while loops
  • for loops
  • for … in ... loops
  • Functions with side effects (we can manage or isolate components of our application that induce side effects, but core business logic should be implemented via pure functions)
  • Functions typed as void or Promise<void>
  • Array mutations, e.g. pop, push
  • Pass-by-reference input values for mutation!

So what is functional programming? Let's start with a core tenet.

Pure Functions

Pure functions must always return the same output for a given input. They must not have any side effects. Think of methods on objects — often, if I call the same function multiple times, it has a different output, i.e. counter.incrementCounterBy(5) we can imagine would return a different result every time it is called. That's because it is tracking state that exists outside the method, in this case the instance it is a method of. Even worse, methods may produce different results due to calls from other parts of the system. In the above example, if another part of the system called counter.resetCounter(), I might not get back the value I expected. These aren't pure functions at all, and if I simply refactor my code so resetCounter and incrementCounterBy are functions that read and write from some global or even passed in value, I haven't made them functional.

Let's have a look at what a pure function is. Normally, at this point in an article we get the very contrived example of

function sum(x, y) {
  return x + y;
}

Which, while functional, isn't a terribly inspiring argument for functional programming in serious business systems.

Instead let's see how we might encapsulate some business logic in a pure function. We're going to create a pure function to determine if a given user should be allowed to login, given some stringent requirements.

As such, the first part of this implementation will be establishing some rich types for us to use. Be patient! The functional code is coming.

type User = {
  id: number;
  email: string;
  name: string;
  accountStatus: AccountStatus;
  verifications: Verification[];
};

type AccountStatus = 'ACTIVE' | 'PENDING_VERIFICATION' | 'LOCKED';

type Verification = {
  kind: VerificationKind;
  validated: boolean;
  payload: EmailVerificationPayload | PhoneVerificationPayload;
};

type VerificationKind = 'EMAIL' | 'PHONE';

type EmailVerificationPayload = {
  email: string;
  isHighRiskDomain: boolean;
  confirmationReceived: boolean;
};

type PhoneVerificationPayload = {
  phoneNumber: string;
  confirmationReceived: boolean;
};

type LoginAttempted = {
  user: User;
  attemptNumber: number;
};

type LoginAttemptResult = {
  result: 'LOGIN_SUCCEEDED' | 'LOGIN_REJECTED';
  reason?: string;
}

// okay, now we can write our checking function

const checkUserCanLogin = ({ user, attemptNumber }: LoginAttempt): LoginAttemptResult => {

  if (attemptNumber > 3) {
    return {
      result: 'LOGIN_REJECTED',
      reason: 'Too many login attempts',
    };
  }

  switch (user.accountStatus) {
    case 'ACTIVE':
      return { result: 'LOGIN_SUCCEEDED' };
    case 'LOCKED':
      return {
        result: 'LOGIN_REJECTED',
        reason: 'Account has been locked',
      };
    case 'PENDING_VERIFICATIONS':
      const pendingVerifications = user.verifications.filter(v => !v.validated);
      if (pendingVerifications.find(v => v.kind === 'EMAIL')) {
        return {
          result: 'LOGIN_REJECTED',
          reason: 'Email verification pending',
        };
      }
      if (pendingVerifications.find(v => v.kind === 'PHONE')) {
        return {
          result: 'LOGIN_REJECTED',
          reason: 'Phone verification pending',
        };
      }
      throw new Error(`Corrupt verification data for user ${user.id}`);
      // This is an exceptional case, we want the application to blow up, and completely halt. We should never use errors to branch behaviour on in typescript - errors are untyped and poorly supported in TypeScript. This is very different from Python, where branching on Exception types is encouraged.
    default:
      isNever(`user ${user.id} had invalid status ${user.accountStatus}`);
      // This should never happen unless corrupt data somehow enters our system
      // Typescript will not let code compile that it sees can reach the default case
  }
}

There's some code we could see in a production system! It's complex, it enforces a lot of business rules, for example email verification comes before phone verification — perhaps phone verifications cost more money than email verifications, so the business wants this rule enforced in code.

Imagine how easy the test is to setup and run for this code! We can now easily write tests for each case, without worrying about issues such as setting up and tearing down database fixtures or stubbing out APIs. We're not making calls to third parties here, there are no promises, there is nothing but data and logic. Writing tests for every scenario becomes somewhat trivial, which means your tests can easily document all expected behaviour.

This is where pure functions in production systems make sense. But how DO I talk to a database or external API in a functional programming context?

Partially Applied Controllers

This is a pattern I've personally dubbed partially applied controllers. Please, if you know of a better name for this pattern, let me know in the comments!

So, we want to talk to some external, non-deterministic systems (i.e. multiple calls with the same parameters ARE NOT guaranteed to return the same result), get back a result, pass that data to our pure function, flush the result to our persistence system (database) and return the result to the caller so they can operate based on that result.

Now, we want this logic to live inside a function that doesn't have any implicit dependencies. Everything it needs we should be able to pass to it. We also want to be able to write some tests to ensure the controller works, but we don't need to write integration tests to cover every conceivable business case, because thankfully those are covered by the unit tests on the checkUserCanLogin function.

First of all, we will model our persistence store and a third party API each as async data repositories. We're going to supply this repository to a function that returns another function that we can then use to query a given userId and find out if they can login.

Let's get started

type UserRepository = {
  getUser: (userId: number): Promise<User>;
};

type LoginAttemptRepository = {
  getCount: (user: User): Promise<number>;
  incrementCount: (user: User): Promise<number>;
  resetCount: (user: User): Promise<number>;
};
// LoginAttemptCountRepository is a little contrived, but imagine we need data from a secondary source

const createCheckUserCanLoginController = (
  userRepo: UserRepository,
  loginAttemptRepo: LoginAttemptRepository,
) => async (userId: number): Promise< LoginAttemptResult> => {
  // get our data
  const user = await userRepo.getUser(userId);
  const attemptNumber = await loginAttemptRepo.getCount(user);

  // perform business logic
  const loginAttemptResult = checkUserCanLogin({ user, attemptNumber });

  // flush the result, could wrap this up in its own function since branches are a code smell in partially applied controllers
  switch (loginAttemptResult.result) {
    case 'LOGIN_SUCCEEDED':
      await loginAttemptRepo.resetCount(user);
      break;
    case 'LOGIN_REJECTED':
      await loginAttemptRepo.incrementCount(user);
      break;
    default:
      isNever(`user ${user.id} received invalid result ${loginAttemptResult.result}`);
      break;
  }
  return loginAttemptResult;
};

I intentionally left the implementation of the repositories out for now, because I want to demonstrate quickly why we're applying this pattern. Looking at those types I've written, is there any reason a repository HAS to be an actual database?

I could just as easily write these repositories as in-memory stores. In fact it might be easier. Then I could write out the full implementation of my feature before even considering how we're going to persist the data, or more importantly, I can write the tests first, then get this working with an in-memory repo before writing up my database layer.

Using this function itself is as easy as follows:

const checkUserCanLoginWithMemory = createCheckUserCanLoginController(
  memoryBackedUserRepo,
  memoryBackedLoginAttemptRepo
);

const checkUserCanLoginWithDatabase = createCheckUserCanLoginController(
  databaseBackedUserRepo,
  databaseBackedLoginAttemptRepo
);

// For completeness, here's what calling this looks like
const loginResult = await checkUserCanLoginWithMemory(userId);

Let's write implementations for one of these repositories now, just so nothing is left to the imagination.

const users: User[] = [
  // mock user not included for brevity
];

const memoryBackedUserRepository: UserRepository = {
  getUser: async (userId: number): Promise<User> => users.find(u => u.userId === userId);
};

Yes, if you haven't realised already, I've left checking for non-results out in this code. We're going to pretend find always returns a result, and users always exist in a database.

Let's add a database repo

const databaseBackedUserRepository: UserRepository = {
  getUser: async (userId: number): Promise<User> => {
    const result = await pool.query<UserDatabaseResult>(`
        SELECT * FROM users u
        JOIN user_verifications uv ON u.id = uv.user_id
        WHERE u.id = $1
      `,
      [userId]
    );
    return result.map(userDatabaseResultToUser).shift();
  },
};

So you're probably thinking "Hang on Anthony, this doesn't look very functional! This is just like the example you gave earlier of non-functional functions!"

You're right. The controller encapsulates all the impure behaviour in a single function. It allows us to keep other parts of the application pure while maintaining a functional style. That's one of the reasons branching is a code smell in partially applied controllers — you don't want business logic sneaking into the impure parts of the code through an if statement here or there.

In our example, we can use the in-memory tests to assert that the flush operation logic is maintained. We could also write tests for the database-backed repository itself if we want to ensure that our counters worked properly against the database, or that our join returned the correct data.

But when we test the rules around who is allowed to login, the only thing under test is our implementation of those business rules, not the database, and not our mocks.

I hope the above demonstrates how we can use functional programming in our every day production systems!

Please leave a comment if you have any questions, suggestions, or improvements.