Crafting Effective Type Systems

Will Madden
Nov 25 · 4 min read

In this article I describe two simple principles for designing a type system in your application, the benefits they confer and some real-world examples of their use.

If you’ve ever worked with TypeScript or Flow, you’ve probably found yourself in a position where it feels like you’re fighting the type checker. It’s easy to fence yourself in with types that are too strict or difficult to extend, but when it’s executed well, your application’s type system should help you instead of getting in the way.

If you’re already experienced with type systems these principles may be familiar to you, but I hope that codifying them here will help structure conversation in your team around these common goals.

1. Errors should be meaningful, and in the right place

Most of the time, your type system will be invisible. It doesn’t talk to you until you make a mistake, and its voice is the error that you read. The quality of the conversation depends on the quality of those errors, and there are two characteristics that define an effective error:

  1. It’s in the location where you made the mistake
  2. The error message describes the mistake you made

Here’s a simple example of an error message in the wrong place:

The mistake we made was on line 4 where but the error doesn’t appear until we try to use the name property later on. This doesn’t make much difference in a snippet less than ten lines long, but in a real-world application that error might be in a different file in an unrelated part of the codebase.

This problem is easily avoided by including the return type on the function definition:

Now the error appears where you made the mistake, and describes the actual problem: that the object you created is not a User.

2. Your types should be understood by your tools

Your type system forms documentation of the structure of your application, how it behaves and how data flows through it. When designing your types, aim to create structures that can be understood by the tools you use. A simple example is autocomplete, but more sophisticated examples are rename refactoring and the “go to definition” function of your IDE.

A concrete example that occurred in our app is i18n. There are a lot of existing JavaScript i18n libraries, but we decided not to use any of them and build our own simply because none of them had type definitions strict enough to be useful at compile time.

If you’ve used any typical i18n library before you’ll have seen a pattern like this, where you define your translations for each language in a tree structure and access them by dot separated string key:

The signature for getTranslation() is usually something like:

function getTranslation(key: string): string;

Unfortunately, this tells the compiler only that it takes a string and returns a string. Which means, for example, that your tools have no way to suggest translation keys for autocomplete.

Our solution is very simple: we expose an object per language (like germanTranslations above), and you access it through normal dot notation property access:

No magic. It’s just an object. The compiler knows what structure it has so our tools offer autocomplete suggestions each step of the way, it’s impossible to reference a translation key which isn’t defined, and refactoring tools in our IDE allow us to e.g. rename user in the translations object and have all references to it automatically renamed.

Take autocomplete seriously. If you’re typing something and autocomplete won’t work for someone using your solution, pick a different representation that your tools can understand.

Conclusion

Don’t consider your type system as something you tack on after you’ve finished writing your code. As developers, we know that anticipating how our solutions will be read, extended, and maintained are some of our key responsibilities, and crafting an effective type system for your application has a noticeable impact on all those things.

The type system you create shapes the conversation you’ll have as you and your team develop your product. It defines the terms your compiler will speak to you in, and the messages it will send you. It also defines the terms your teammates will use to talk about your code.

Take the time to think it through and find an expressive solution for your team and codebase. If you don’t agree with the guiding principles here, come up with your own and share them with your team. We found them very helpful in the development of our tools, I hope that they at least serve as inspiration for yours.


What did you think of this article? I’d love to hear from you!

I’m the frontend lead at Remerge, where we put a lot of thought into how we write our code. If you’re like us — passionate about writing the best damn applications you can — we’re looking for new teammates.

Our office is in Berlin, Germany on the top floor of a beautiful old building in the heart of the city. Read more about us here.

JavaScript in Plain English

Learn the web's most important programming language.

Will Madden

Written by

VP Engineering at Remerge. Passionate about making great things with great people.

JavaScript in Plain English

Learn the web's most important programming language.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade