As a reminder, here's the initial type definition from our domain that we want to improve:
interface User {
firstName: string
lastName: string
emailAddress: string
middleNameInitial?: string
remainingReadings?: number
verifiedDate?: number
}
What are the constraints?
Here are the constraints that are not reflected in the initial type:
- Both first and last names must be strings with at least 1 character, and at most 50 characters.
- The email address must a string containing a... valid email address.
- The middle name initial is optional: it may not be provided, but if it is, it must be a string containing a single character.
- The remaining readings must be a positive integer (zero accepted).
- The verified date must be a timestamp value.
Let's improve the initial type by supporting each constraint, step by step.
In this article, we're only focusing on the type definitions. We will write the constructor functions and validation functions in the next article of this series. These functions will ensure that the types we are defining here are reflected at runtime as well.
1. Both first and last names must be strings with at least 1 character, and at most 50 characters.
We need a type that is valid only when the value provided is a non-empty string that has less than 51 characters.
We are going to use newtype-ts to build a branded type:
import { Newtype, Concat } from 'newtype-ts'
import { NonEmptyString } from 'newtype-ts/lib/NonEmptyString'
type String50 = Newtype<{ readonly String50: unique symbol }, string>
type NonEmptyString50 = Concat<String50, NonEmptyString>
First, we are declaring a String50
branded type (or newtype) that is the combination of a brand ({ readonly String50: unique symbol }
) and a primitive type (string
).
Here, we are branding a string
in order to get a meaningful subset of strings. The type String50
represents the set of strings that have less than 51 characters.
At this point, the empty string value is still allowed in this set. To exclude it, we can use the NonEmptyString
newtype provided by newtype-ts
and "combine" it with our String50
, thus ending up with the NonEmptyString50
type.
We can now update our initial "User" definition:
interface User {
firstName: NonEmptyString50 // constraints are clear now
lastName: NonEmptyString50
emailAddress: string
middleNameInitial?: string
remainingReadings?: number
verifiedDate?: number
}
2. The email address must a string containing a... valid email address.
This one is simpler.
import { Newtype } from 'newtype-ts'
type EmailAddress = Newtype<{ readonly EmailAddress: unique symbol }, string>
We could combine it with NonEmptyString
, but it doesn't bring much value. We know an empty string is not a valid email address, there's no need to support this special case in the type definition.
We can update the User
type:
interface User {
firstName: NonEmptyString50
lastName: NonEmptyString50
emailAddress: EmailAddress
middleNameInitial?: string
remainingReadings?: number
verifiedDate?: number
}
3. The middle name initial is optional: it may not be provided, but if it is, it must be a string containing a single character.
There are 2 constraints here:
- The value is optional
- If provided, the value must be a single character
There's no native "Character" type in TypeScript (or JavaScript), so we need a newtype for that. Thankfully, newtype-ts
already provides such type: Char
.
We could use an optional property for the middleNameInitial
field:
import { Char } from 'newtype-ts/lib/Char'
interface User {
firstName: NonEmptyString50
lastName: NonEmptyString50
emailAddress: EmailAddress
middleNameInitial?: Char // <- notice the "?"
remainingReadings?: number
verifiedDate?: number
}
But this means we'll have to make runtime checks to make sure the property is set or not. In the fp-ts
ecosystem, there's a data type we can use to handle "values that may not exist": Option
.
import { Option } from 'fp-ts/Option'
interface User {
firstName: NonEmptyString50
lastName: NonEmptyString50
emailAddress: EmailAddress
middleNameInitial: Option<Char>
remainingReadings?: number
verifiedDate?: number
}
Now, the property is required, but we know the value may not exist because we are using the Option
type.
4. The remaining readings must be a positive integer (zero accepted).
We're almost done with the types! Here, we need a "positive integer" that can be zero. Again, newtype-ts
already provides a type for us: PositiveInteger
.
import { PositiveInteger } from 'newtype-ts/lib/PositiveInteger'
interface User {
firstName: NonEmptyString50
lastName: NonEmptyString50
emailAddress: EmailAddress
middleNameInitial: Option<Char>
remainingReadings?: PositiveInteger
verifiedDate?: number
}
We could go even further by defining a subset of PositiveInteger
that represents the positive integers that are below a certain value. In our example, the maximum value is 3. There are 2 ways to define this subset:
// combining 2 newtypes, like we did with NonEmptyString50
type RemainingReadings = Concat<
Newtype<{ readonly NumberBelow3: unique symbol }, number>,
PositiveInteger
>
// or using a union of literal types,
// since there are not many values allowed
type RemainingReadings = 0 | 1 | 2 | 3
Here, I chose to keep PositiveInteger
, but it's up to you if you want to go further. If you do, I'd recommend using the union of literal types, since it's simpler to write and read/understand.
The challenge when defining these types is to find a good balance to get the "this type carries enough domain information, without going too far" feeling. Using PositiveInteger
allows us to up the limit to 4 or 5 in the future without changing the code, but it also opens the possibility of having 1000 remaining readings, which could be a bug (or perhaps it's a hidden feature?).
5. The verified date must be a timestamp value.
Last but not least, a timestamp is an integer that is comprised between -8640000000000000 and 8640000000000000.
import { Newtype, Concat } from 'newtype-ts'
import { Integer } from 'newtype-ts/lib/Integer'
type Timestamp = Concat<
Newtype<{ readonly TimestampNumber: unique symbol }, number>,
Integer
>
We end up with the following User
type:
interface User {
firstName: NonEmptyString50
lastName: NonEmptyString50
emailAddress: EmailAddress
middleNameInitial: Option<Char>
remainingReadings?: PositiveInteger
verifiedDate?: Timestamp
}
We can understand the domain constraints by reading this type. We still don't know what's going on with remainingReadings
and verifiedDate
though: can they both be defined? Or none of them? What's the domain logic regarding these properties?
What is the logic?
The domain logic in this case study is the following:
- While users are not verified yet, they are limited in the number of articles they can read.
- Once verified, they can read as many articles as they want.
This means a User
is either unverified or verified. It cannot be both, or none. Does this ring a bell? That's right, we can use a sum type to model the different types of users in our domain!
interface UnverifiedUser {
readonly type: 'UnverifiedUser'
readonly firstName: NonEmptyString50
readonly lastName: NonEmptyString50
readonly emailAddress: EmailAddress
readonly middleNameInitial: Option<Char>
readonly remainingReadings: PositiveInteger
}
interface VerifiedUser extends Omit<UnverifiedUser, 'type' | 'remainingReadings'> {
readonly type: 'VerifiedUser'
readonly verifiedDate: Timestamp
}
type User = UnverifiedUser | VerifiedUser
I made all the properties "read-only" since I think it's a good practice. This forces us to make copies of objects instead of changing the objects directly (immutability at the type level if you will).
Now we know that unverified users are limited in their readings, since the UnverifiedUser
type has a remainingReadings
property. We also know that verified users have a verification date (the verifiedDate
property) and don't have any readings limitation, because remainingReadings
is missing in VerifiedUser
.
The source code is available on the ruizb/domain-modeling-ts GitHub repository.
So, we've clearly defined our model constraints and logic in the type definition. We should be able to understand what the domain does (or at least get a pretty good idea) only by reading the type. We don't have to look at the code implementation to understand it, which is what we were looking for.
However, we still need some runtime implementation to actually do something. We have to write the code that makes sure the data we receive from outside (the API of a web service) is valid in our domain. This is what we are going to do in the next article!
Top comments (0)