DEV Community

loading...

Using fp-ts and newtype-ts: types

ruizb profile image Benoit Ruiz ・5 min read

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
}
Enter fullscreen mode Exit fullscreen mode

What are the constraints?

Here are the constraints that are not reflected in the initial type:

  1. Both first and last names must be strings with at least 1 character, and at most 50 characters.
  2. The email address must a string containing a... valid email address.
  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.
  4. The remaining readings must be a positive integer (zero accepted).
  5. 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>
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
>
Enter fullscreen mode Exit fullscreen mode

We end up with the following User type:

interface User {
  firstName: NonEmptyString50
  lastName: NonEmptyString50
  emailAddress: EmailAddress
  middleNameInitial: Option<Char>
  remainingReadings?: PositiveInteger
  verifiedDate?: Timestamp
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!

Discussion (0)

pic
Editor guide