I spend most of my time programming in Elm these days, enjoying (while it lasts) type-safe IO and a rich type system that guarantee no runtime exceptions. And then, just last week, a fairly complex TypeScript project landed in my lap. Excited and optimistic, I spent some time looking into offering the same guarantees on this new terrain using the lovely io-ts and fp-ts packages.
This series sums up this learning journey, and goes by the name Safe Functional IO in TypeScript. It starts out with some pain points of my previous TypeScript experience, and hopefully lands someplace nice.
The need for safe IO
TypeScript's type system has gotten so good that around v3.1 and upwards, I felt comfortable making surgical changes to a 3000 loc React app relying on just the compiler and a few dozen unit tests. However, that kind of benefit only worked as long as data coming into the application behaved as I assumed it to:
interface Fruit {
name: string;
sweetness: string;
}
fetch("https://some.endpoint/fruit.json")
.then(res => res.json())
.then((fruit: Fruit) => {
// do something with `fruit`
// although I'm a bit worried 😨
})
Guaranteeing that fruit
is of the right shape when comes back from the server is tricky, especially since it's tempting to trust the backend team's documentation, annotate UI code like in the snippet above and move on.
But then, there is the inevitable miscommunication, mismatched versions of deployments, unpredictable caching, and the resulting bug does what bugs do best: hit hard and deliver a kind of message/stack trace that does little to narrow down its location.
Not for me, and not for the users of this next product.
Enter io-ts
This clever package exposes a language to define the Fruit
interface above in a way that surfaces not only a static type, but also a highly graceful runtime validator. For our example, it would look like this:
import * as t from "io-ts";
// Validator object, called `codec`
const Fruit = t.type({
name: t.string,
sweetness: t.string
});
// Static type, constructed from the codec
type Fruit = t.TypeOf<typeof Fruit>;
There is a bit of advanced TypeScript sorcery going on here, but for now, it suffices to know that we have a static Fruit
type that behaves the same as the simple interface from before. And with the Fruit
object, we can do our validation:
// Instead of the optimistic `Fruit`, we can now properly annotate as `unknown`
const someValue: unknown = {
name: "apple",
sweetness: "somewhat"
};
const validatedApple = Fruit.decode(someValue);
console.log(validatedApple);
What comes out in the console is this:
{
"_tag": "Right",
"right": {
"name": "apple",
"sweetness": "somewhat"
}
}
Looks like the object is trying to tell me, somewhat awkwardly and with lots of repetition, that something was right. But are we really expected to work with this?
Yes we do, but with lots of elegant help and definitely no response._tag === "Right"
checks.
Enter fp-ts
io-ts
works with the data structures of general-purpose functional programming toolkit called fp-ts
, both internally and in its public API. Using the package, the object above can be constructed like so:
import * as either from "fp-ts/lib/either";
const validationFromBefore = either.right({
name: "apple",
sweetness: "somewhat"
});
And if we were to annotate it, it would look like this:
either.Either<t.Errors, Fruit>
Either
represents a type that can go down two roads: the left, parameterized by one given type, and the right, parameterized by another. Either a folder with documents in it, or a basket with apples in it: there is always a container, and rest assured there won't be any flattened apples in that folder.
To make things a bit more concrete, the left side usually holds some error (t.Errors
in our case), while the right some payload resulting from a successful operation (Fruit
). In a friendlier worded language like Elm, the either-left-right triad is called result-err-ok.
Either
uses discriminated unions in TypeScript so that the compiler can help us use these containers and values correctly.
Working with Either
As I promised earlier, fp-ts
exposes some convenience functions to work with Either
values comfortably. To turn the decoded result into a friendly string to display in a UI, we can use a single either.fold
function that additionally takes two functions:
-
onLeft
: a function that turns the error into string -
onRight
: a function that turns the successfully decoded fruit into a string
In essence, the compiler simply forces us to handle both success and error cases before it lets us interact with our program. In our case, it would look like this:
import * as t from "io-ts/lib/either";
import * as either from "fp-ts/lib/either";
// Decode an arbitrary value
const validation = Fruit.decode({
name: "apple",
sweetness: "somewhat"
});
const result: string = either.fold(
(errors: t.Errors) => "Something went wrong",
(fruit: Fruit) => `${fruit.name} ~ ${fruit.sweetness} sweet`
)(validation);
console.log(result);
// Logs "apple ~ somewhat sweet"
If we replace the value in the sweetness
field with 65 (reasonably assuming that sweetness should go on a 0-100 scale), the validation would fail and you would get "Something went wrong"
instead, as expected.
I set up a CodeSandbox containing some of this code. Feel free to play around with validations there. It is somewhat in flux and may contain all kinds of other goodies by the time you get to it, but isn't it more fun that way 🤓.
Conclusion
In this post, I went through how make sure that a random value in TypeScript conforms to a static interface using the io-ts
package, and how to work with the resulting safe value afterwards using fp-ts
.
Next up, I will set up a small app that talks to a mock server and handles all the errors. There will be some state modeling in fp-ts
, with more functional tricks to enjoy. Until then!
Top comments (0)