Skip to Content
Course content

73: Runtime Validation with io-ts

Click on the "Edit" button in the top corner of the screen to edit your slide content.

One of the biggest lies we tell ourselves in TypeScript is that a type cast—like as User—actually does something. It doesn't. It just tells the compiler to stop complaining. If your API sends back a null where you expected a string, your app will still crash at runtime, but the compiler will be blissfully unaware until the Cannot read property 'x' of null error hits your logs.

This is where io-ts comes in. It allows us to define a "codec"—a single source of truth that acts as both a runtime validator and a TypeScript type definition. I've used this in several production projects where we deal with unstable third-party APIs, and it's a lifesaver.

Defining our data contract with codecs

Let's say we're building a small dashboard that fetches a user's GitHub profile. We can't trust the API response implicitly. Instead of a standard interface, we'll use io-ts to define what a "User" looks like. I'm going to use the t namespace from io-ts to build this.

import * as t from 'io-ts';
import { isLeft } from 'fp-ts/Either';

const UserCodec = t.type({
  id: t.number,
  login: t.string,
  bio: t.string,
  public_repos: t.number,
});

Notice that UserCodec isn't a type; it's a value. It's an object that knows how to check if a random piece of data matches the structure we defined. This is the "runtime" part of the equation.

The mistake: Confusing the codec with the type

When I first started with io-ts, I made a classic mistake. I tried to use the codec itself as the type for my variables. I wrote something like this:

// ❌ THIS IS WRONG
const profile: UserCodec = {
  id: 1,
  login: 'octocat',
  bio: 'GitHub mascot',
  public_repos: 10
};

TypeScript immediately screamed at me. Why? Because UserCodec is the validator, not the shape of the data. The validator is a complex object with methods like decode and encode. It's not a user profile.

To fix this, io-ts provides a helper called t.TypeOf. This extracts the actual TypeScript interface from the codec so we don't have to write the interface twice (once for runtime and once for compile-time).

// ✅ This is the way
type User = t.TypeOf<typeof UserCodec>;

const profile: User = {
  id: 1,
  login: 'octocat',
  bio: 'GitHub mascot',
  public_repos: 10
};

Handling the "Either" result

Now for the actual validation. When we call UserCodec.decode(data), io-ts doesn't throw an error if the validation fails. Instead, it returns an Either type from the fp-ts library. An Either is basically a fancy way of saying "this is either a Left (an error) or a Right (the successfully validated data)."

Here is how I typically implement a fetcher using this pattern:

async function fetchUser(username: string): Promise<User | null> {
  const response = await fetch(`https://api.github.com/users/${username}`);
  const json = await response.json();

  const result = UserCodec.decode(json);

  if (isLeft(result)) {
    console.error('API response did not match the UserCodec:', result.left);
    return null;
  }

  // result.right is now fully typed as User
  return result.right;
}

I prefer this approach because it forces me to handle the failure case explicitly. If the API changes a field name from login to username, my app won't crash with a weird undefined error deep in the UI components; it will fail right at the edge of the system where the data enters the app.




📋 Practical Task

Exercise: Building a Product Catalog Validator

You are integrating with an e-commerce API that returns a list of products. The API is notoriously flaky and sometimes sends numbers as strings or omits required fields.

Your Task:

  1. Install io-ts and fp-ts (conceptually, or in your local environment).
  2. Create a ProductCodec that validates the following structure:
    • sku: A string
    • price: A number
    • tags: An array of strings
  3. Define a TypeScript type Product using t.TypeOf based on your codec.
  4. Write a function validateProduct(data: unknown): Product | string.
    • If the data is valid, return the validated Product.
    • If the data is invalid, return the string "Invalid Product Data".
  5. Test your function with these two objects:
    • { sku: "TS-101", price: 29.99, tags: ["book", "typescript"] } (Should succeed)
    • { sku: "TS-101", price: "29.99", tags: ["book"] } (Should fail because price is a string)
Rating
0 0

There are no comments for now.

to be the first to leave a comment.