DEV Community

loading...

Using fp-ts and newtype-ts: implementation

ruizb profile image Benoit Ruiz ・9 min read

Here's the final type definition we got in the previous article:

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

In this article, we are going to write the function that takes some unknown data as input, and returns either a list of error messages or a User as output.

For each new type we defined previously, we'll write a type guard function and a "validation" function (we'll call it a parser), that will use the type guard to return either an error message or a value with the correct type.

Finally, we'll compose all these functions to create the User data object if the input is valid, or get a list of error messages otherwise.

The objective is to write the implementation of the following function:

declare function parseUser(input: unknown): Either<string[], User>
Enter fullscreen mode Exit fullscreen mode

Type guards and parsers

The parsers are functions that take an unknown value and return either a list of error messages, or a validated value for the domain.

import { NonEmptyArray } from 'fp-ts/NonEmptyArray'
import { Either } from 'fp-ts/Either'

type Validation<A> = Either<NonEmptyArray<string>, A>

type Parser<A> = (value: unknown) => Validation<A>
Enter fullscreen mode Exit fullscreen mode

I chose to use a NonEmptyArray for the list of error messages because the list cannot be empty. If the list is empty then it means there is no error, so the "right" side of the Either data type should be used, and not the "left" one that holds the list of errors.

We want a bunch of Validation<A> objects that we are going to combine in some way to create a Validation<User>.

First and last names

Let's write a type guard function to validate that an unknown value is actually a NonEmptyString50:

import { isNonEmptyString } from 'newtype-ts/lib/NonEmptyString'

function isNonEmptyString50(s: unknown): s is NonEmptyString50 {
  return typeof s === 'string' && isNonEmptyString(s) && s.length <= 50
}
Enter fullscreen mode Exit fullscreen mode

First we make sure that the value provided is a string. Then, we use the isNonEmptyString type guard provided by newtype-ts to make sure the string is not empty. Finally, we make sure that its size is lower than 51 characters.

Let's use this type guard to build a Parser<NonEmptyString50> for the first and last names:

import * as E from 'fp-ts/Either'
import * as NEA from 'fp-ts/NonEmptyArray'

const parseName = (label: string) =>
  E.fromPredicate(
    isNonEmptyString50,
    invalidValue => NEA.of(`${label} value must be a string (size between 1 and 50 chars), got: ${invalidValue}`)
  )

const parseFirstName: Parser<NonEmptyString50> = parseName('First name')

const parseLastName:  Parser<NonEmptyString50> = parseName('Last name')
Enter fullscreen mode Exit fullscreen mode

I chose to provide a human-readable error message as a string. I could've used an object instead, something such as:

enum ErrorType {
  InvalidNonEmptyString50
}

const parseName = (label: string) =>
  E.fromPredicate(
    isNonEmptyString50,
    invalidValue => NEA.of({
      errorType: ErrorType.InvalidNonEmptyString50,
      value: invalidValue
    })
  )
Enter fullscreen mode Exit fullscreen mode

Then, let the caller use this object to build a human-readable (with localization support?) error message. Here, I favored simplicity and directly set the error message in the Validation<NonEmptyString50> data type.

Email address

function isEmailAddress(s: unknown): s is EmailAddress {
  // 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
  return typeof s === 'string' && emailPattern.test(s)
}
Enter fullscreen mode Exit fullscreen mode

Nothing extraordinary here (besides this beautiful regular expression). We make sure the value is a string that has a certain pattern. Let's write the associated parser function:

const parseEmailAddress: Parser<EmailAddress> =
  E.fromPredicate(
    isEmailAddress,
    invalidValue => NEA.of(`Email address value must be a valid email address, got: ${invalidValue}`)
  )
Enter fullscreen mode Exit fullscreen mode

Middle name initial

The newtype-ts library already provides a type guard to validate a single character: isChar. Sadly, there is no type predicate in the current version (v0.3.4) of this function, so we'll have to make a type assertion to tell TypeScript that it's indeed a Char if the function returns true, and not just a regular string.

This parser is a bit more complicated than the previous ones because the middle name initial is an optional property. We want to write a Parser<Option<Char>>.

First we create an Option<string> from an unknown value. Next, we need to make sure this string has only 1 character, by using the isChar type guard. We could use Option.map in order to access the string value that we need to validate into a Char:

import * as O from 'fp-ts/Option'
import { isChar } from 'newtype-ts/lib/Char'

const parseMiddleNameInitial: O.Option<Parser<Char>> = flow(
  O.fromPredicate((s: unknown): s is string => typeof s === 'string'),
  O.map(E.fromPredicate(
    isChar,
    invalidValue => NEA.of(`Middle name initial value must be a single character, got: ${invalidValue}`)
  ))
) as O.Option<Parser<Char>>
Enter fullscreen mode Exit fullscreen mode

The problem here is that we end up with a Option<Parser<Char>>, but we want a Parser<Option<Char>>. To invert the order while still applying the E.fromPredicate function, we can use traverse:

const parseMiddleNameInitial: Parser<O.Option<Char>> = flow(
  O.fromPredicate((s: unknown): s is string => typeof s === 'string'),
  O.traverse(E.either)(
    E.fromPredicate(
      isChar,
      invalidValue => NEA.of(`Middle name initial value must be a single character, got: ${invalidValue}`)
    )
  )
) as Parser<O.Option<Char>>
Enter fullscreen mode Exit fullscreen mode

Some data types are not traversable (e.g. IO), so the traverse function may not be available. Here, Option is traversable and E.either is an instance of Applicative, so we can use it to transform an Option<Either<string>> into a Parser<Option<Char>>.

Remaining readings

This one needs 2 validation steps:

  • First we need to make sure the value is a number,
  • Then check that this number is actually a PositiveInteger. We can do that by using the type guard provided by newtype-ts.
import { isPositiveInteger } from 'newtype-ts/lib/PositiveInteger'

const parseRemainingReadings: Parser<PositiveInteger> = flow(
  E.fromPredicate(
    (n: unknown): n is number => typeof n === 'number',
    invalidValue => NEA.of(`Remaining readings value must be a number, got: ${invalidValue}`)
  ),
  E.chain(
    E.fromPredicate(
      isPositiveInteger,
      invalidValue => NEA.of(`Remaining readings value must be a positive integer, got: ${invalidValue}`)
    )
  )
) as Parser<PositiveInteger>
Enter fullscreen mode Exit fullscreen mode

Same as isChar, isPositiveInteger doesn't use a type predicate in the version I'm using (v0.3.4), so we need to use a type assertion to tell TypeScript that it's actually a PositiveInteger.

Verified date

Last parser to write! We need a type guard to make sure the value provided is a Timestamp, i.e. an Integer comprised between -8640000000000000 and 8640000000000000.

import { isInteger } from 'newtype-ts/lib/Integer'

function isTimestamp(t: unknown): t is Timestamp {
  return typeof t === 'number' && isInteger(t) && t >= -8640000000000000 && t <= 8640000000000000
}
Enter fullscreen mode Exit fullscreen mode

Finally, we can write the parser simply with:

const parseTimestamp: Parser<Timestamp> =
  E.fromPredicate(
    isTimestamp,
    invalidValue => NEA.of(`Timestamp value must be a valid timestamp (integer between -8640000000000000 and 8640000000000000), got: ${invalidValue}`)
  )
Enter fullscreen mode Exit fullscreen mode

User-like object

One last step before we move on to the constructors and composition of all these parsers. The input value is unknown. We have to make sure this input is actually an object that "looks like a user" before trying to validate each of its properties. In other words, we need a parser that takes unknown and returns a Validation<UserLike>:

interface UserLike {
  readonly firstName: unknown
  readonly lastName: unknown
  readonly emailAddress: unknown
  readonly middleNameInitial?: unknown
  readonly remainingReadings?: unknown
  readonly verifiedDate?: unknown
}

// or, using mapped types and type intersection
type UserLike =
  Record<'firstName' | 'lastName' | 'emailAddress', unknown> &
  Partial<Record<'middleNameInitial' | 'verifiedDate' | 'remainingReadings', unknown>>
Enter fullscreen mode Exit fullscreen mode

The type guard:

function isUserLike(value: unknown): value is UserLike {
  return (
    typeof value === 'object' &&
    value !== null &&
    'firstName' in value &&
    'lastName' in value &&
    'emailAddress' in value
  )
}
Enter fullscreen mode Exit fullscreen mode

And the parser:

const parseUserLike: Parser<UserLike> =
  E.fromPredicate(
    isUserLike,
    invalidValue => NEA.of(`Input value must have at least firstName, lastName and emailAddress properties, got: ${JSON.stringify(invalidValue)}`)
  )
Enter fullscreen mode Exit fullscreen mode

Constructors

Before combining all these parsers to build a Validation<User>, we'll declare 2 constructors, one for each type of user (unverified and verified):

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

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

We'll build a valid object that is almost a user (thanks to the parsers we wrote), then we'll call one of these constructors to finalize the creation of a User.

Functions composition

It's time to glue all these pieces together!

Let's start by getting a UserLike object:

import { pipe } from 'fp-ts/function'

const parseUser: (input: unknown) => Validation<User> =
  flow(
    parseUserLike,
    // Validation<UserLike>
  )
Enter fullscreen mode Exit fullscreen mode

Next, we need to validate the properties that are common to both unverified and verified users, i.e. first and last names, email address, and middle name initial.

We could chain the parsers to validate these properties one by one:

// { firstName: NonEmptyString50, lastName: NonEmptyString50, emailAddress: EmailAddress, middleNameInitial: O.Option<Char>, verifiedDate?: unknown, remainingReadings?: unknown }
type UserLikePartiallyValid =
  Pick<User, 'firstName' | 'lastName' | 'emailAddress' | 'middleNameInitial'> &
  Pick<UserLike, 'remainingReadings' | 'verifiedDate'>

const parseUser: (input: unknown) => Validation<User> =
  flow(
    parseUserLike,
    E.chain(userLikeObject => pipe(
      parseFirstName(userLikeObject.firstName),
      E.map(firstName => ({ ...userLikeObject, firstName }))
    )),
    E.chain(userLikeObject => pipe(
      parseLastName(userLikeObject.lastName),
      E.map(lastName => ({ ...userLikeObject, lastName }))
    )),
    E.chain(userLikeObject => pipe(
      parseEmailAddress(userLikeObject.emailAddress),
      E.map(emailAddress => ({ ...userLikeObject, emailAddress }))
    )),
    E.chain(userLikeObject => pipe(
      parseMiddleNameInitial(userLikeObject.middleNameInitial),
      E.map(middleNameInitial => ({ ...userLikeObject, middleNameInitial }))
    )) // Validation<UserLikePartiallyValid>
  )
Enter fullscreen mode Exit fullscreen mode

But this is very verbose, and there's a lot of repetition. In addition, we can't aggregate all the error messages: the chain breaks as soon as an error occurs.

In theory, we could run the 4 parsers in parallel since they are all independent. We can use Apply, and more specifically the sequenceS function provided by fp-ts, to make the following transformation:

// use Apply to transform:
type From<A, B, C> = {
  a: Validation<A>
  b: Validation<B>
  c: Validation<C>
}
// into:
type To<A, B, C> = Validation<{
  a: A
  b: B
  c: C
}>
Enter fullscreen mode Exit fullscreen mode

This will allow us to build the same object as above (a UserLikePartiallyValid), but with fewer steps. Additionally, the error messages will be aggregated instead of fast-failing at the first error encountered.

import * as A from 'fp-ts/Apply'
import * as E from 'fp-ts/Either'
import * as NEA from 'fp-ts/NonEmptyArray'

// This Applicative instance allows for error messages to be aggregated,
// using the Semigroup instance of NonEmptyArray.
const validationApplicativeInstance =
  E.getApplicativeValidation(NEA.getSemigroup<string>())

// This is where the From -> To transformation occurs
const validateStruct = A.sequenceS(validationApplicativeInstance)
Enter fullscreen mode Exit fullscreen mode

This is how we can use validateStruct on the UserLike object:

const validateCommonProperties = ({
  firstName,
  lastName,
  emailAddress,
  middleNameInitial,
  ...rest
}: UserLike): Validation<UserLikePartiallyValid> =>
  pipe(
    validateStruct({
      firstName:         parseFirstName(firstName),
      middleNameInitial: parseMiddleNameInitial(middleNameInitial),
      lastName:          parseLastName(lastName),
      emailAddress:      parseEmailAddress(emailAddress)
    }),
    E.map(validProperties => ({ ...validProperties, ...rest }))
  )

const parseUser: (input: unknown) => Validation<User> =
  flow(
    parseUserLike,
    E.chain(validateCommonProperties) // Validation<UserLikePartiallyValid>
  )
Enter fullscreen mode Exit fullscreen mode

At this point, all the properties common to both UnverifiedUser and VerifiedUser have been validated. Now, we need to detect if the user is verified or not. In our domain, we consider a user to be verified if it has a verifiedDate property defined:

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

The object parameter (with the onUnverified and onVerified properties) allows for a continuation, with a feel of pattern matching. If the UserLikePartiallyValid object looks like an unverified user, then we need to check the remainingReadings property in the "onUnverified" path. Otherwise, we need to make sure the verifiedDate property is valid in the "onVerified" path. In both cases, we end up with a Validation<User>, which is exactly what we were looking for!

const validateUnverifiedUser = (userLikeObject: UserLikePartiallyValid): Validation<User> =>
  pipe(
    parseRemainingReadings(userLikeObject.remainingReadings),
    E.map(remainingReadings => unverifiedUser({ ...userLikeObject, remainingReadings }))
  )

const validateVerifiedUser = (userLikeObject: UserLikePartiallyValid & { verifiedDate: unknown }): Validation<User> =>
  pipe(
    parseTimestamp(userLikeObject.verifiedDate),
    E.map(verifiedDate => verifiedUser({ ...userLikeObject, verifiedDate }))
  )

const parseUser: (input: unknown) => Validation<User> =
  flow(
    parseUserLike,
    E.chain(validateCommonProperties),
    E.chain(
      detectUserVerification({
        onUnverified: validateUnverifiedUser,
        onVerified:   validateVerifiedUser
      })
    ) // Validation<User>
  )
Enter fullscreen mode Exit fullscreen mode

And that's it! We can now validate any kind of input value, and get a list of error messages, or a valid User object:

const res0 = parseUser(42)
/*
Left([
  'Input value must have at least firstName, lastName and emailAddress properties, got: 42'
])
*/

const res1 = parseUser({ firstName: 'Bob' })
/*
Left([
  'Input value must have at least firstName, lastName and emailAddress properties, got: {"firstName":"Bob"}'
])
*/

const res2 = parseUser({ firstName: 'Bob', lastName: 42, emailAddress: 'foo' })
/*
Left([
  'Last name value must be a string (size between 1 and 50 chars), got: 42',
  'Email address value must be a valid email address, got: foo'
])
*/

const res3 = parseUser({
  firstName: 'Bob',
  middleNameInitial: 'B',
  lastName: 'Barker',
  emailAddress: 'test@yes.com',
  remainingReadings: 3
})
/*
Right({ type: 'UnverifiedUser', middleNameInitial: Some('B'), ... })
*/

const res4 = parseUser({
  firstName: 'Bob',
  middleNameInitial: 'B',
  lastName: 'Barker',
  emailAddress: 'test@yes.com',
  verifiedDate: 1615339130200
})
/*
Right({ type: 'VerifiedUser', middleNameInitial: None, ... })
*/
Enter fullscreen mode Exit fullscreen mode

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

Summary

If you've made it this far, congratulations!

In the previous article, we built a type definition that described the domain constraints and logic. Here, we created type guards, parsers and constructors for the new types. Then, we combined these elements together to go from an unknown input to either a list of error messages or a valid User object. We can safely use this User in the functions containing the business logic of our domain!

We used newtype-ts and fp-ts to do that. But what if I told you there's already a library in this ecosystem that is specifically useful for data validation? In the next and final article of this series, we'll use this library (spoiler alert: it's io-ts) for both the implementation and type definition!

Discussion (0)

pic
Editor guide