Skip to main content

Future-proof union values handling with TypeScript

· 7 min read

Unions and discriminated unions are great tools that can make the code flexible, while keeping the complexity to a low level. But tiny differences in the style of the code that uses them can make the difference between robust code, and bugs waiting to happen.

In this article, we will dive into two patterns that can help us make our code more future-proof.

But before "future-proofing" our code, let's remember that many technical debt nightmares originate from unfortunate attempts to be future-proof.

So let's be clear about what we mean by future-proof code.

Early abstraction meme

You don't need foresight

When we develop a feature, every one of us likes to get it right and make sure it behaves well against the test of time.

So picture this:

  • Step 1: Guess all the possible futures
  • Step 2: Implement the best abstraction to encompass all of them
  • Step 3: Yay, whatever happens, we're covered !

Wouldn't that be great ?

Unfortunately, from my personal experience, the business will most likely soon throw unexpected things that will break the abstraction.

So if like me you're bad at foreseeing the future, you'll have to deal with a much more complex abstraction that doesn't even do the job. You will be in a worse situation than if you didn't try to anticipate the future in the first place.

The most future-proof code

So does it mean that we should completely forget that our codebase will evolve ?

Of course not !

What we need to do is to find a way to make our codebase more adaptable to change.

This will result from three characteristics:

  1. The code is simple and easy to understand
  2. Business logic is well tested and tests are easy to understand
  3. It's easy to identify parts of the code that need to be changed

In this article, we will explore two patterns that make point 3 easier.

The problem with the if statement

Let's do this with a concrete example: payment schedules.

At Orus, we need to provide our customers with a document called "Payment Schedule". This document contains a table that will list incoming payments for a given contract.

But they can take very different forms, depending on the payment recurrences, and the specificities of the contract. For example, we can have:

  • One single payment
  • Twelve monthly payments
  • 1 payment for the next 4 months, then 1 payment for the next year

We want each document to be crystal clear and simple to understand, so we will use a different structure ("templates") for each of these cases.

At some point in time, we only had 2 payment recurrences: yearly and monthly.

Let's say we have a getPaymentSchedule method that does this:

  async getPaymentSchedule(contract: Contract): Promise<PaymentSchedule> {
const payments = await this.invoicingService.getPayments(contract);

const template = contract.paymentRecurrence === 'yearly'
? this.yearlyPaymentScheduleTemplate
: this.monthlyPaymentScheduleTemplate;

const { customer } = contract

return template.generateDocument({ customer, payments });
}

Now, in the code above, there is a bug waiting to happen.

Do you see it ?

In our current codebase, we have hundreds of places where we use the concept of payment recurrence. When we add a payment recurrence ('one-time', 'quarterly', etc), we will have a hard time identifying where updates are needed.

In an ideal world, all the changes will be carefully planned and a product designer will craft a wonderful new template for the payment schedule for the new payment recurrence. And implementing this template will be a task in our well-oiled project management process, ensuring success at the first time.

But we're not yet in an ideal world, so let's see how the structure of the code helps us.

In the current state of the code, if nobody thinks about the payment schedule, we will use the wrong template, and the resulting document will be absurd.

The early abstraction dead end

So let's pause a little bit and see how we can improve this.

First (bad) idea: early abstraction. Based on our two payment recurrences, we could create an abstraction of what a payment recurrence is. For example, we could say that a payment recurrence is a TimeUnit (day, month, year, etc...) and a Frequency (every 1 months, every 3 months, etc...). And code everything that depends on this abstraction using these parameters. We could have a single template that would be much more dynamic and easier to maintain.

The problem is that our single dynamic template would be much more work than the sum of the two specific templates we have today. So it's a bad strategy for today's problem, and we are not even guaranteed that it will help in the future.

Abstractions should be introduced to make implementing current requirements easier. Not to bet on future requirements that we can't foresee.

What we really need is a way to be notified that we need to update our code.

Use records to associate behaviors to union values

So here's the first pattern that will help us.

  private templates: Record<PaymentRecurrence, PaymentScheduleTemplate> = {
yearly: new YearlyPaymentScheduleTemplate(),
monthly: new MonthlyPaymentScheduleTemplate(),
}

async getPaymentSchedule(contract: Contract): Promise<PaymentSchedule> {
const payments = await this.invoicingService.getPayments(contract);

const template = this.templates[contract.paymentRecurrence];

const { customer } = contract

return template.generateDocument({ customer, payments });
}

The code above is not more complex than the initial code, but it's much more future-proof.

Indeed, if we add a new payment recurrence, the code will not compile until we update the templates record.

The result is we don't have to bother now about the future payment recurrences, and we can focus on the current one. The compiler will help us identify where an update is needed.

Exhaustive switch statements

Sometimes it can be a bit cumbersome to have to introduce records and callback functions everywhere we want to implement a slightly different behavior based on the value of a union type.

So to allow for a more lightweight approach, we activated the eslint rule typescript-eslint/switch-exhaustiveness-check.

This forces us to handle all cases in our switch statements, and to not miss any union value.

This way, if a union value is added, the linter will not be happy until we update the switch statement to handle the new value.

  async getPaymentSchedule(contract: Contract): Promise<PaymentSchedule> {
const payments = await this.invoicingService.getPayments(contract);

const { customer } = contract

switch (contract.paymentRecurrence) {
case 'yearly':
return this.yearlyPaymentScheduleTemplate
.generateDocument({ customer, payments });
case 'monthly':
return this.monthlyPaymentScheduleTemplate
.generateDocument({ customer, payments });
}
}

As with the record, we can focus on the problem at hand, and trust the linter to notify us when we need to update our code later because of a new union value.

⚠️ Adding a default case would defeat the purpose ! In some situations, it might make sense, but then it's not the same pattern anymore.

Conclusion

Writing future-proof TypeScript code doesn't require predicting the future.

By using exhaustive checks with Records and switch statements, we can rely on the compiler and linter to notify us when new union values are added.

This approach keeps our code simple today while ensuring it remains adaptable tomorrow - letting the type system do the hard work of tracking down what needs updating when requirements change.