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!
Top comments (6)
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
andio-ts
is that the amount of code becomes more and the complexity of the type increases. What do you think?Hello! You are right, the complexity of the project increases with this approach. But as always, it's a matter of tradeoff:
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 :)
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:
Hello, thank you for your feedback!
If you want to avoid using a type assertion there, you can create a predicate function:
And use it both for the type guard and validate functions of your codec:
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
orNonEmptyString
) that must be reimplemented usingt.Branded
.(there's actually a
NonEmptyString
codec and afromNewtype
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 anew 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 aNewtype
into aBranded
:Now we can do the following:
Note: we could also directly define
Branded
types and completely discardNewtype
:Hope that helps :)
Great series! Thanks a lot!
I'm glad you enjoyed it!