DEV Community

Benoit Ruiz
Benoit Ruiz

Posted on

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

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!

Top comments (6)

Collapse
 
mrdulin profile image
official_dulin

Nice! I found I have met the issue "what's the constraints of each property of the TS type?" Sometimes, the type have different state, like your said, the sum type is the key to handle this situation.

But, the intuitive feeling of using newType-ts and io-ts is that the amount of code becomes more and the complexity of the type increases. What do you think?

Collapse
 
ruizb profile image
Benoit Ruiz

Hello! You are right, the complexity of the project increases with this approach. But as always, it's a matter of tradeoff:

  • Do we agree to increase complexity, but have a self-documented and safer code?
  • Or are we fine with simpler types, but with lots of comment lines to explain their constraints?
  • Or no comment at all, but then we have to look at the runtime code parts scattered everywhere in the project to understand all the constraints?

Personally, as a developer, I would rather have all my answers directly in the type definitions. It would save me time as I wouldn't have to follow all the code paths that use such type to understand its scope, and it would also help me write fewer unit tests overall. But, as you said, it comes at a price: adding some abstraction/library to allow us to write types this way.

In this series, I've used the fp-ts ecosystem, but you may also use another library that does schema validation. The ultimate goal is to guard any input that travels to the core of your application where lies the business logic, and define types that provide as much information as possible, including the constraints. If you can reach this goal with the simplest code possible, then you have won!

Hopefully I answered your question correctly :)

Collapse
 
florianbepunkt profile image
Florian Bischoff

Loved this series. Regarding your final thoughts: Is using a combination of new-types and io-ts something you would recommend? I gave it a go but I had to manually cast the type:

type String60 = Newtype<{ readonly String60: unique symbol }, string>;
type NonEmptyString60 = Concat<String60, NonEmptyString>;

const NonEmptyString60 = new t.Type<NonEmptyString60, string, unknown>(
  'string',
  (input: unknown): input is NonEmptyString60 => typeof input === 'string' && input.length <= 60,
  (input, context) =>
    typeof input === 'string' && input.length <= 60
      ? t.success(input as unknown as NonEmptyString60)
      : t.failure(input, context),
  String,
);
Enter fullscreen mode Exit fullscreen mode
Collapse
 
ruizb profile image
Benoit Ruiz

Hello, thank you for your feedback!

If you want to avoid using a type assertion there, you can create a predicate function:

const isNonEmptyString60 = (input: unknown): input is NonEmptyString60 =>
  t.string.is(input) && isNonEmptyString(input) && input.length <= 60
Enter fullscreen mode Exit fullscreen mode

And use it both for the type guard and validate functions of your codec:

const NonEmptyString60 = new t.Type<NonEmptyString60, string, unknown>(
  'string',
  isNonEmptyString60,
  (input, context) => (isNonEmptyString60(input) ? t.success(input) : t.failure(input, context)),
  String
)
Enter fullscreen mode Exit fullscreen mode

Since everything newtypes-ts does is also possible with branded types in io-ts, I'd only use io-ts. The only downside I see is that newtypes-ts exposes convenient types (such as Integer, Char or NonEmptyString) that must be reimplemented using t.Branded.

(there's actually a NonEmptyString codec and a fromNewtype function in the io-ts-types package, but I couldn't manage to make this function work. I believe it uses the newer io-ts APIs, which are still marked as experimental as of today. Feel free to give it a try though :) )


On a side note, I'd use t.brand instead of defining a new t.Type, as it requires less boilerplate.

If you are working with newtypes, you'll have to convert them into Branded types. Indeed, both of these types are not defined in the same way, so they don't interoperate very well. To manage that, we could use some "adapter" to transform a Newtype into a Branded:

// Convert a Newtype<A, B> into a Branded<B, A>
type ToBranded<A> = A extends Newtype<infer Brand, infer UnderlyingType>
  ? t.Branded<UnderlyingType, Brand>
  : never

// extract the brand B from a Branded<A, B>, used to define codecs type guards
type GetBrand<A> = A extends t.Brand<infer Brand> ? Brand : never
Enter fullscreen mode Exit fullscreen mode

Now we can do the following:

import * as NES from 'newtype-ts/lib/NonEmptyString'

type NonEmptyString = ToBranded<NES.NonEmptyString>
const NonEmptyString = t.brand(
  t.string,
  (s: string): s is t.Branded<string, GetBrand<NonEmptyString>> => s.length > 0,
  'NonEmptyString'
)

type String60 = ToBranded<Newtype<{ readonly String60: unique symbol }, string>>
const String60 = t.brand(
  t.string,
  (s: string): s is t.Branded<string, GetBrand<String60>> => s.length <= 60,
  'String60'
)

const NonEmptyString60 = t.intersection([NonEmptyString, String60])
type NonEmptyString60 = t.TypeOf<typeof NonEmptyString60>
// or: type NonEmptyString60 = NonEmptyString & String60
Enter fullscreen mode Exit fullscreen mode

Note: we could also directly define Branded types and completely discard Newtype:

type String60 = t.Branded<string, { readonly String60: unique symbol }>
type NonEmptyString = t.Branded<string, { readonly NonEmptyString: unique symbol }>
type NonEmptyString60 = t.Branded<string, String60 & NonEmptyString>
Enter fullscreen mode Exit fullscreen mode

Hope that helps :)

Collapse
 
artemkislov profile image
Artem Kislov

Great series! Thanks a lot!

Collapse
 
ruizb profile image
Benoit Ruiz

I'm glad you enjoyed it!