When we start coding our first apps with the clean architecture, we quickly figure out how important the Domain is since every use-case of our application use it.
It comes necessary to know how to define and validate it in order to enforce the business rules of our application and even making illegal states unrepresentable.
In this article, we will see how Typescript and the Runtypes library can help us to write a precise and auto-documented Domain which benefit from almost instant unit tests thanks to validators.
The simplest approach to define your entities with Typescript may look to this:
++pre>++code class="has-line-data" data-line-start="6" data-line-end="8">type User = { id: string tag: string email: string password: string};
++/code>++/pre>
Or even better:
++pre>++code class="has-line-data" data-line-start="6" data-line-end="11">type ID = int
type ValidEmail = string
type HashedPassword = string
type User = {
id: ID,
email: ValidEmail,
password: HashedPassword
};
++/code>++/pre>
The second approach, allows us to define a richer domain.
The richness of a Domain is the number of business rules it tells and enforces by design.
Here, we tell to the readers of this code that a user has a unique identifier, an email which have been validated and a password which have been hashed.
Those are the specifications of our application, and we are already informed that we will need few processes to
The domain drives the design of our application !
But, there is still a problem, Typescript do not know what is the difference between an email and a password since they are both just strings ? so this allows us to mix them up and make a mistake !
Let's see it in the next example, I will freely mix email and password:
++pre>++code class="has-line-data" data-line-start="6" data-line-end="15">let id: ID = 1
let email: ValidEmail = 'dummy@gmail.com'
let password: HashedPassword = 'passw0rd!'
let myUser: User = {
id: id,
// MISTAKE HERE
email: password,
password: email
}
++/code>++/pre>
You can copy-paste this code in the typescript playground and see by yourself that Typescript is totally ok with that, which is highly error-prone and something we want to avoid.
But how can we avoid this ? Is there a way to tell to Typescript my email is a string and also an email ?
There is a way, and it's called branded types. They are like the Typescript primitives types (string, number, boolean?) but with a "brand" which allow him to make the difference between a string "Email" and a string "Password". So, we should be able to enforce one of our specification: "An email is a string, but a string is not necessarily an email".
Let's find out how to do so !
The Runtypes library provides us "run types": String, Boolean, Number etc. which all are of type ++code>RunType<T>++/code>.
On those, you can actually call the ++code>Runtype<T>.withBrand()++/code> method which accept a string as an argument: the brand of our type.
This turns all our previous code into:
++pre>++code class="has-line-data" data-line-start="6" data-line-end="20">import { Static, String, Number, Record } from 'runtypes';
const ID = Number.withBrand('ID');
type ID = Static<typeof ID>;
const Email = String.withBrand('Email');
type Email = Static<typeof Email>;
const HashedPassword = String.withBrand('HashedPassword')
type HashedPassword = Static<typeof HashedPassword>;
const User = Record({ id: ID, email: Email, password: HashedPassword });
export type User = Static<typeof User>;
++/code>++/pre>
When you create branded types, you need to distinguish the validator from the type.
For each example, we firstly define the validator: a runtype with a brand, and then we create its static type in order to "convert" runtypes to TS types.
If you hover the Email type, typescript, we'll identify it as:
++pre>++code>type Email = string & RuntypeBrand<"Email">.++/code>++/pre>
Our Email is no longer just a string ! It also as the brand "Email".
Now Typescript gives us compile-time type checking: an Email is a string but also an "Email".
If you try to assign a string to our Email branded type, you'll find out that:
++pre>++code>//KO
let myEmail: Email = 'dummy@gmail.com'++/code>++/pre>++pre>++code>Type 'string' is not assignable to type 'string & RuntypeBrand<"Email">'.++/code>++/pre>
We have to create those emails thanks to the static method Runtype<T>.check().
++pre>++code>//OK
let myEmail: Email = Email.check('dummy@gmail.com')++/code>++/pre>
The Runtype<T>.check() method will check if the argument comply with the constraints we defined on our Runtype: for the moment, we don't have any.
Now, with our branded Domain, we are avoiding all mixing errors while auto-documenting our application.
But, an email should conform to some specifications, a regex for example.
The Runtypes library comes with a method Runtype<T>.withConstraint() that let us apply as many constraints as we want on our branded types.
Let's constraint our email with a regex.
To ensure that our Emails are valid ones, we can now specify the constraint they should conform to:
++pre>++code class="has-line-data" data-line-start="6" data-line-end="15">import { Static, String } from 'runtypes';
const EmailRegex =
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
export const Email = String.withBrand('Email').withConstraint((maybeEmail) => EmailRegex.test(maybeEmail) || 'Invalid email');
export type Email = Static<typeof Email>;++/code>++/pre>
And we create it:
++pre>++code>let myEmail: Email = Email.check('dummy@gmail.com')++/code>++/pre>
When this piece of code will execute, the string ++code>'dummy@gmail.com'++/code> will go through all the defined constraints in the Email RunType and return the email value otherwise throw the defined exception:++code>'Invalid email'.
++/code>
Since we always need to check the incoming values to create our entities, the Domain is protected from any invalid data !
The specifications of our app tells us that each user should have a "HashedPassword".
It infers that the password comes from another type: PlainPassword.
A plain password should respect some constraint to be a valid one, for example:
Let's do it with the method ++code>Runtype<T>.withConstraint()++/code>:
++pre>++code>import { Static, String } from 'runtypes';
const AtLeastOneLowerLetterRegex = /(?=.*[a-z])/;
const AtLeastOneUpperLetterRegex = /(?=.*[A-Z])/;
const AtLeastOneDigitRegex = /\d/;
export const PlainPassword = String.withBrand('PlainPassword')
.withConstraint((maybeValidPassword) => maybeValidPassword.length >= 8 || 'Password should contain at least 8 characters' )
.withConstraint((maybeValidPassword) => AtLeastOneLowerLetterRegex.test(maybeValidPassword) || 'Password should contain at least one lower' )
.withConstraint((maybeValidPassword) => AtLeastOneUpperLetterRegex.test(maybeValidPassword) || 'Password should contain at least one upper letter' )
.withConstraint((maybeValidPassword) => AtLeastOneDigitRegex.test(maybeValidPassword) || 'Password should contain at least one digit' );
export type PlainPassword = Static<typeof PlainPassword>;++/code>++/pre>
You can chain as many constraints as needed.
Our type PlainPassword is protected from any data that don't respect those rules.
Having our specifications as close as possible to our Domain objects drastically increase the discovery, readability, and maintenance of our code.
From my experience, having a well-defined Domain with expressive types which encapsulate their own specifications increase the quality of my apps, avoiding the mix-up mistakes and keeps me away from an Anemic Domain Model.
If you want to see the concepts we applied in this article, feel free to have a look at this repository.
Defining its Domain with types is usually done in functional programming. If you are new to it, you can have a look at this video and make your first steps into with this free training book.
I also highly recommend you to have a look at the video DDD Made functionnal from Scott Wlaschin which inspired this article.
++code>++/code>
++code>++/code>
++code>++/code>