Unlocking the Power of Type Encoding / Decoding with io-ts

Revolutionize your development workflow and eliminate bugs with the io-ts library.

#typescript
#javascript
#bestpractices
Unlocking the Power of Type Encoding / Decoding with io-ts
Picture by Andre Taissin

As a developer at Elastic, I've had my fair share of challenges when it comes to type encoding and decoding. One tool that has been instrumental in streamlining my workflow and eliminating bugs is the io-ts library, which provides a runtime-type system for IO decoding/encoding.

When I first started using this library, it was confusing and I struggled on grasping the concept behind that, but after getting used to it, I realized how beneficial it is when working on large-scale projects.

That's why in this post, I'll explore the benefits of using io-ts for better type encoding and decoding and why I recommend to every Typescript developer to consider it.

What are Runtime Types and how do they differ from Typescript types?

Before diving into the specifics of io-ts, it's important to understand what runtime types are and how they differ from TypeScript types.

TypeScript types are only used during compilation and are therefore limited in their scope. In contrast, runtime types are used during runtime, allowing for more flexible and powerful type encoding and decoding.

There are several benefits to using runtime types with io-ts, including:

  • Improved type safety: Using io-ts provides an extra layer of safety when encoding and decoding data, preventing unexpected or invalid values from being processed.
  • Increased flexibility: Runtime types allow for more complex type definitions than TypeScript types, which can be useful when working with complex data structures.
  • Easier integration with external data sources: With io-ts, it's easy to define custom codecs for integrating with external data sources, such as APIs or databases.
  • More informative error messages: When an error occurs during encoding or decoding, io-ts provides detailed error messages that make it easier to identify and fix the issue.

However, there are also some potential downsides to using io-ts, including:

  • Increased complexity: With the added flexibility of runtime types comes added complexity, which can be daunting for developers who are new to the library.
  • Overhead: The use of runtime types may introduce additional overhead during encoding and decoding, which can impact performance in some cases.
  • Learning curve: While io-ts is well-documented, there is still a learning curve involved in mastering the library.

Implementing runtime type systems with io-ts

Despite the potential downsides, I believe that the benefits of using io-ts far outweigh the drawbacks. To demonstrate how to implement runtime-type systems with io-ts, I'll walk through an example of encoding and decoding data for a fictional to-do list application.

First, we'll define the runtime types for our to-do list items:

ts
import * as t from 'io-ts';

const todoRT = t.type({
id: t.number,
title: t.string,
completed: t.boolean
});

// 🤩 It is possible to derive a TS type from the runtime type definition
type Todo = t.TypeOf<typeof todoRT>;

const todoListRT = t.array(todoRT);

With these runtime types defined, we can now use them to encode and decode data. For example, to decode JSON data into our to-do list items, we can use the following code:

ts
import { pipe } from 'fp-ts/lib/function';
import * as E from 'fp-ts/lib/Either';
import * as D from 'io-ts/lib/Decoder';

const decodeTodos = (input: string) =>
pipe(
D.parse(input, todoListRT.decode),
E.getOrElse(() => [])
);

Using the decodeTodos function, we can easily decode a JSON string into an array of to-do list items, with error handling built in.

This is a basic example, but io-ts comes with utilities that allow the creation of more complex runtime types!

Let's now see a real-world example, creating a common runtime type to encode/decode the response payload from a to-do CRUD api.

Using the Todo runtime type with APIs

Now that we have created the todoRT runtime type, let's explore how we can use it in a more complex example. We will now see how to encode the response payload of a GET request in the backend using the todoRT runtime type and how to decode the response in the client.

Let's say we have a server endpoint that returns a list of todos in the following format:

ts
interface Todo {
id: number;
title: string;
description: string;
completed: boolean;
createdAt: string;
updatedAt: string;
}

type TodoList = Todo[];

As we saw before, we can derivate the Typescript type from the runtime type definition, so we won't need to create manually the type defined above.

In the backend, we can use the todoRT runtime type to validate the response payload before sending it back to the client:

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

const todoRT = t.type({
id: t.number,
title: t.string,
description: t.string,
completed: t.boolean,
createdAt: t.string,
updatedAt: t.string
});

const todoListRT = t.array(todoRT);

type TodoList = rt.TypeOf<typeof todoListRT>;

router.get('/api/todos', (req: Request, res: Response) => {
// Fetch todos from the database
const todos: TodoList = fetchTodosFromDb();

// Validate the todos using the runtime type
const result = todoListRT.decode(todos);

if (isLeft(result)) {
// Handle validation error
res.status(500).send({ message: 'Invalid todo payload' });
return;
}

// Return the validated todos
res.status(200).send(result.right);
});

In the client, we can use the todoListRT runtime type to decode the response payload before using it in our application:

ts
async function getTodos() {
const response = await fetch('/api/todos');
const todos: TodoList = await response.json();

// Validate the response using the runtime type
const result = todoListRT.decode(todos);

if (isLeft(result)) {
// Handle validation error
throw new Error('Invalid todo payload');
}

return result.right;
}

We first fetch the response from the server and then use the todoListRT runtime type to decode the response payload. If the payload is invalid, we throw an error. Otherwise, we return the validated todos.

Using runtime types in this way can add some overhead to your application, but it's worth it as it can also provide better type safety and validation for your application's data.

Conclusion

I honestly believe we should always take care of validating our input/output after data transitions, and io-ts so far have been a great DX for me, I would recommend any colleague out there to give it a try on a side project or on a complex platform to see the benefits and structure it brings to the project!

Last updated: