How to Create Better Components with TypeScript and React Hooks (I)

Published in WordPress.

One of the things I like the most about working with my partners at Nelio, Ruth and Toni, is the possibility of choosing what projects we’ll work on and how we’ll implement them. We also enjoy learning and trying out new technologies whenever we can. So it shouldn’t come as a surprise that every time we start something, we see it as an opportunity to integrate what we’ve learned so far and thus do things better.

It today’s post, I want to talk you about types and, in particular, how a good type definition in TypeScript can guide the development of our plugins, allowing us to create a more robust, reliable, and maintainable software. And, in addition, I’m going to explain it with a real example, Nelio Popups, which Ruth told you about a few days ago. I hope you enjoy it and that, like us, you feel like trying it in your next projects.

An Overview of TypeScript

As we have already mentioned in previous posts, TypeScript is a programming language that extends JavaScript with types. Its aim is simple: strong typing of our JavaScript code will help us to prevent runtime errors (as the compiler will detect them at compile time) and will lead to better, more reliable software.

For instance, imagine we have an object with information about a user: name, password, etc.

const u = {
  name: 'David',
  password: 'some-nice-password',
};

Next, suppose we have a function that checks if a user’s password is “safe” (i.e. it has 8 or more characters):

function hasSafePassword( user ) {
  return 8 <= user.pasword.length;
}
hasSafePassword( u ); // ERROR!

Well, it turns out that in less than 10 lines of code, we have an error that, at first glance, is difficult to catch. We have to run the code to make it obvious:

Uncaught TypeError: user.pasword is undefined
    hasSafePassword

Here’s what went wrong: our user doesn’t have a pasword attribute… because we’ve misspelled it! It’s actually password with two s

Had we implemented this same code using TypeScript and provided a type definition for a User object as follows:

type User = {
  readonly name: string;
  readonly password: string;
};
// A
const u: User = {
  name: 'David',
  password: 'some-nice-password',
};
// B
function hasSafePassword( user: User ) {
  return 8 <= user.pasword.length;
}
// C
hasSafePassword( u );

the compiler would have been able to catch the error right off the bat:

Property 'pasword' does not exist on type 'Person'. Did you mean 'password'?

A pretty simple example which, let’s be honest, we both know you and I have faced several times… which can be quickly addressed by a robust type system.

Union Types

One of the coolest things about TypeScript is the fact that any variable or parameter can have more than one type. In the previous example, for instance, we’ve seen that the constant u has to be exactly of type User, as well as the user parameter in the hasSafePassword function. Now, there are cases in which we need a variable or parameter to have different types. A paradigmatic example of this is a Redux reducer.

As we discussed in a previous post, a Redux store allows us to keep track of our app’s state. To update it, we have a reducer method that takes two arguments (the current state of our application and an action with the necessary information to update it) and produces a new state:

function reducer( state: State, action: Action ): State

Logically, the actions a reducer receive are all different from each other. For example, if we’re implementing a TODO list, we may have actions like these:

  • Create a task: given an ID and the description of a task, add it in the application state.
  • Delete a task: Given the ID of a task, remove it from the application state.
  • Change the status of a task: given the ID of a task, mark it as completed if it was pending (and viceversa).

In TypeScript:

type NewTask = {
  readonly type: 'NEW_TASK';
  readonly id: string;
  readonly task: string;
}
type RemoveTask = {
  readonly type: 'REMOVE_TASK';
  readonly id: string;
}
type ToggleTaskStatus = {
  readonly type: 'TOGGLE_TASK_STATUS';
  readonly id: string;
}

As you can see, the actions of such a small application would either be of type NewTask, RemoveTask, or ToggleTaskStatus. This is what is known as a “type union,” which TypeScript represents using the pipe symbol:

type Action =
  | NewTask
  | RemoveTask
  | ToggleTaskStatus;

Type Guards

When the code is running, an action in our TODO list example will have one specific type only. If you look closely at their definition, you’ll notice NewTask, RemoveTask, and ToggleTaskStatus all have a type property (which, by the way, is a standard convention in Redux stores) and an id property. You’ll also notice that NewTask is slightly different: it also has a task property.

Before using an action, we need to know its exact type. To do so, we use a thing called “type guards.” A type guard is a simple function that validates if a certain variable has one specific type or another. In the case of actions, this check is super simple, because all we need to do to know the specific action type is look at its type attribute, duh:

function reducer( state: State, action: Action ): State {
  switch ( action.type ) {
    case 'NEW_TASK':
      return {
         ...state,
         [ action.id ]: {
           completed: false,
           task: action.task,
         }
      };
    ...
  }
}

Therefore, we can use a switch block to do one thing or the other depending on the action we received. When type is NEW_TASK, we know for sure that we’ve been given a NewTask action and, therefore, action does contain the task attribute.

This type of union of types is called “discriminated union of types” because, as its name indicates, it is a union of types, all of which share a common attribute (in this case, type) that allows us to discriminate one type from the other.

But not all type unions have a discriminator. It is perfectly possible that two different types do not have a specific attribute to tell them apart:

type Element = Post | Task;
type Post = {
  readonly id: number;
  readonly title: string;
}
type Task = {
  readonly id: number | string;
  readonly task: string;
  readonly completed: boolean;
}

So the following question arises: how can we tell if a given Element is actually a Post or a Task? Well, we need to use “type predicates”. A type predicate is basically a predicate function (i.e. a boolean function) that’s responsible of checking if a certain variable is of a certain type or not:

const isPost = ( el: Element ): el is Post =>
  undefined !== ( el as Post ).title;
const isTask = ( el: Element ): el is Task =>
  undefined !== ( el as Task ).task;

isPost and isTask are two different functions that receive an Element object and tell us if the Element is a Post or a Task respectively. To do this, they both take advantage of the fact that there are attributes that only Post or Task have. So, for example, if el has a title, we know it’s a Post.

With these type predicates we can then do things like this:

function stringify( el: Element ): string {
  return isPost( el ) ? el.title : el.task;
}

where we use the isPost type guard to check if the element is a Post or not. If it is, we return its title ; if not, TypeScript knows el must be a Task and, therefore, we can look at its attribute task. You can play with this example here and see there aren’t any errors.

Now, what?

I hope you found this short introduction to TypeScript interesting. Now that you have a better understanding of some basic type theory, you know everything you need to know to write better software. Next week we’ll see how union types and safe guards will lead to better designs. We’ll also learn the importance of “making impossible states impossible.”

See you next week!

Featured image by Kelly Sikkema on Unsplash.

Leave a Reply

Your email address will not be published. Required fields are marked: •

I have read and agree to the Nelio Software Privacy Policy

Your personal data will be located on SiteGround and will be treated by Nelio Software with the sole purpose of publishing this comment here. The legitimation is carried out through your express consent. Contact us to access, rectify, limit, or delete your data.