DEV Community

Peter Szerzo
Peter Szerzo

Posted on • Updated on

Safe functional IO in TypeScript: an introduction

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 😨
  })
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

What comes out in the console is this:

{
  "_tag": "Right",
  "right": {
    "name": "apple",
    "sweetness": "somewhat"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
});
Enter fullscreen mode Exit fullscreen mode

And if we were to annotate it, it would look like this:

either.Either<t.Errors, Fruit>
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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)