DEV Community

loading...

Using fp-ts and io-ts: types and implementation

ruizb profile image Benoit Ruiz ・7 min read

The idea behind io-ts is to create a value with the type Type<A, Output, Input> (also called a "codec") that is the runtime representation of the static type A.

In other words, this codec allows to:

  • Parse/Deserialize an Input and validate that it's an A (e.g. parse an unknown and validate that it's a NonEmptyString50). This part is handled by the Decoder side of the codec.
  • Serialize an A into an Output (e.g. serialize an UnverifiedUser into a string). This part is handled by the Encoder side of the codec.

We are going to use only the first part, i.e. the Decoder, since we want to take values coming from outside our domain, validate them, then use them inside our business logic.

In this article, I am not going to use the experimental features. I'll use what is available with the following import as of v2.2.16:

import * as t from 'io-ts'
Enter fullscreen mode Exit fullscreen mode

When decoding an input, the codec returns an Either<ValidationError[], A>, which looks very similar to the Validation<A> type we wrote in the previous article of this series. Actually, the library exposes a Validation<A> type that is an alias to Either<ValidationError[], A>.

Previously, we defined the types then we wrote the implementation. Here, we are going to do the opposite: write the implementation, then derive the types from it using the TypeOf mapped type provided by io-ts.

First and last names

The equivalent of a "newtype" created with newtype-ts is a "branded type" in io-ts. We can use the t.brand function to create a codec for a branded type:

interface NonEmptyString50Brand {
  readonly NonEmptyString50: unique symbol
}

const NonEmptyString50 = t.brand(
  t.string,
  (s: string): s is t.Branded<string, NonEmptyString50Brand> => s.length > 0 && s.length <= 50,
  'NonEmptyString50'
)

type NonEmptyString50 = t.TypeOf<typeof NonEmptyString50>
Enter fullscreen mode Exit fullscreen mode

First we create the NonEmptyString50Brand brand. Next, we create the codec by providing 3 parameters:

  • The codec for the "underlying" type of the branded type (here, string)
  • The type guard function, or "refinement" function
  • The name of the codec (optional)

Let's look at the default error message reported for this codec when an invalid input is provided:

import { PathReporter } from 'io-ts/PathReporter'

PathReporter.report(NonEmptyString50.decode(42))
// ['Invalid value 42 supplied to : NonEmptyString50']
Enter fullscreen mode Exit fullscreen mode

If we keep the same logic regarding the errors handling as we did in the previous article, then this message is not particularly "user-friendly". We want a better description of the expected value (string whose size is between 1 and 50 chars). For that, we can use a little helper function provided by io-ts-types:

import { withMessage } from 'io-ts-types'

const FirstName = withMessage(
  NonEmptyString50,
  input => `First name value must be a string (size between 1 and 50 chars), got: ${input}`
)

const LastName = withMessage(
  NonEmptyString50,
  input => `Last name value must be a string (size between 1 and 50 chars), got: ${input}`
)
Enter fullscreen mode Exit fullscreen mode

Let's look at the error message reported:

import { PathReporter } from 'io-ts/PathReporter'

PathReporter.report(FirstName.decode(42))
// ['First name value must be a string (size between 1 and 50 chars), got: 42']
Enter fullscreen mode Exit fullscreen mode

We end up with the same error message we had in the previous article, with very little effort thanks to withMessage!

Email address

Nothing fancy here:

interface EmailAddressBrand {
  readonly EmailAddress: unique symbol
}

// https://stackoverflow.com/a/201378/5202773
const emailPattern = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/i
const EmailAddress = withMessage(
  t.brand(
    t.string,
    (s: string): s is t.Branded<string, EmailAddressBrand> => emailPattern.test(s),
    'EmailAddress'
  ),
  input => `Email address value must be a valid email address, got: ${input}`
)

type EmailAddress = t.TypeOf<typeof EmailAddress>
Enter fullscreen mode Exit fullscreen mode

Middle name initial

We need to create a Char codec:

interface CharBrand {
  readonly Char: unique symbol
}

const Char = t.brand(
  t.string,
  (s: string): s is t.Branded<string, CharBrand> => s.length === 1,
  'Char'
)

type Char = t.TypeOf<typeof Char>
Enter fullscreen mode Exit fullscreen mode

Then create a MiddleNameInitial codec from it:

import { optionFromNullable } from 'io-ts-types'

const MiddleNameInitial = withMessage(
  optionFromNullable(Char),
  input => `Middle name initial value must be a single character, got: ${input}`
)
Enter fullscreen mode Exit fullscreen mode

This is the same codec as Char, but we made it optional with the optionFromNullable helper, and we set a custom error message.

Remaining readings

The io-ts library provides a codec for integers, but not for positive integers like we had in newtype-ts. We need to create this type:

interface PositiveIntBrand {
  readonly PositiveInt: unique symbol
}

const PositiveInt = t.brand(
  t.Int,
  (n: t.Int): n is t.Branded<t.Int, PositiveIntBrand> => n >= 0,
  'PositiveInt'
)

type PositiveInt = t.TypeOf<typeof PositiveInt>
Enter fullscreen mode Exit fullscreen mode

As you noticed, we can create branded types from other branded types: t.Branded<t.Int, PositiveIntBrand>.

Let's define a RemainingReadings codec, which is a PositiveInt codec with a custom error message:

const RemainingReadings = withMessage(
  PositiveInt,
  input => `Remaining readings value must be a positive integer, got: ${input}`
)
Enter fullscreen mode Exit fullscreen mode

Verified date

Last but not least, we need a Timestamp codec for the verified date:

interface TimestampBrand {
  readonly Timestamp: unique symbol
}

const Timestamp = t.brand(
  t.Int,
  (t: t.Int): t is t.Branded<t.Int, TimestampBrand> => t >= -8640000000000000 && t <= 8640000000000000,
  'Timestamp'
)

type Timestamp = t.TypeOf<typeof Timestamp>
Enter fullscreen mode Exit fullscreen mode

The VerifiedDate codec is a Timestamp with a custom error message:

const VerifiedDate = withMessage(
  Timestamp,
  input =>
    `Timestamp value must be a valid timestamp (integer between -8640000000000000 and 8640000000000000), got: ${input}`
)
Enter fullscreen mode Exit fullscreen mode

User types

If you remember from the previous article, we wrote 2 intermediate types before getting a User: UserLike and UserLikePartiallyValid.

To create UserLike, we can do the following:

const UserLike = t.intersection([
  t.type({
    firstName: t.unknown,
    lastName: t.unknown,
    emailAddress: t.unknown
  }),
  t.partial({
    middleNameInitial: t.unknown,
    verifiedDate: t.unknown,
    remainingReadings: t.unknown
  })
])

type UserLike = t.TypeOf<typeof UserLike>
Enter fullscreen mode Exit fullscreen mode

The only way to make some properties of an object optional is to make the intersection between an object with required properties (type) and an object with all the optional properties (partial).

Next, we can use some codecs previously defined to build the UserLikePartiallyValid codec:

const UserLikePartiallyValid = t.strict({
  firstName: FirstName,
  lastName: LastName,
  emailAddress: EmailAddress,
  middleNameInitial: MiddleNameInitial
})

type UserLikePartiallyValid = t.TypeOf<typeof UserLikePartiallyValid>
Enter fullscreen mode Exit fullscreen mode

I used strict here (as opposed to type) to make sure any extra property of the input is discarded from a UserLikePartiallyValid data object.

Now we can write both UnverifiedUser and VerifiedUser codecs.

const UntaggedUnverifiedUser = t.intersection(
  [
    UserLikePartiallyValid,
    t.strict({
      remainingReadings: RemainingReadings
    })
  ],
  'UntaggedUnverifiedUser'
)

type UntaggedUnverifiedUser = t.TypeOf<typeof UntaggedUnverifiedUser>

type UnverifiedUser = UntaggedUnverifiedUser & { readonly type: 'UnverifiedUser' }
Enter fullscreen mode Exit fullscreen mode

We first build an UntaggedUnverifiedUser because we don't want to include the validation of the type property that is used only to create the User sum type in TypeScript. Then, we create the UnverifiedUser type by adding the type property.

Notice that it's only a type definition, there's no codec associated because there's no need to validate external data: we (the developers) are the ones adding the type property via the constructor functions (defined a bit later).

We can do the same for the UntaggedVerifiedUser codec:

const UntaggedVerifiedUser = t.intersection(
  [
    UserLikePartiallyValid,
    t.strict({
      verifiedDate: VerifiedDate
    })
  ],
  'UntaggedVerifiedUser'
)

type UntaggedVerifiedUser = t.TypeOf<typeof UntaggedVerifiedUser>

type VerifiedUser = UntaggedVerifiedUser & { readonly type: 'VerifiedUser' }
Enter fullscreen mode Exit fullscreen mode

Now that we have both UnverifiedUser and VerifiedUser types, we can create the User type simply with:

type User = UnverifiedUser | VerifiedUser
Enter fullscreen mode Exit fullscreen mode

And the constructor functions:

const unverifiedUser = (fields: UntaggedUnverifiedUser): User => ({ ...fields, type: 'UnverifiedUser' })

const verifiedUser = (fields: UntaggedVerifiedUser): User => ({ ...fields, type: 'VerifiedUser' })
Enter fullscreen mode Exit fullscreen mode

There's one last function we need before (finally) writing the parseUser function. We need to detect if a user-like object looks like a verified user or not. In the previous article, we wrote the detectUserVerification function. Here, we are going to write a similar function, but instead of taking a UserLikePartiallyValid input, it will take a UserLike input:

const detectUserType = <A>({
  onUnverified,
  onVerified
}: {
  onUnverified: (userLikeObject: UserLike) => A
  onVerified: (userLikeObject: UserLike & { verifiedDate: unknown }) => A
}) => ({ verifiedDate, ...rest }: UserLike): A =>
  pipe(
    O.fromNullable(verifiedDate),
    O.fold(
      () => onUnverified(rest),
      verifiedDate => onVerified({ ...rest, verifiedDate })
    )
  )
Enter fullscreen mode Exit fullscreen mode

This is because we are going to use the decoders of either UntaggedUnverifiedUser or UntaggedVerifiedUser codecs that already contain the validation steps for a UserLikePartiallyValid object:

const parseUser: (input: unknown) => t.Validation<User> = flow(
  UserLike.decode,
  E.chain(
    detectUserType({
      onUnverified: flow(UntaggedUnverifiedUser.decode, E.map(unverifiedUser)),
      onVerified:   flow(UntaggedVerifiedUser.decode,   E.map(verifiedUser))
    })
  )
)
Enter fullscreen mode Exit fullscreen mode

And that's it! The logic for parseUser is slightly different compared to the one we wrote in the previous article, but overall it looks very similar. And, we wrote fewer lines of code for the same result, which is nice (fewer lines = less chances for a bug to be introduced).

The source code is available on the ruizb/domain-modeling-ts GitHub repository.


The io-ts library allows us to create codecs that we can combine to build even more complex codecs.

One key difference with the previous method is that the type definition for User is not clearly readable for the developers without relying on IntelliSense, and even then, it doesn't show the whole type definition:

// examples of types displayed with IntelliSense

type UserLikePartiallyValid = t.TypeOf<typeof UserLikePartiallyValid>
/*
type UserLikePartiallyValid = {
  firstName: t.Branded<string, NonEmptyString50Brand>;
  lastName: t.Branded<string, NonEmptyString50Brand>;
  emailAddress: t.Branded<...>;
  middleNameInitial: O.Option<...>;
}
*/

type UnverifiedUser = UntaggedUnverifiedUser & { readonly type: 'UnverifiedUser' }
/*
type UnverifiedUser = {
  firstName: t.Branded<string, NonEmptyString50Brand>;
  lastName: t.Branded<string, NonEmptyString50Brand>;
  emailAddress: t.Branded<...>;
  middleNameInitial: O.Option<...>;
} & {
  ...;
} & {
  ...;
}
*/
Enter fullscreen mode Exit fullscreen mode

There is an open issue to address this problem in the VS Code editor.

To solve this, we could've first defined the type like we did in the 3rd article of this series, then use io-ts for the implementation part only, and not use TypeOf to define the types of users.

Final thoughts

We used these codecs to validate data coming from the external world to use them in our domain. We can safely use these data in the functions holding the business logic, at the core of the project. We didn't write these functions in this series though, I chose to focus on the "let valid data enter our domain" part.

If you are familiar with the "onion architecture" (or "ports and adapters architecture") then these codecs take place in the circle wrapping the most-inner one that has the business logic.

This approach allows us to document the code easily by describing and enforcing domain constraints and logic at the type level.

I hope I convinced you to try Domain Driven Design in TypeScript using the fp-ts ecosystem!

Discussion (0)

pic
Editor guide