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.