This article will help you to avoid mistakes that are difficult (or just tiresome) to fix later. If you are going to create a new project and want to make it amazing — keep reading!
Pure Functions
This is the most important thing: keep your functions pure, as many of them as possible.
That’s how Wikipedia defines pure functions:
- the function return values are identical for identical arguments (no variation with local static variables, non-local variables, mutable reference arguments or input streams), and
- the function has no side effects (no mutation of local static variables, non-local variables, mutable reference arguments or input/output streams).
Functions that use keyword this
are not pure — they use information outside of their scope, so they can return different results for the same arguments.
Nonetheless, some of our functions, obviously, have to use this
— still, try to move as much of your code as possible from impure functions to pure functions.
Functions that mutate their arguments are the biggest source of evil — avoid them at all costs.
Immutability
Accidental mutations of data often lead to dangerous bugs, that’s why the JS community created tools to provide immutability for data structures. You can find them and read their documentation if you like (at least reading about them is a good idea, anyway).
I’ll show you one trick that will be “good enough” in many cases — it will not save you from every mistake (as immutability tools would), but it will cover the absolute majority of cases, and it will cost you nothing:
export type UserModel = {
readonly name: string;
readonly email?: string;
readonly age?: string;
}
By adding readonly
keyword to the fields of your data structures, you will get a TS error on every attempt to mutate the data you received as an argument. And all of that will only have cost during compilation time — it will be removed after compilation and will not be checked in runtime.
It’s enough to just create a shallow copy to get rid of that error and create a new object with a modified field:
updatedUser = {
...user,
age: 25
}
Also, it’s not as restrictive as mutability tools — if you know what you are doing and want to ignore this limitation for a particular case, you can apply the -readonly
modifier or the Writeable
type from type-fest or ts-essentials.
In these libraries, you can find also types to make your data structure readonly recursively (deep), but in practice, it works not as flawlessly and predictable as expected — try them, if you want, but I found that it’s better to either manually declare fields as readonly (and do it recursively for nested structures), or just use immutability libraries.
Immutability is a big topic, too big for just one part of an article, and you can find much more information and opinions.
If you are not using it yet, I encourage you to try it: in the beginning, it will be a little bit bumpy road, but eventually, you’ll start applying rules and patterns of programming with immutability in mind, and it will become just as easy as regular programming. Also, it works especially well with the pure functions ;)
Visibility and mutability modifiers
There is a well-known rule: use const
instead of let
to declare variables.
I advise you to use another one: add the readonly
modifier to every field that you are not going to modify (in classes, types, interfaces). It will bring 0 cost — if you are not going to modify it, then the compiler will catch any accidental attempts to do this. If you later decide to make this field writeable — you can just remove the readonly
modifier.
export class HealthyComponent {
// do not modify this!
private readonly stream$: Observable<UserModel>;
// it's ok to modify this
protected isInitialized: boolean = false;
constructor() {
// you can initialize readonly fields in the constructor
this.stream$ = of({name: example});
}
}
For the fields and methods of your classes (including components, pipes, services, and directives), use visibility modifiers: private
, protected
, public
.
You will thank yourself for this later: when some method of field is private and at some moment you see your class can get rid of it, you can be sure that no other code is using it, so it’s fine to remove it.
Protected fields and methods are visible not only for inherited classes but also in the Angular templates, so it’s a very good reason to use protected
modifier for the fields and methods that should be accessible to the template, and private
for the fields and methods that the template doesn’t need.
I don’t add public
modifiers — fields and methods are public by default without modifiers — but it’s your personal choice. Since inputs and outputs should be public, I don’t add a modifier to them to don’t overload the syntax.
Same as with the private
modifier, protected
will let you know that you can safely remove or rename some fields or methods without worrying about the template.
Any code tends to increase complexity with time, humans can’t remember everything: that’s why these small modifiers can make a big difference during refactoring.
Type Aliases
I wish someone told me this earlier: use type alias instead of interface
for your models and other data structures.
export type Model = {
readonly field: string;
readonly isExample?: boolean;
}
In another file:
import type { Model } from '@example/models';
By doing this, you can avoid loading the whole library when you just need a few models because imports like this will be completely removed during the TypeScript compilation: documentation link.
Also, you will avoid implicit interfaces declaration merging and will use explicit intersection types (if you want). There are no other significant differences, so type alias is the best choice.
Branded Types
Another trick that is better to start using at the beginning of a project.
“Branded type” is like a regular type, but with some additional information. That addition can be ignored by the code and can be used by the compiler to help you.
export type UUID = string & { __type: 'UUID' };
In the example above, UUID
will still work like a string
, but it has a piece of additional information (“branding”), that might help distinguish it from just a regular string.
Branded types will save you from situations when you pass the user’s password instead of email to some function. It will catch an error when you send a wrong ID — and it’s the most difficult case to catch without branded types because IDs are often stored in variables and fields with similar names and types.
We can do it with a unique symbol, or without:
// Method with additional fields:
export type UUID = string & {
readonly __type: 'UUID'
};
export type DomainUUID = UUID & {
readonly __model: 'Domain'
}
export type Domain = {
readonly uuId: DomainUUID;
readonly isActive: boolean;
readonly name: string;
}
export type UserUUID = UUID & {
readonly __model: 'User'
}
export type User = {
readonly uuId: UserUUID;
readonly name: string;
}
// Method with a unique symbol:
declare const brand: unique symbol;
export type Brand<T, TBrand extends string> = T & {
readonly [brand]: TBrand;
}
export type UUID = Brand<string, 'UUID'>;
// you can extend not only primitive types
export type DomainUUID = Brand<UUID, 'Domain'>;
export type Domain = {
readonly uuId: DomainUUID;
readonly isActive: boolean;
readonly name: string;
}
export type UserUUID = Brand<UUID, 'User'>;
export type User = {
readonly uuId: UserUUID;
readonly name: string;
}
You can see from the code, that the unique symbol here elegantly replaces our artificial field __model
.
More about branded types and methods of their implementation, you can read in this Twitter thread.
Typed Functions
Please type your functions: their arguments and returned result.
When you create them, it’s obvious to you what they should receive and what they should return. But there are two reasons to add types:
- For the rest of the code, it’s not obvious what they should send to that function and what it guarantees to return;
- For you, in a few months, it will be not obvious either.
There are different opinions if we should declare return types: I recommend you declare them, excluding (if you want) those that don’t return anything.
The main benefit is not obvious initially, but it will be very helpful during refactoring:
- if you change the code of your function and accidentally change its return type, it will be caught by the compiler;
- if you intentionally change the return type of your function and some code that uses this function is not ready for that — it will be caught by the compiler.
In the case of inferred types, there are big chances that some code will accept the returned result but will change behavior:
// before refactoring
function isAuthenticated(user: User) {
//...
return true;
// inferred type: boolean
}
if (isAuthenticated(user)) {
markItemAsPaid();
} else {
redirectToLogin();
}
// after refactoring
function isAuthenticated(user: User) {
//...
return someApiRequest(user);
// inferred type: Observable<boolean>
}
In this example, the compiler will not raise any errors: "Observable" is an object and if (isAuthenticated(user))
will work - but it will always return true.
It is a simple example, but with the more complicated code chances for this to happen are even higher.
Also, it significantly improves the readability of your code, and it’s quite an important metric, much more important than saving a few symbols to type.
Inheritance
Use the Composition over Inheritance principle.
Text by the link above explains things outside of the Angular context, I’ll explain why it’s not a good idea to use abstract classes and inheritance for the components and directives in Angular.
In addition to the usual issues that inheritance brings, inputs and outputs declared in the parent class will be inherited, so you’ll have to support them in child classes, even if you don’t need them in a particular child class.
Also, in the case of components, every Angular component should have a template. Here you have 2 ways:
- Link to parent’s template: you can not override anything, so the parent’s template will have a lot of branches to handle the needs and special cases of every child;
- Create a child’s own template: you’ll have to duplicate the whole template, and it decreases the code reusability — the initial reason for the inheritance.
As with other recommendations in this article, this one leads to some additional moves and thinking, but nothing good comes for free. This article is written in an attempt to help you, not argue or criticize: use as many recommendations from this article as you want ✌️
💙 If you enjoy my articles, consider following me on Twitter, and/or subscribing to receive my new articles by email.
🎩️ If you or your company is looking for an Angular consultant, you can purchase my consultations on Upwork.
Top comments (0)