loading...

Notes on TypeScript: Handling Side-Effects

busypeoples profile image A. Sharif ・6 min read

Introduction

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


Basics

When working with TypeScript, we want to rely on the defined types throughout the application.
But can we always rely on these types? There are cases where we can't guarantee that these types are valid at compile time. Let's think about some possible cases where this might apply.
When working with external files that we might load at runtime, or a simple API fetch. We can't guarantee that what we expect is actually the expected type at runtime. There might be cases where we control the complete application cycle and can assume that the expected types apply, but even then, we're dealing with an side-effects, that we want to handle safely as possible.

Let's see an example where we assume a specific type is actually valid, but where we might run into problems at runtime.

type Theme = "basic" | "advanced";

type User = {
  id: number;
  name: string;
  points: number;
  active: boolean;
  settings: {
    configuration: {
      theme: Theme;
    };
  };
};

User type defines a specific user shape, nothing too special so far. In our application we will probably be retrieving this user object via an endpoint and use this user object to later update some properties.

Somewhere in our app, we want to enable to change the user settings, to make the profile configurable. changeTheme is a simplified function that accepts a possible theme and updates the user settings.

const changeTheme = (user: User, theme: Theme): User => {
  return {
    ...user,
    settings: {
      ...user.settings,
      configuration: { ...user.settings.configuration, theme }
    }
  };
};

The user object has to come from somewhere, so let's assume we have a function that takes care of loading that user.

const loadUser = () => {
  fetch("http://localhost:3000/users/1")
    .then(response => response.json())
    .then((user: User) => state.saveUser(user))
    .catch(error => {
      console.log({ error });
    });
};

Furthermore we might want to keep state of that user inside the client, so we call the saveUser function to keep track of the object.

type State = {
  user: User | undefined;
  saveUser: (user: User) => void;
  getUser: () => User | undefined;
};

const state: State = {
  user: undefined,
  saveUser: (user: User) => {
    state.user = user;
  },
  getUser: () => {
    return state.user;
  }
};

TypeScript will not complain. Calling the json function on the response object returns an Promise<any>, so there is no guarantee that we are dealing with a User type. The following code will compile as well. If you recall changeTheme expects a User type.

const user = state.getUser();
if (user) {
  const result = changeTheme(user, "advanced");
}

When calling changeTheme with the following fetch result, we will be greeted with an error at runtime. We expected the property to have the settings key, but it actually returned setting.


const user = {
  id: 1,
  name: "Test User",
  points: 100,
  active: true,
  setting: {
    configuration: {
      theme: "basic"
    }
  }
};

changeTheme(user, "advanced");

// Error! 
// Uncaught TypeError: Cannot read property 'configuration' of undefined at changeTheme

This example might be very simplified, but it should show that we can't fully rely on the data coming from an external source. We could be more defensive when working with user, which we should be doing anyway, but it also means that we can rely on these types even less. Adding a check will prevent the previous error.

if (!user || !user.settings || !user.settings.configuration) {
  return user;
}

We have seen that we need to be more restrictive when working with external data and relying TypeScript defined types, but there might be a more convenient way to handle side-effects when working inside a TypeScript codebase.


Advanced

If you have worked with Elm or ReasonMl before, you might have noticed that there is a way to encode and decode any JSON data. This is very valuable as we want to ensure that JSON data we are getting has the expected type. TypeScript by default doesn't offer any runtime JSON validators, but there are a couple of possible solutions to solving that problem in TS land. We will use one of these libraries to show how we can make our types more reliable.

io-ts is a library written by Giulio Canti that is focused on runtime type validations. We will not get too deep into the details and capabilities of this library, as the official README covers most of the basics. But it's interesting to note, that the library offers a large range of so called codecs, which are runtime representations of specific static types. These codecs can then be composed to build larger type validations.

Codecs enable us to encode and decode in and out data and the built-in decode method that every code exposes, returns an Either type, which represents success (Right) and failure (Left). This enables us to decode any external data and handle success/failure 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,
  points: t.number,
  active: t.boolean,
  settings: t.type({
    configuration: t.type({
      theme: t.keyof({
        basic: null,
        advanced: null
      })
    })
  })
});

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>;

This gives us the exact same representation we defined in the beginning of this post.

type UserType = {
  id: number;
  name: string;
  points: number;
  active: boolean;
  settings: {
    configuration: {
      theme: 'basic' | 'advanced';
    };
  };
};

To clarify the concept, let assume we have a user object and want to validate if it has the expected shape. io-ts also comes with fp-ts as a peer dependency, which offers useful utility functions like isRight or fold. We can use the isRight function to check if the decoded result is valid.

const userA = {
  id: 1,
  name: "Test User",
  points: 100,
  active: true,
  settings: {
    configuration: {
      theme: "basic"
    }
  }
};

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

const userB = {
  id: 1,
  name: "Test User",
  points: "100",
  active: true,
  settings: {
    configuration: {
      theme: "basic"
    }
  }
};

isRight(User.decode(userB)); // false

Now that we have some basic understanding of how io-ts functions, let's revisit the original loadUser function and decode the data before we pass it around inside our application. But before we start refactoring let's take a look at one more useful functionality, that will help us when working with the Either type, that the decode returns. fold 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));

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) => state.saveUser(user)
);

const loadUser = () => {
  fetch("http://localhost:3000/users/1")
    .then(response => response.json())
    .then(user => resolveUser(User.decode(user)))
    .catch(error => {
      console.log({ error });
    });
};

As we can see from the above code, 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));
};

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 = () => {
  fetch("http://localhost:3000/users/1")
    .then(response => response.json())
    .then(user => decodePromise(User, user))
    .then((user: User) => state.saveUser(user))
    .catch(error => {
      console.log({ error });
    });
};

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 like error reporters or 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

pic
Editor guide
Collapse
juancarlospaco profile image
Juan Carlos

Nice!, I miss that on TypeScript too much...,
on Nim you have run-time checks, side effects tracking, immutability, pure-functions, and JSON Types.