JavaScript Type Checkers are More than Linters

Sid Raval

I’ve recently worked with several teams that have sizeable JavaScript codebases. In order to increase the maintainability of their codebases, they’ve chosen to start gradually typing their codebases, using Flow, a static type checker for JavaScript. As a believer in the virtues of statically typed code, I’ve encouraged this adoption.

If developers on the team do not have experience with statically typed languages, I’ve seen the following workflow arise:

  • Developers write their JavaScript code as they always have
  • They add some types to their codebase
  • They run Flow, and fix problems as necessary

This is what I mean by “treating your type checker as a linter”. Flow is being used as an after-the-fact coherence check on code that was written without types in mind.

A First Attempt

To give an example of this workflow, let’s say we need to produce an object of the following structure, to pass off to some analytics code.

{
  name: "Analytics event name here",
  description: "Human readable description of the event",
  linkURL: "The URL of the link we're reporting analytics for",
  currentPageURL: "The URL of the page the event is happening on"
}

To that end, we could write something like the following:

const analyticsEvents = {
  onClick: {
    name: "onClick",
    description: "Link click"
  },
  onHover: {
    name: "onHover",
    description: "Link hover"
  }
};

function buildAnalyticsObject(eventName, metadata) {
  const analyticsData = analyticsEvents[eventName];

  if (!analyticsData) {
    return null;
  }

  return {
    ...analyticsData,
    ...metadata
  };
}

Since we’ve been asked to use Flow (and avoid the any type as much as possible), we’ll add some types to our function. We’re expecting eventName to be a string; metadata is a caller-supplied Object; and we know the shape of the structure we’re returning, so we might write the following:

function buildAnalyticsObject(
  eventName: string,
  metadata: Object
): {
  name: string,
  description: string,
  currentPageUrl: string,
  linkUrl: string
} | null {
  const analyticsData = analyticsEvents[eventName];

  if (!analyticsData) {
    return null;
  }

  return {
    ...analyticsData,
    ...metadata
  };
}

Flow is happy with this level of typing in our code - so, what’s the problem?

  • Callers of our function need to understand its implementation

It’s not clear that the only valid strings one should call it with are "onClick" and "onHover". It’s also not clear that the metadata object should not have name or description properties to prevent shadowing.

  • The return type of our function is partially a lie

Nothing in our code ensures the return type will have each of the properties listed. It may not contain the stated properties at all; it’s possible that it contains only name and description.

  • The optional return type is unfortunate, and asks the caller to handle failure

It would be great if we could guarantee that we’re returning an object with the properties, rather than an optional value that the caller has to handle.

We can fix all of these issues by thinking about our types before we write our code.

A Second Attempt

We’ll begin by defining a few of our types up-front. We know we want to handle two types of events, and we know what structure the result should have:

type OnClickEvent = {
  name: "onClick",
  description: "Link click"
};

type OnHoverEvent = {
  name: "onHover",
  description: "Link hover"
};

type AnalyticsEvent = OnClickEvent | OnHoverEvent;

type AnalyticsMetadata = {
  linkUrl: string,
  currentPageUrl: string
};

type AnalyticsWithMetadata = AnalyticsEvent & AnalyticsMetadata;

We define a few events, then use the union operator (|) to express that an AnalyticsEvent is an OnClickEvent or a OnHoverEvent. We then use the intersection operator (&) to express that our result type should have all the properties of an AnalyticsEvent and an AnalyticsMetadata. With the types defined, we can then implement our function as follows:

function buildAnalyticsObject(
  eventName: "onClick" | "onHover",
  metadata: AnalyticsMetadata
): AnalyticsWithMetadata {
  const event = eventNameToEvent(eventName);

  return {
    ...event,
    linkUrl: metadata.linkUrl,
    currentPageUrl: metadata.currentPageUrl
  };
}

function eventNameToEvent(
  eventName: "onClick" | "onHover"
): AnalyticsEvent {
  switch (eventName) {
    case "onClick":
      return {
        name: "onClick",
        description: "Link click"
      };
    case "onHover":
      return {
        name: "onHover",
        description: "Link hover"
      };
  }

  throw new Error(`Unexpected analytics event name: ${eventName}`);
}

What have we gained by writing code like this, in terms of the types we defined up-front?

  • Callers of our function no longer need to understand its implementation

Flow will ensure the eventName argument is one of the two valid strings; it will also ensure that the metadata object passed in contains the two necessary properties, linkURL and currentPageURL. Furthermore, shadowing of the name and description properties is no longer possible. If we try to spread the metadata object as before, Flow will complain about the potential shadowing.

  • Our return type tells the whole story

Callers know the return type of our function is an object with the four properties we defined above; they also do not have to handle an optional return value.

  • We’ve encoded our domain (analytics work) into the type system

By defining our types up-front, we’re giving future developers information about what we knew when implementing the code: the types communicate that we only support two events, and that we’re only giving two pieces of metadata to our analytics.

Conclusion

The type-driven approach shown above produces a more robust and expressive solution to the problem we set out to solve. I hope it illustrates the concrete benefits to leveraging your type system before you write implementations, rather than after.