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
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>
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>
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
}
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')
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
})
)
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)
}
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}`)
)
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>>
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>>
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 aPositiveInteger
. We can do that by using the type guard provided bynewtype-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>
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
}
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}`)
)
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>>
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
)
}
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)}`)
)
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
})
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>
)
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>
)
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
}>
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)
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>
)
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 })
)
)
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>
)
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, ... })
*/
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!
Top comments (2)
Hey Benoit! Really enjoyed this series. Especially how (atleast for me) the implementation in newtype act as a bridge to io-ts. Thanks alot!
Thank you for your feedback! I'm glad you liked it :)