One of the basics of the Object-Oriented programming is Encapsulation, which aims to never let our domain models enter an invalid state.
Think about the peace of mind when persisting data or using it for any processing, if you can be absolutely sure that is is valid ๐
In this post, we're gonna look into how to achieve that with TypeScript in two easy steps.
Step 1: run away from the Primitive Obsession
It is easy to create utility functions to take care of validation and use them throughout the project.
If you're in a small project, that's fine.
But, in a big project, it is easy to get lost in so many utility functions, because you soon have tenths of them, without any apparent relation between them.
For instance, imagine that you need to handle VAT IDs, so you're gonna have validateVatId
and formatVatId
. But you also need to handle emails, so you're gonna have validateEmail
as well, and so on.
Besides that, we can have several models, workflows and integrations using these data types, and we are gonna need those validate and format functions everywhere.
It works, but it gets messy real soon, and it doesn't make any sense through the eyes of Object Oriented programming, which wants you to group logically related concepts -- surprise, surprise! -- under a single object.
But the solution to this problem is actually simple: create classes (objects) to represent these data types, and let these classes take responsibility for doing all the work releated to the data type in question. Have a look:
class VatId {
static readonly PATTERN = /(\d{3})\.?(\d{3})\.?(\d{3})-?(\d{2})/;
static readonly FORMAT = "$1.$2.$3.$4";
private _value!: string;
constructor(value: string) {
this.value = value;
}
get value(): string {
return this._value;
}
private set value(value: string) {
if (!value?.length)
throw new Error("The VAT ID is mandatory.");
if (!PATTERN.test(value))
throw new Error("The specified VAT ID is invalid.");
this._value = value.replace(PATTERN, FORMAT);
}
}
Now, we can stop accepting strings in classes which need to handle VAT IDs and start accepting the VatId itself.
class Person {
constructor(public vatId: VatId) { }
}
Everytime we create an instance of Person
, we can now be sure that the VAT ID is valid.
Let's remind ourselves that every VAT ID is a string, but not every string is a VAT ID. The same goes for emails and many other data types that we need to process every day.
The idea of avoiding Primitive Types is to exchange types such as string, number, boolean, array, etc., for classes that encapsulate all the business rules related to that data type.
Step 2: Enclose all business rules inside classes
This tip is not limited to strings. You can create classes to represent geographic coordinates (lat/long), money, percentage... and so on.
Money is nice, so let's use it as an example ๐ If we're developing an API that processes orders from different countries, then Money needs to be represented as a Currency-Amount pair.
Furthermore, if we need to apply discounts and perform simple operations such as add and subtract, of course we can't do these for different currencies.
Instead of having if
s and else
s sprinkled throughout the codebase, Object Oriented programming and Encapsulation offer a solution once more:
class Money {
private _currency!: string;
private _amount: number;
constructor(currency: string, amount: number) {
this.currency = currency;
this._amount = amount;
}
get currency(): string {
return this._currency;
}
private set currency(value: string) {
if (!value?.length)
throw new Error("Currency is mandatory.");
if (!/[a-z]{3}/i.test(value))
throw new Error("Currency must conform to ISO 4217");
this._currency = value.toUpperCase();
}
get amount(): number {
return this._amount;
}
add(other: Money): Money {
this.throwIfDifferentCurrency(other);
return new Money(this.currency, this.amount + other.amount);
}
subtract(other: Money): Money {
this.throwIfDifferentCurrency(other);
return new Money(this.currency, this.amount - other.amount);
}
applyDiscount(percent: number): Money {
this.throwIfDifferentCurrency(other);
return new Money(this.currency, this.amount * percent);
}
toString() {
return this._currency + " " + this.amount;
}
private throwIfDifferentCurrency(other: Money) {
if (this._currency !== other.currency)
throw new Error("Can't do math with different currencies.");
}
}
See how we no longer need to fill the "helpers" folder with functions to validate the currency and perform monetary operations?
What we also got for free is an object that represents the concept of Money in its entirety, as far as our API is concerned.
When we have new requirements, they'll have a clear destination: they'll go straight into the Money
class, which is now the owner of all "Money" rules in our API.
Now, read this post if you wanna write simple, yet effective, validation rules:
Important details
Now, let's dive into some aspects of this implementation ๐
Values are always valid: every property that has some business rule associated to it, such as currency
, has get
and set
methods defined for them, and the set
implementation guarantees that the value is always valid; otherwise, it throws an exception. YOU SHALL NOT PASS!! ๐งโโ๏ธ
Immutability: instances are immutable, readonly. In other words, the properties have get
but not set
, or set
is private. The add
and subtract
methods create a new instance with the new value. This is a huge plus for big projects, which I'm gonna explain in another post. Meanwhile, you can learn more here: why immutability is so important in javascript.
The idea of immutability will not work for every class in your project. Generally speaking, it works best for ValueObjects. But if you can manage to have immutability for 80 to 90% of your code base, things get so much easier.
๐ BONUS: the importance of encapsulation
We can make a direct analogy between Object Oriented Encapsulation and the capsule of some medicine.
You know when the doctor gives you a prescription for a medicine that you must have it taylor-made for you?
What if it was up to you to buy the ingredients and mix'em up all by yourself! If things were like this, of course we would know about people getting it wrong and having problems all the time.
Us, developers, we have to be aware that "user" is not just someone who clicks a button. Our coworkers, who use and help maintain our code, they are also users of every line of code we write.
When we take on the task of developing a feature, it is our responsibility to understand everything we can about the subject, run the extra mile to enrich the checks we bake into the code, and encapsulate everything into concise, well though-out components.
This way we can deliver an out-of-the-shelf medicine for each of the problems we face daily in a project, just as we buy some Migravent to make short work of headaches, without having to know what's inside and how a headache happens (I don't get paid to advertise this medicine, lol).