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 anA
(e.g. parse anunknown
and validate that it's aNonEmptyString50
). This part is handled by theDecoder
side of the codec. - Serialize an
A
into anOutput
(e.g. serialize anUnverifiedUser
into astring
). This part is handled by theEncoder
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'
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>
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']
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}`
)
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']
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>
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>
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}`
)
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>
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}`
)
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>
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}`
)
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>
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>
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' }
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' }
Now that we have both UnverifiedUser
and VerifiedUser
types, we can create the User
type simply with:
type User = UnverifiedUser | VerifiedUser
And the constructor functions:
const unverifiedUser = (fields: UntaggedUnverifiedUser): User => ({ ...fields, type: 'UnverifiedUser' })
const verifiedUser = (fields: UntaggedVerifiedUser): User => ({ ...fields, type: 'VerifiedUser' })
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 })
)
)
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))
})
)
)
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<...>;
} & {
...;
} & {
...;
}
*/
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!