How to make Angular applications more robust
In our day to day work we tend to skip and short cut some paths to save time for ourselves or make it easier, but eventually it back fires as careless approach leads to an increased amount of bugs in the production, higher maintenance costs, harder features incorporation and so on.
In this article we are going to cover some of the topics that are vital for better development and maintenance as well as reducing team tension.
0. Stop fighting TypeScript, you are on the same side
By some reason developers tend to think that TypeScript is an enemy as it keeps throwing errors at them and not building the app. But let’s rewind why we even have such a tool. JavaScript is a dynamically typed language and this leads to problems at runtime when variables’ names are misspelled, wrong types used (remember those jokes around 1 + "1"
).
Developers needed something to help them keeping them sane by enforcing usage of correct types and spelling variables correctly. That’s why TypeScript was born — to help us be consistent and avoid silly mistakes. This is where we come to the #1 point in this article.
1. Don’t use "any"
Seriously, don’t. By using any
you are slipping back into the JavaScript world and have no more type safety, what makes the usage of TypeScript pointless. It’s like trying to keep food hot in the freezer — why have a freezer in the first place?
any
type can only be used when your code can work with literally anything and even in this case it’s safer to use unknown
instead, so TypeScript will make sure you do type checks like if (e instanceof User)
, if (typeof e === "string")
before accessing type specific methods/props.
2. Use specific types
By using concrete types you enable TypeScript to kindly slap you when you misspell, use incompatible types etc. Also it helps us very much during refactorings as VSCode and other IDEs are capable of offloading to TypeScript or handling themselves variables renames so you don’t need to go through the entire code base when you want to rename a variable.
3. Don’t be afraid of generics
Generics help us to have a less specific type (e.g. “string” instead of “Alex”, “Bob” for a function getFullName(): string
) and so follow the Open-Closed principle, since we don’t need to change the signature or implementation of the function when a new name arrives.
Also, when we use a signature such as function concat<T>(arr1: T[], arr2: T[]): T[]
we are telling in a generic way to TypeScript that there is an imaginary type T which can be defined explicitly when the function is called (concat<User>(oldUsers, newUsers)
in the angle brackets), or will be inferred from the arguments used. It doesn’t really matter how you call the T
, for simple methods it’s okay to use a single uppercase letter for brevity.
4. Handle errors
Developers are optimists, that’s why they like to write simply
this.userService.getUsers().subscribe(users => this.users = users);
Otherwise, I can’t explain why server requests tend to miss error handling callbacks, or they might pass an empty callback. The second case not only is not prepared for the real life scenarios when there are network issues, server side issues etc, but also it silences errors which makes it insanely hard to detect problems and debug them, because the console is always clear.
Please, at least put a console.error
there, so that developers can see in the console immediately what went wrong. Ideally, you should also handle it in the UI (show a notification to the user, retry a request etc depending on the business requirements; if there are none you — should ask for them).
5. Don’t use optional chaining operator everywhere
While it’s easy to write users?.[index]?.data?.firstName
and immediately silence the TypeScript compiler complaining that value might be null it’s a bad approach. When TypeScript complains that something may not have a value that’s a point of a potential high interest to the business. The reason for that is that if written as in the example — there would be emptiness (e.g. a rendered card with no header), meanwhile we might want to show something else indicating that there is a problem or perhaps no content. Furthermore, each ?.
operator is an if/else pair, which means you’ll need to have at least two test cases for each operator in the access chain so to cover all branches in tests.
6. Use ESLint
With great power comes great responsibility. Same is true for the code style. If it’s not enforced for each team member to follow it becomes a mess, because each one makes their own decision on how the code should be written. The longer you wait, the harder it is becoming to read the code. Furthermore, the team may start fighting over the code style during the PR review process, which is a waste of time.
To eliminate it, you need to gather up your team, talk to them and come to the common ground on code style rules for the team and enforce it using ESLint.
This way:
- no need to keep all decisions in mind
- no need to spend time during the PRs checking for and fixing code style issues
- everything is highlighted automatically (who in the world loves automating stuff as mush as developers?)
- new developers don’t spend hours on KTs (knowledge transfers) regarding code style guides
- no decision making fatigue
- much less fights in the PRs
- you have a much cleaner code base
- you have much smoother interpersonal relations in the team
The more well-written rules you have — the less decisions developers need to make, so you would want to consider incorporating some additional plugins to cover broader areas in the code base.
7. Use Prettier
Same points as for ESLint with the small note, that Prettier is rather about indentation and quotes, than about the way developers write the code. It just makes your code base look pretty (how can it be not, when all files have the same indentation and quote marks? :) ).
8. Use Husky
Huskies are fluffy and great companions for all humans, so make sure every developer has at least one.
Jokes aside, Husky is a great tool which allows you to write Git hooks once and they will be installed automatically for all developers. This way you can automate work of linters, formatters, run tests on actions such as commit, push etc. so developers have an early feedback way before the pipeline build fails.
I could advise to setup running formatters and linters check on changed files on each commit as they do it fast and running tests before pushes.
9. Embrace the tests
It’s important to keep in mind that when we write tests — we do it for ourselves, not just to hit some numbers in coverage. Coverage is easy to hit with tests that do nothing.
Usually, when writing tests you want to test the business logic of some code fragment so that you are sure it does what it was intended to. So, you should check that all methods were called correctly, that a tested method does return a correct value for a given input.
Also, it’s good to test not only the happy path, but also some edge cases (don’t spend hours thinking of exquisite cases though, users will always do a better job at it). A few simple ones that are more likely to happen should be enough. When fixing a bug it’s also a good approach to add it to the test cases so it verifies the bug presence first, then that the bug was fixed and after it was deployed that a regression doesn’t happen.
When you develop new code cover it with the tests that cover the basics of its functionality at the very least. It’s quick and will help you in the future catch some bugs early, especially during the refactorings.
Refactorings are like one of the solid reasons why we need to write tests. Complex algorithms are soon forgotten and then when time comes to improve it or rewrite due to its bad architecture it’s a real quest, because it may already behave in the wrong way and during the refactor you actually don’t know what this code was intended to do. If there were tests, there would have been at least some guiding lights that the code had to do A, B and C. If everything works, then the high chances are that the refactoring went well.
10. Configure CI/CD
You may have well-established processes in the team, linters and formatters, tests running before push, but if you don’t verify on the remote that all those rules (read linters) are followed and tests are passing (including the build of course) then it’s more of a “Hi, would you mind doing it? No? Oh, okay”.
Git hooks can be skipped, developers may do some unexpected movement that your hooks were not prepared for and break the general approach. Finding those manually is hard and unproductive. Instead, when the code arrives to the repository i.e. a PR is created, you should run at least: linters, formatters, tests, make a build to ensure everything is okay. If not — prohibit the merging of the PR by means of the repository platform.
11. Be mindful with dependencies
When you decide to add a new library to the project take a minute to check the reliability of the library — how many people out there are using this library, how often it’s updated, what was the last time the update was published, how many issues there are in the repository.
If picked carelessly, you may end up in a situation when the library is not maintained for a few years and it blocks you from upgrading your Angular version, some feature development etc. At the same time you could have already used it extensively throughout the application, meaning it would be quite hard to replace this library.
12. Eliminate dead code as soon as it becomes so
Don’t keep in the app constants, props, methods, functions, constructor injections that aren’t used anymore as it adds to the mess, while being distracting during the refactorings. Nowadays, IDEs are clever enough to highlight unused pieces of code, so make sure to keep your code clean.
If you remove something — remove it completely and not by commenting out, unless you are dead sure you’ll need this next PR. You are working in a Git repo, which means that everything in your remote branches will be there in the history (unless someone force pushes the only branch with the commit indeed), so you could mark the commit’s hash in a ticket for the future reference when it’s needed.
13. RxJS — know your enemy
RxJS is a great library with lots of features built in while being shipped with Angular by default. If mastered you will never regret it and will be a wizard in Angular world.
If used right, it can significantly reduce the complexity of your code while keeping it declarative, as most of the common cases are foreseen by the authors and covered by operators.
Lots of things in Angular are better done in the reactive way and can be directly connected to templates using async
pipe which handles observable subscriptions for you.
When a Subject
and its kinds are used you should always complete them on components/services destruction phase (exception is singleton services as they are created once and never destroyed). This is needed to ensure that there are no hung subscriptions by any chance, thus preventing memory leaks.
Another important thing is to never subscribe
, 70% of the cases if not more are better suited when connected to a template directly, because:
- Angular handles subscriptions for you
- it’s simpler and clearer
- you won’t forget to unsubscribe
For the cases when you do need a subscription you should always unsubscribe when a component/service is destroyed. It can be achieved in two ways:
- use the
takeUntil
operator and pass a subject to it, in thengOnDestroy
hook call.next()
on the subject and then.complete()
. The benefit of this approach is that one subject can serve as a completion trigger for any amount of observables - store a subscription in a property and in the
ngOnDestroy
hook callthis.subscription.unsubscribe()
See now, why it’s easier to use the pipe for handling subscriptions?
I hope this post will help you to provide your users with a more stable and smooth product, while keeping the team healthy and environment friendly.
Looking for an easy way to scaffold a new Angular repo with all the tools mentioned in this post? I have published a package which does exactly that!