DEV Community

Cover image for Why use static types in JavaScript? (Part 2)
Preethi Kasireddy
Preethi Kasireddy

Posted on

Why use static types in JavaScript? (Part 2)

Advantages of static types

Static types offer us many benefits when writing programs. Let's explore a few of them:

1. Detects bugs and errors early

Static type checking allows us to verify that the invariants we specified are true without actually running the program. And if there's any violation of those invariants, they will be discovered before runtime instead of during it.

A quick example: suppose we have a simple function that takes a radius and calculates the area:

const calculateArea = (radius) => 3.14 * radius * radius;

var area = calculateArea(3);
// 28.26
Enter fullscreen mode Exit fullscreen mode

Now, if we were to pass a radius which is not a number (e.g. ‘im evil')…

var area = calculateArea('im evil');
// NaN
Enter fullscreen mode Exit fullscreen mode

…we'd get back NaN. If some piece of functionality relied on this calculateArea function always returning a number, then this result might lead to a bug or crash. That's not very pleasing, is it?

Had we used static types, we could have specified the exact input(s) and output types for the function:

const calculateArea = (radius: number): number => 3.14 * radius * radius;
Enter fullscreen mode Exit fullscreen mode

Try to pass anything but a number into our calculateArea function now, and Flow will send us a handy-dandy message:

calculateArea('Im evil');
^^^^^^^^^^^^^^^^^^^^^^^^^ function call
calculateArea('Im evil');
              ^^^^^^^^^ string. This type is incompatible with 

const calculateArea = (radius: number): number => 3.14 * radius * radius;
                               ^^^^^^ number 
Enter fullscreen mode Exit fullscreen mode

Now we're guaranteed that the function will only ever accept valid numbers as inputs and return a valid number as output.

Because the type checker tells you when there are errors while you're coding, it's a lot more convenient (and a lot less expensive) than finding out about the bug once the code has been shipped to your customers.

2. Living documentation

Types serve as living, breathing documentation for both ourselves and other users of our code.

To see how, let's look at this method that I once found in a large code base that I was working in:

function calculatePayoutDate(quote, amount, paymentMethod) {
  let payoutDate;

  /* business logic */

  return payoutDate;
}
Enter fullscreen mode Exit fullscreen mode

At first glance (and the second and third), I had no idea how to use this function.

Is quote a number? Or a boolean? Is payment method an object? Or maybe it's a string representing the type of payment method? Does the function return the date as a string? Or as a Date object?

No clue.

My solution at the time was to evaluate the business logic and grep through the codebase until I figured it out But that's a lot of work just to understand how a simple function works.

On the other hand, if we had written something like:

function calculatePayoutDate(
  quote: boolean,
  amount: number,
  paymentMethod: string): Date {
  let payoutDate;

  /* business logic */

  return payoutDate;
}
Enter fullscreen mode Exit fullscreen mode

It becomes immediately clear what type of data the function takes as input and what type of data it returns as output. This demonstrates how we can use static types to communicate the intent of the function. We can tell other developers what we expect from them, and can see what they expect from us. Next time someone goes to use this function, there will be no questions asked.

There's an argument to be made that adding code comments or documentation could solve the same problem:

/*
  @function Determines the payout date for a purchase
  @param {boolean} quote - Is this for a price quote?
  @param {boolean} amount - Purchase amount
  @param {string} paymentMethod - Type of payment method used for this purchase
*/
function calculatePayoutDate(quote, amount, paymentMethod) {
  let payoutDate;
  /* .... Business logic .... */

  return payoutDate;
};
Enter fullscreen mode Exit fullscreen mode

This works. But it's way more verbose. Beyond verbosity, code comments like this are difficult to maintain because they're unreliable and lack structure — some developers might write good comments, some might write obscure comments, and some might forget to write them at all.

It's especially easy to forget to update them when you refactor. Type annotations, however, have a defined syntax and structure and can never go out of date — they're encoded into the code.

3. Reduces convoluted error handling

Types help remove convoluted error handling. Let's revisit our calculateArea function to see how.

This time, I'll have it take an array of radii and calculate the area for each radius:

const calculateAreas = (radii) => {
  var areas = [];
  for (let i = 0; i < radii.length; i++) {
    areas[i] = PI * (radii[i] * radii[i]);
  }

  return areas;
};
Enter fullscreen mode Exit fullscreen mode

This function works, but doesn't properly handle invalid input arguments. If we wanted to make sure that we properly handle cases where the input is not a valid array of numbers, we'd end up with a function that looks like:

const calculateAreas = (radii) => {
  // Handle undefined or null input
  if (!radii) {
    throw new Error("Argument is missing");
  }

  // Handle non-array inputs
  if (!Array.isArray(radii)) {
    throw new Error("Argument must be an array");
  }

  var areas = [];

  for (var i = 0; i < radii.length; i++) {
    if (typeof radii[i] !== "number") {
      throw new Error("Array must contain valid numbers only");
    } else {
      areas[i] = 3.14 * (radii[i] * radii[i]);
    }
  }

  return areas;
};
Enter fullscreen mode Exit fullscreen mode

Wow. That's a lot of code for a little bit of functionality.

But with static types, we could simply do:

const calculateAreas = (radii: Array<number>): Array<number> => {
  var areas = [];
  for (var i = 0; i < radii.length; i++) {
    areas[i] = 3.14 * (radii[i] * radii[i]);
  }

  return areas;
};
Enter fullscreen mode Exit fullscreen mode

Now the function actually looks like what it originally looked like without all the visual clutter from error handling.

Easy enough to see the benefit, right? :)

4. Refactor with greater confidence

I'll explain this one through an anecdote: I was working in a very large codebase once and there was a method defined on the User class that we needed to update — specially, we needed to change one of the function parameters from a string to an object.

I made the change, but was having cold feet to commit the change — there were so many invocations of this function sprinkled around the code base that I had no idea if I'd updated all the instances properly. What if I missed some invocation deep in some untested helpers file?

The only way to know was to ship the code and pray that it didn't blow up with errors.

Using static types would have avoided this. It would have given me the assurance and peace of mind that if I updated a function and in turn, updated the type definitions, the type checker would be there for me to catch all the errors I missed. All I'd have to do is go through those type errors and fix them.

5. Separates data from behavior

One less talked-about benefit of static types is that they help separate data from behavior.

Let's revisit our calculateAreas function with static types:

const calculateAreas = (radii: Array<number>): Array<number> => {
  var areas = [];
  for (var i = 0; i < radii.length; i++) {
    areas[i] = 3.14 * (radii[i] * radii[i]);
  }

  return areas;
};
Enter fullscreen mode Exit fullscreen mode

Think about how we'd go about composing this function. Because we're annotating types, we are forced to first think about the type of data we intend to use so that we can appropriately define the input and output types.

js-code

Only then do we implement the logic:

more-js-code

This ability to precisely express the data separate from the behavior allows us to be explicit about our assumptions and more accurately convey our intent, which relieves some mental burden and brings some mental clarity to the programmer. Without it, we are left to track this mentally in some fashion.

6. Eliminates an entire category of bugs

One of the most common errors or bugs we encounter as JavaScript developers are type errors at runtime.

For instance, let's say our initial application state is defined as:

var appState = {
  isFetching: false,
  messages: [],
};
Enter fullscreen mode Exit fullscreen mode

And let's assume that we then make an API call to fetch the messages in order to populate our appState. Next, our app has an overly simplified view component that takes in the messages (defined in our state) as a prop and displays the unread count and each message as a list item:

import Message from './Message';

const MyComponent = ({ messages }) => {
  return (
    <div>
      <h1> You have { messages.length } unread messages </h1>
      { messages.map(message => <Message message={ message } /> )}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

If the API call to fetch the messages fails or returns undefined, we'd end up with a type error in production:

TypeError: Cannot read property ‘length' of undefined

… and your program crashes. You lose a customer. Bummer.

Let's see how types can help us. We'll start by adding Flow types to our application state. I'll type alias the AppState and then use that to define the state:

type AppState = {
  isFetching: boolean,
  messages: ?Array<string>
};

var appState: AppState = {
  isFetching: false,
  messages: null,
};
Enter fullscreen mode Exit fullscreen mode

Since our API to fetch messages is known to be unreliable, here we're saying that messages is a maybe type of an array of strings.

Same deal as last time — we fetch our messages from the unreliable API and use it in our view component:

import Message from './Message';

const MyComponent = ({ messages }) => {
  return (
    <div>
      <h1> You have { messages.length } unread messages </h1>
      { messages.map(message => <Message message={ message } /> )}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Except now, Flow would catch our error and complain:


<h1> You have {messages.length} unread  messages </h1>
                        ^^^^^^ property `length`. Property cannot be accessed on possibly null value                                                                  
<h1> You have {messages.length} unread messages </h1>
               ^^^^^^^^ null

<h1> You have {messages.length} unread  messages </h1>
                        ^^^^^^ property `length`. Property cannot be accessed on possibly undefined value

<h1> You have {messages.length} unread messages </h1>
               ^^^^^^^^ undefined

     { messages.map(message => <Message message={ message } /> )}
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ call of method `map`. Method cannot be called on possibly null value

     { messages.map(message => <Message message={ message } /> )}
       ^^^^^^^^ null

     { messages.map(message => <Message message={ message } /> )}
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ call of method `map`. Method cannot be called on possibly undefined value

     { messages.map(message => <Message message={ message } /> )}
       ^^^^^^^^ undefined
Enter fullscreen mode Exit fullscreen mode

Whoa buddy!

Because we defined messages as a maybe type, we are saying that it is allowed to be null or undefined. But it still does not allow us to perform operations on it (like .length or .map) without doing a null check because if the messages value was in fact null or undefined, we'd end up with a type error if we perform any operation on it.

So let's go back and update our view function to be something like:


const MyComponent = ({ messages, isFetching }: AppState) => {
  if (isFetching) {
    return <div> Loading... </div>
  } else if (messages === null || messages === undefined) {
    return <div> Failed to load messages. Try again. </div>
  } else {
    return (
      <div>
        <h1> You have { messages.length } unread messages </h1>
        { messages.map(message => <Message message={ message } /> )}
      </div>
    );
  }
};
Enter fullscreen mode Exit fullscreen mode

Flow now knows that we've handled the case where messages is null or undefined, and so the code type checks with 0 errors. Long dead runtime type errors :)

7. Reduces # of unit tests

We saw earlier how static types can help eliminate convoluted error handling because they guarantee input and output types. As a result, they also reduce the # of unit tests.

For instance, let's go back to our dynamically-typed calculateAreas function with error handling:

const calculateAreas = (radii) => {
  // Handle undefined or null input
  if (!radii) {
    throw new Error("Argument is missing");
  }

  // Handle non-array inputs
  if (!Array.isArray(radii)) {
    throw new Error("Argument must be an array");
  }

  var areas = [];

  for (var i = 0; i < radii.length; i++) {
    if (typeof radii[i] !== "number") {
      throw new Error("Array must contain valid numbers only");
    } else {
      areas[i] = 3.14 * (radii[i] * radii[i]);
    }
  }

  return areas;
};
Enter fullscreen mode Exit fullscreen mode

If we were diligent programmers, we might have thought to test invalid inputs to make sure they are handled correctly in our program:

it('should not work - case 1', () => {
  expect(() => calculateAreas([null, 1.2])).to.throw(Error);
});

it('should not work - case 2', () => {
  expect(() => calculateAreas(undefined).to.throw(Error);
});

it('should not work - case 2', () => {
  expect(() => calculateAreas('hello')).to.throw(Error);
});
Enter fullscreen mode Exit fullscreen mode

… and so on. Except it's very likely that we forget to test some edge cases — then our customer is the one to discover the problem. :(

Since tests are solely based on the cases we think to test, they are existential and easy to circumvent.

On the other hand, when we're required to define types:

const calculateAreas = (radii: Array<number>): Array<number> => {
  var areas = [];
  for (var i = 0; i < radii.length; i++) {
    areas[i] = 3.14 * (radii[i] * radii[i]);
  }

  return areas;
};
Enter fullscreen mode Exit fullscreen mode

…not only are we guaranteed that our intent matches reality, but they are also simply harder to escape. Unlike empirically-based tests, types are universal and difficult to be wishy-washy around.

The big picture here is: tests are great at testing logic, and types at testing data types. When combined, the sum of the parts is greater than the whole.

8. Domain modeling tool

One of my favorite use cases for types is domain modeling. A domain model is a conceptual model of a domain that includes both the data and behavior on that data. The best way to understand how you can use types to do domain modeling is by looking at an example.

Let's say I have an application where a user has one or more payment methods to make purchases on the platform. There are three types of payment methods they're allowed to have (Paypal, Credit card, Bank Account).

So we'll first type alias these three different payment method types:

type Paypal = { id: number, type: 'Paypal' };
type CreditCard = { id: number, type: 'CreditCard' };
type Bank = { id: number, type: 'Bank' };
Enter fullscreen mode Exit fullscreen mode

Now we can define our PaymentMethod type as a disjoint union with three cases:

type PaymentMethod = Paypal | CreditCard | Bank;
Enter fullscreen mode Exit fullscreen mode

Next, let's model our app state. To keep it simple, let's assume that our app data only consists of the user's payment methods.

type Model = {
  paymentMethods: Array<PaymentMethod>
};
Enter fullscreen mode Exit fullscreen mode

Is this good enough? Well, we know that to get the user's payment methods, we need to make an API request, and depending on where in the fetching process we are, our app will have different states. So there's actually four possible states:

1) We haven't fetched the payment methods

2) We are fetching the payment methods

3) We successfully fetched the payment methods

4) We tried fetching but there was an error fetching the payment methods

But our simple Model type with paymentMethods doesn't cover all these cases. Instead, it assumes that paymentMethods always exists.

Hmmm. Is there a way to model our app state to be one of these four cases, and only these four cases? Let's take a look:

type AppState<E, D>
  = { type: 'NotFetched' }
  | { type: 'Fetching' }
  | { type: 'Failure', error: E }
  | { type: 'Success', paymentMethods: Array<D> };
Enter fullscreen mode Exit fullscreen mode

We used a disjoint union type to define our state as one of the four scenarios described above. Notice how I am using a type property to determine which of the four states our app is in. This type property is actually what makes this a disjoint union. Using this, we can do case analysis to determine when we have the payment methods and when we don't.

You'll also notice that I pass in a generic type E and D into the app state. Type D will represent the user's payment method (PaymentMethod defined above). We haven't defined type E, which will be our error type, so let's do that now:

type HttpError = { id: string, message: string };
Enter fullscreen mode Exit fullscreen mode

Now, we can model our app domain as:

type Model = AppState<HttpError, PaymentMethod>;
Enter fullscreen mode Exit fullscreen mode

In summary, the signature for our app state is now AppState<E, D> — where E is of the shape HttpError and D is PaymentMethod. And AppState has four (and only these four) possible states: NotFetched, Fetching, Failure and Success.

js-output

I find this type of domain modeling useful for thinking about and building user interfaces against certain business rules. The business rules tells us that our app can only ever be in one of these states. So this allows us to explicitly represent build our app state and guarantees that it will only ever be in one of the pre-defined states. And when we build off of this model (e.g. to create a view component), it comes blatantly obvious that we need to handle all four possible states.

Moreover, the code become self-documenting — you can look at the union cases and immediately figure out how the app state is structured.

Top comments (0)