Senior software engineer Rory Bain explains how Freetrade integrates Typescript.
At Freetrade, our stack contains hundreds of serverless Node functions. We use Typescript to help us find bugs at compile-time rather than live in production.
There are countless articles about what Typescript is and why you should use it (here are some by Slack, Bloomberg and the Typescript Team themselves). What I’ll focus on instead here are some particular tricks we use to better utilise the language.
Tens, or even hundreds of serverless functions later, we decided to adopt the `strict` flag for tsconfig, which would add a bundle of extra compile-time checks to our code. This flag forces you to perform ‘null’ and ‘undefined’ checks - so that you don’t access or invoke something that doesn’t exist; it also ensures that function references that you pass around are invoked with the correct argument types, and it forces you to be explicit about function arguments so that you can’t have implicit any types.
For example, here we use the `find` function to look up an item in a list. This function returns either a list element or undefined. Without strict enabled you can compile and run this code, only to find that it crashes at runtime. With strict enabled, the compiler will fail to compile, enforcing that you check if `banana` exists before you access a property on it.
Each of these checks are handy and we wanted to adopt them. However, when we tried enabling the flag, we had hundreds of errors popping up across the codebase where we didn’t perform these strict checks in the past. In some cases, you would fix one error and three others would appear. It would be too big a task to migrate everything all at once.
Fortunately, we found this great post by the Visual Studio Code Team which describes how they had a similar problem with the `strictNullChecks` flag - one of several that is included in the `strict` flag. This post details how you can create a second `tsconfig` which opts files into strict null checks on a case by case basis.
Going forward, we’ve implemented a second tsconfig - like in the above post - which gets built on each pull request and is helping us catch bugs at compile-time rather than in production. We’ve also adopted the stricter config by default in all of our newer projects.
Typescript provides compile-time checks that expected types are provided. For example, say we had a function that sets the foreign `AccountId` key on an `Order` with a specific id.
Typescript will ensure that `setAccountForOrder` is called with two strings, which is great. However, what if someone was to call this API and accidentally pass the accountId as the first argument, and the orderId as the second argument? Well, the function would fail to look up the order and we might throw an exception.
To ensure that the correct string types are passed around, we can use a nominal type - as detailed in this great blog post by Michal Zalecki.
With this, we can create our own types that must be explicitly typed when created. This allows the Typescript compiler to distinguish between certain nominal types, which share the same underlying type.
By moving this check to compile-time, we can avoid breaking things in production and help keep the platform running smoothly for our users.
For our older code that is still not opted into strict mode, Typescript cannot ensure that enum cases are exhausted in switch statements. This can lead to situations where someone adds a new case to an enum, without noticing that they need to handle that case in a switch statement, which can cause bugs when it comes to running that code.
Take the following code which handles user input buttons for a game:
If we wanted to add support for moving left and right, we would update the enum with Left and Right. If we didn’t see the switch statement however, and we had strict mode disabled, Typescript would happily compile the code and we wouldn’t find out until runtime that left and right don’t work. This is because `direction` could be undefined or null, and without strict mode, Typescript will ignore those cases and won’t force you to add a default fallback.
To avoid this, we can add a default statement that passes the direction variable to a function that takes ‘never’ as an argument:
Typescript will now force you to add any new cases to the enum to the switch statement, as it will not allow passing a `direction` type to `never` - regardless of whether or not it is null or undefined.
MappedTypes allow you to meta-program the type system in Typescript. They allow you to programmatically create types from other types. This can be useful in ensuring that you pass the right types around and it saves you from rewriting existing types, which can be a pain point when types are updated in one place and you have to manually update them in several other places.
One example of where we use this is to create a generic cache wrapper for objects that make expensive requests to a database/web service. Say we have a CarRepository that looks like the following interface:
If all of our functions were just using a single ‘id’ argument, it’d be easy enough to use that id as a cache key. However, we have some functions, like the `getByMakeModel` function here, that provide multiple arguments or have arguments that are not string types.
Using a MappedType, we can define a type that maps each function of an object to a cache key creating function. This looks something like this:
Now let’s break down that type a little:
To put that type to use, lets see how we can create a provider object for our CarRepository:
Using CacheKeyProvider, Typescript will ensure that we provide both a `getById` and a `getByMakeModel` function here. It will also check that the functions we provide take the right number of parameters that are all the right type.
I hope that some of the tricks above are useful. As the Typescript language improves, and our understanding of it develops, we continue to find new ways to make the most of it - which ultimately helps us run a more robust platform for our users!