Writing scalable Typescript
Let’s explore in detail how to write clean, safe, enterprise-grade Typescript code
Let’s explore in detail how to write clean, safe, enterprise-grade Typescript code
Before we get started, I want to give my checklist for “scalable code”; in the case of this article, Typescript code:
- it is clean, well-formatted, readable code
- it is well-designed, consistent and predictable
- it is easy to extend
- last but not least, it is bug-free
Since its introduction, Typescript has helped us solve some of the biggest problems with the Javascript ecosystem thanks to its tooling and its powerful static analysis.
In my experience, though, many teams only leverage a very small part of the features that Typescript provides us.
Some argue that:
- it is time-consuming
- typing our code does not reduce its bugs
- it’s harder and more difficult for junior developers to get started with
And I am not here to prove them wrong. Cause thing is, they may be right.
But Typescript doesn’t have to be scary, and in my opinion, writing code using it properly largely outweighs the cons mentioned above.
In this article, I want to discuss and explore the best tooling and practices to make Typescript worth our time.
Use Linters and Formatters
As stated above, we want our Typescript code to be clean, well-formatted and readable.
In one of my previous articles, I wrote a guide for setting up Prettier and TsLint in order to keep our code consistent for the whole team. As I may be repeating myself too often, I won’t go too much in detail in regards to why and how to set up these tools.
Instead, I want to focus on the impact of the benefits to your team from using these tools.
Whether you’re using EsLint, TsLint, Prettier or all of them, I can’t stress enough that providing consistency to our codebases is one of the most impactful things in terms of productivity for an efficient team.
A familiar codebase:
- is easier to read and work with
- is easier to understand and get started with
- is easier to modify
- is easier to review
- reduces frictions within a team
And anyone who’s worked in this industry long enough knows far too well how important the last point is.
For a business, frictions, and misunderstandings among colleagues are worst than bad code and worse than bugs.
And programmers happen to care about formatting quite a lot.
By reducing to the minimum the amount of time spent arguing on code reviews, meetings, and calls, we make the business more efficient as a result.
The time spent reviewing where a semicolon is positioned is instead spent checking that the business logic of the code is correct, or that the performance of a function is optimal. That’s what a code review should be.
A happy team is a team is an efficient one. Keep your Typescript clean with linters and formatters.
Use Strict Compiler options
One of Typescript’s biggest help comes from enabling its strict mode compiler. In order to enable strict mode, you just need to change the compiler options file located in your tsconfig.json
:
"compilerOptions": {
... // more
"strict": true
}
By enabling this option, as a result of the other strict options will also be enabled by default.
Strict Null Checks
A strict compiler will help us catch possible errors that may happen at runtime.
strictNullChecks
is, in my opinion, the most important option to make your compiler help you prevent such errors.
In conjunction with correct typing, this option will warn us when we are trying to access a possibly null
or undefined
value.
This is probably one of the most common causes of runtime errors in our applications, and we can finally get help to avoid them as much as possible.
If you have a been a Javascript developer for more than 2 hours before reading this article, you may have seen this in your console:
Uncaught TypeError: Cannot read property ‘property’ of undefined
Oh, I have. Thousands of times.
But… not so much lately, thanks to Typescript.
So how exactly does strictNullChecks
help us?
Let’s break down this example:
- we have some boolean called
x
- we have a declared function
logger
- we want to call
logger
with a variable calledmsg
that could also be undefined
And the compiler is, rightly and gently, letting me know that if logger
accepts an argument that I type as string, then I can only pass an argument that is only and always a string.
If I type logger’s msg argument as a string, then I cannot call String’s methods.
These examples look as trivial and extremely simple, but it’s incredibly common to find similar situations in professional codebases.
The good thing is, strictNullChecks
helps us in much more contrived scenarios.
Type well, Type often
Honest typing
Some of you may be thinking if the term “honest” is due to my limited English skills or if there’s more to it. What’s honest typing?
- Say we have a back-end API that returns the price object of a financial product
- Not all responses contain an ask price or a bid price
{
"correlationId": "123",
"askPrice": 1213.45,
"bidPrice": undefined
}
Let’s create a Typescript interface for this:
interface Price {
correlationId: string;
askPrice: number;
bidPrice: number;
}
Is that correct? Certainly not.
I have heard several reasons why programmers won’t fully type nullable values:
I’m lazy, the compiler will complain
98% of times it’s not undefined
“I have no idea what that does“
We want to tell the compiler that askPrice
and bidPrice
might be undefined
. The compiler, as a result, will warn us when we’re trying to access these properties without checking the type or if they exist beforehand.
interface Price {
correlationId: string;
askPrice?: number;
bidPrice?: number;
}
// or
interface Price {
correlationId: string;
askPrice: number | undefined;
bidPrice: number | undefined;
}
That means the compiler helps us avoid runtime errors when that 2% of times do happen.
Honest typing also helps our new coworkers or users of our libraries to fully understand the domain entities of the application.
There’s literally no reason why your client-side entities shouldn’t fully and strictly be typed as their back-end counterparts.
no implicit any
As we have seen in the previous paragraph, honest and rigorous typing plays a fundamental role in ensuring our code behaves in a correct way.
Honest typing is related to the option noImplicitAny.
Let’s consider the following snippet:
The compiler has no idea what x
and y
are, and in some situations, it cannot figure it out on its own.
Don’t be lazy, and type your code.
There are situations where the compiler can figure it out without us explicitly adding a type, but in these cases, you need to consider whether adding the type increases or decreases the readability of your code.
Clean Typescript Code
Use predictable naming conventions
While linters and formatters make great allies in ensuring consistency across our codebases, there are some things that they still cannot help with: naming.
Use predictable naming conventions your team can understand is fundamental in ensuring cleanliness, consistency, and clarity.
Consider the following snippet, which is a scenario I encounter far too often:
Obviously, I am not saying naming is easy. It’s not.
But if you follow the most basic principles, you’re still ahead of many. Some things I’d feel suggesting are:
- if your method does not return anything, never prefix it with get
- if your method returns something, never prefix it with set
- ideally, don’t set and get in the same method…
- if your method is returning a boolean, consider prefixing it with is or should (isThisThingVisible, shouldShowError, etc.)
- don’t name your variables with their type
- if you’re using a DSL from a different library or framework, stick with their conventions. For example, if you declare an observable with RxJS, make sure to suffix it with the dollar symbol ($)
Use Aliases
Let’s be honest, no one likes seeing relative imports all over the place in our Typescript code. Using the paths aliases functionality in Typescript is a great way for making the imports nicer and shorter.
How do aliases work?
We define the paths
configuration in our tsconfig.json
. See the below example:
And then, I can access all my interfaces from @core/interfaces
and (if you prefer even shorter access), all my enums from enums.
Prefer horizontal reading
This is someone not everyone may be on the same page with me, but that I greatly believe impacts the overall readability of your code.
I love to keep my lines code between 80 (perfect) and 120 lines of code, depending on how my team feels about it.
Let’s see the difference with one of my projects’ snippets. In the following image, the horizontal length is set to 120.
In the image below, it is instead set to 80.
Which one would you say it is easier to read and modify?
💡Pro tip: Use Prettier to automatically wrap code for you
Arrow functions are cool but don’t overuse them
I love arrow-functions. And I use them pretty often too. But I see them abused from time to time.
Arrow functions are perfect for small expressions, but for longer and more complex ones, I’d much rather create a function block.
Sometimes I see pretty convoluted expressions just for the sake of using an arrow function.
Let’s see the difference between a long expression with an arrow function:
And without an arrow function:
It is totally possible to still use an arrow function and wrap the expression on the next line, but I feel it adds complexity when I happen to refactor the code, for example, if I need to add a variable in the expression.
If you wrap it with an arrow function which is perfectly fine, make sure that the piece of code is unlikely to be changed anytime soon.
Use logical spacing
Just like in Medium, white space, although not too much, can impact readability.
The same happens with our code: we want to add spacing where it makes sense.
Some code just feels like a wall of text without any logical separation. This is not an easy task, as everyone might just feel different about it, and a lot of it probably depends on everyone’s preferences.
I’d say there are two main reasons for adding spaces:
- logical reasons, as it concerns the logic behind our code
- design reasons, as it concerns how easy the code is to read
I won’t talk about the design reasons behind it as this just feels too subjective. Personally, I just try to follow these simple guidelines:
- group variables declarations logically
- keep one white space between your return statement and the function body
If we are building two different objects with two separate groups of constants, chances are we want to add a space between them. For example:
const name = "..";
const surname = "..";
const player = { name, surname };
// logical break
const teamName = "..";
const teamId = "..";
const team = { teamName, teamId };
return { player, team };
Break complex expressions down
It is pretty easy to end up with various long and complex expressions in our code.
I‘d recommend to break down long expressions into groups of variables and separate methods.
- If a condition has more than 2 or 3 expressions, you should consider to break it down
- if a condition contains magic strings and numbers, you should consider extracting the expression into a method
Let’s see a scenario where we have two possibly undefined objects that I am sure you see every day:
execute() {
if (price && price.canExecute && user && user.hasPermissions && service.status === 1) {
return priceService.execute(price);
}
}
Maybe it’s my poor sight, but I can’t read that! Let’s refactor this:
execute() {
const STATUS_CODE_UP = 1;
const isServiceDown = service.status !== STATUS_CODE_UP;
if (isServiceDown) {
return;
}
if (!price.canExecute || !user.hasPermissions) {
return;
}
return priceService.execute(price);
}
Ok, I can read this, but now just feels too long. Let’s refactor again:
const STATUS_CODE_UP = 1; // ideally imported from another file
get isServiceUp() {
return service.status === STATUS_CODE_UP;
}
get canExecute() {
if (!price || !user) {
return;
}
return price.canExecute && user.hasPermissions;
}
execute() {
const canExecute = this.isServiceUp && this.canExecute;
return canExecute && priceService.execute(price);
}
That feels better!
Takeaways
- Lint and Format your code before it gets pushed
- Be rigorous with your code, enable
strict
mode - Type well, and type often. Don’t use any, use generics and unknown instead
- Make sure your code is as readable as possible by following industry standards