DEV Community

A. Sharif
A. Sharif

Posted on

Notes on Advanced TypeScript: Runtime Validations

Introduction

These notes should help in better understanding advanced TypeScript topics and might be helpful when needing to lookup up how to leverage TypeScript in a specific situation. All examples are based on TypeScript 4.6.

Note: This post is an update version of the original Notes on TypeScript: Handling Side-Effects

Basic

There are situations when working with TypeScript, where we can't guarantee that the types reflect the actual data we are working with. Examples for these types of situations include reading from a file, fetching data from an external endpoint or loading information saved in local storage. In all of the above scenarios we can't guarantee that the data entering our application actually reflects the types we defined. Further more, in any of these scenarios we can be running into runtime errors, no matter what the type actually claims.
This means once we're dealing with external data, that is not defined at compile time, we need some mechanism to safely handle this type of data.

To make it more practical, let's assume the following situation: we want to load a user from a pre-defined endpoint.

const loadUser = (id: number) => {
  fetch(`http://www.your-defined-endpoint.com/users/${id}`)
    .then((response) => response.json())
    .then((user: User) => saveUser(user))
    .catch((error) => {
      console.log({ error });
    });
};
Enter fullscreen mode Exit fullscreen mode

At first look this all sounds reasonable, we fetch a user by id, and then save the data for further processing. If you take a closer look at the code, you will notice that we defined the data to be of type User after decoding the json data. The User type in this example is defined as follows:

type User = {
  id: number;
  name: string;
  active: boolean;
  profile: {
    activatedAt: number;
  };
};
Enter fullscreen mode Exit fullscreen mode

Interestingly the code will compile and TypeScript will show no errors as we defined a User and claimed that the response, once decoded, will always be of aforementioned type. Even more interesting is the fact that calling the json function on the response object returns an Promise<any>, so there is no actual guarantee that we are dealing with a User type at runtime.

Let's see a scenario where our assumptions might fail, so let's add a saveUser function, that expects a user with some profile information:

const saveUser = (user: User) => {
  const activationDate = user.profile.activatedAt;
  // do something with the information...
};
Enter fullscreen mode Exit fullscreen mode

Now how can our application break? The code above will compile, but what happens when the returned user object doesn't have any profile information? Let's assume that at runtime, we suddenly receive the following object:

{
  id: 1,
  name: "Some User Name",
  active: true,
  extended: {
      activatedAt: 1640995200000
  }
};
Enter fullscreen mode Exit fullscreen mode

The result will still be a User inside our application, but we will run into an error at runtime, as soon as we call the saveUser function. One way to deal with this, is to get more defensive, by exteding our function to check if the property profile even exists:

const saveUser = (user: User) => {
  if (user && user.profile && user.profile.activatedAt) {
    const activationDate = user.profile.activatedAt;
    // do something with the information...
  } else {
    // do something else
  }
};
Enter fullscreen mode Exit fullscreen mode

But this will quickly become complicated when we have to do these checks all over our application when working with external data. Rather, we want to do this check as early as possible, in fact at the moment we have access to said data.

Advanced

TypeScript doesn't offer any runtime JSON validation capabilities, but there are libraries in the TypeScript eco-system that we can leverage for that specific case.
We will use the popular io-ts library to ensure the data we are working on is reliable throught the application. Our approach will be to decode any external data entering our application.

io-ts is written by Giulio Canti and offers runtime type validations. For more information on io-ts consult the README. So called codecs are used to encode/decode data.These codecs are runtime representations of specific static types and can be composed to build even larger type validations.

Codecs enable us to encode and decode any in/out data and the built-in decode method returns an Either type, which represents success (Right) and failure (Left). Via leveraging this functionality we can decode external data and handle the success/failure case specifically. To get a better understanding let's rebuild our previous example using the io-ts library.

import * as t from "io-ts";

const User = t.type({
  id: t.number,
  name: t.string,
  active: t.boolean,
  profile: t.type({
    activatedAt: t.number,
  }),
});
Enter fullscreen mode Exit fullscreen mode

By combing different codecs like string or number we can construct a User runtime type, that we can use for validating any incoming user data.

The previous basic construct has the same shape as the User type we defined previously. What we don't want though, is to redefine the User as a static type as well. io-ts can help us here, by offering TypeOf which enables user land to generate a static representation of the constructed User.

type UserType = t.TypeOf<typeof User>;
Enter fullscreen mode Exit fullscreen mode

Interestingly this will give us the same representation we defined in the beginning:

type UserType = {
  id: number,
  name: string,
  active: boolean,
  profile: {
    activatedAt: number,
  },
};
Enter fullscreen mode Exit fullscreen mode

Once we have a defined shape, we can verify if the data is of that expected shape and either handle the success or failure case:

const userA = {
  id: 1,
  name: "Test User A",
  active: true,
  profile: {
    activatedAt: t.number,
  },
};

const result = User.decode(userA);

if (result._tag === "Right") {
  // handle the success case
  // access the data
  result.right;
} else {
  // handle the failure
}
Enter fullscreen mode Exit fullscreen mode

The result of the decode function contains a _tag property that can either be a Right or Left string, which represent success or failure. Furthermore we have access to a right and left property, containing the decoded data in the success case (right) or an error message in the failure case (right).
The above example can be extended to use a so called PathReporter for error message handling:

import { PathReporter } from "io-ts/lib/PathReporter";

if (result._tag === "Right") {
  // handle the success case
  // access the data
  result.right;
} else {
  // handle the failure
  console.warn(PathReporter.report(result).join("\n"));
}
Enter fullscreen mode Exit fullscreen mode

io-ts also comes with fp-ts as a peer dependency, which offers useful utility functions like isRight or fold. We can use the the isRight function to check if the decoded result is valid, instead of having to manually handle this via the _tag property.

import * as t from "io-ts";
import { isRight } from "fp-ts/lib/Either";

const userA = {
  id: 1,
  name: "Test User A",
  active: true,
  profile: {
    activatedAt: t.number,
  },
};

isRight(User.decode(userA)); // true

const userB = {
  id: 1,
  name: "Test User",
  active: true,
  extended: {
    activatedAt: t.number,
  },
};

isRight(User.decode(userB)); // false
Enter fullscreen mode Exit fullscreen mode

One more useful functionality that will help us when working with the Either type, that the decode returns is fold, which enables us to define a success and failure path, check the following example for more clarification:

const validate = fold(
  (error) => console.log({ error }),
  (result) => console.log({ result })
);

// success case
validate(User.decode(userA));

// failure case
validate(User.decode(userB));
Enter fullscreen mode Exit fullscreen mode

Using fold enables us to handle valid or invalid data when calling our fetch functionality. The loadUser function could now be refactored to handle these cases.

const resolveUser = fold(
  (errors: t.Errors) => {
    throw new Error(`${errors.length} errors found!`);
  },
  (user: User) => saveUser(user)
);

const loadUser = (id: number) => {
  fetch(`http://www.your-defined-endpoint.com/users/${id}`)
    .then((response) => response.json())
    .then((user) => resolveUser(User.decode(user)))
    .catch((error) => {
      console.log({ error });
    });
};
Enter fullscreen mode Exit fullscreen mode

We might handle any incorrect representation by throwing another error. This prevents the data from being passed around in our application. There are more improvement we can make here. Right now, we're being very specific in how we're handling the User decoding. There might be an opportunity to write a general function that handles any promise based data.

const decodePromise = <I, O>(type: t.Decoder<I, O>, value: I): Promise<O> => {
  return (
    fold < t.Errors,
    O,
    Promise <
      O >>
        ((errors) => Promise.reject(errors),
        (result) => Promise.resolve(result))(type.decode(value))
  );
};
Enter fullscreen mode Exit fullscreen mode

Our decodePromise function handles any input data based on a defined decoder and then returns a promise, based on running the actual decoding operation.

const loadUser = (id: number) => {
  fetch(`http://www.your-defined-endpoint.com/users/${id}`)
    .then((response) => response.json())
    .then((user) => decodePromise(User, user))
    .then((user: User) => state.saveUser(user))
    .catch((error) => {
      console.log({ error });
    });
};
Enter fullscreen mode Exit fullscreen mode

There are more improvements we could make, but we should have a basic understanding of why it might be useful to validate any external data at runtime. io-ts offers more features handling recursive and optional types. Furthermore there are libraries like io-ts-promise that provide more features and useful helpers, the above decodePromise, for example, is available in a more advanced variant via io-ts-promise.


Links

io-ts

io-ts-promise


If you have any questions or feedback please leave a comment here or connect via Twitter: A. Sharif

Discussion (0)