Statically Typing Complexity with TypeScript

Christian de Botton
8 min readJan 14, 2019

--

What we’re building

There’s going to be a brief (I promise) wall of text before we get into some code, so entice you into continuing, here’s a video of what we’re building. A type safe implementation at a key access level for several methods that get, set, and update values in deeply nested, complex objects and arrays.

In this article I would like to discuss how to statically type and write a set of complex functions that provide an API with absolute type safety. Through this exercise, my goal is to highlight the strengths and weaknesses of TypeScript as a type system. I may have been in the minority leading up to this point, but I’ve been eagerly looking forward to statically typed JavaScript for a very long time. My first real experience with types and programming behind high school Java and C classes was pretty late. I spent a year writing Swift while building a native iPad application. It definitely changed how I thought about programming and shifted my mental model from scrambling to get things to work toward designing well thought out systems which worked because of a guarantee from the compiler.

I was very interested in, invested a lot of time into, and previously wrote about Facebook’s flow. However, to my disappointment, flow was not Swift. About 2 years ago I then moved over to TypeScript, which was also, unfortunately, not Swift. I also spent some time with OCaml and Rust. What I had previously perceived as shortcomings of Flow and TypeScript, I then came to realize that the ways in which these type systems were designed were meant to compliment the languages they were in service of. That isn’t to stay that they’re perfect. TypeScript (as well as Flow) have weak points. Whereas languages like Rust have limitations in the type system (which are currently being addressed) that prevent typings on async code, TypeScript and Flow have limitations that actually allow unsafe data to be passed through your code rather than preventing the code from being written. This was, I believe, a conscious decision on the part of Facebook and Microsoft to make adoption seem easier upfront. Unfortunately these lax rules leave it up to the developer to manage the effectiveness and safety that usage of types can guarantee.

Typescript’s strength is flexibility.

There are two broad categories of type systems, as described by Javiar Chávarri in this thread of Tweets. The first is Hindley-Milner, which describes the systems of Rust and OCaml, and then there are systems which are based on implicit subtyping. The former requires that every function only return one type. The tl;dr of this approach is that it effectively makes it harder for the engineer in the onset as the type system is harder to appease, but there is a stronger guarantee that there will be no runtime errors, and the toolchain can make better inferences about what the engineer intends to do.

Typescript allows for functions to return many different types, and also allows for an any type which acts as a wildcard but loses any guarantee of safety at the scope of that code. This puts the impetus of creating a sound type system more so in the engineers hands and also allows for a slight margin of error. This might be written off as a weakness, but could also be considered a strength. Let’s take a look at how we can use this flexibility to our advantage.

The three methods I would like to discuss writing are setIn, getIn, and updateIn. These three are excellent candidates to exemplify the strengths of Typescript because they model a complexity that other languages like OCaml and Rust would often struggle with. We need to accomplish the following:

1. Maintain that the keys over which we’re traversing into the object exist.

2. We need to ensure that the type returned from getIn properly references the value of the key we are pointing to.

3. In setIn and updateIn, we need to make sure that the value we are setting is the write type for the key we are referencing.

So let’s look at what these function signatures might look like, starting with the most simple: getIn.

First, what parts of the toolchain do we need? What are these types going to look like. Well, what’s the first thing in our function that we need to type? obj. We don’t know what the shape of that type will be, it can really be anything, so this is an excellent use case for a generic. Generic’s are a language feature of many modern type safe languages like Swift and Rust where we can use a placeholder to represent a type that we know nothing or very little about. What does that look like?

We’re declaring some type that is represented by the letter `T` and then saying that obj is that type. Some people prefer to use descriptive names for generics, some prefer using single letters, which is more mathematical. I will probably write another post about my thoughts on that later, but I figured I’d call it out now.

So wait, what about arrayOfNestedKeys? We have a problem, the signature for an array is SomeType[] (or Array<SomeType>), which represents a set of types that all conform to the same value. The best we can do with an array type is say the keys can be any of a set of values that conform to a type, in any order, which doesn’t give us any of the type safety (or IDE benefits) of explicit expectations around the keys.

We can, however, use a tuple type. In Typescript, a tuple sort of looks like an array, it describes a sequence of values that must occur in a specific order. This sounds exactly like what we want. Let’s update our signature to allow for the first key to be passed.

A lot happened here. The first thing we did was describe a second generic and put a constraint on it using the extends keyword, stating that it must be a key of the the first parameter using the keyof language feature. We then set the return type to the accessed value’s type. In Typescript we can access the type of a property of a known object using standard object access notation. At this point, if we had the following implementation, our types would provide the following feedback:

You’ll also notice, if you’re using an IDE like VSCode, as you begin typing the second parameter, it would actually suggest the keys available to you. Pretty cool, right?

Ok, so this only gets us access one level deep, which isn’t very useful. But what next? We’ll need to define several definitions for the arrayOfNestedKeys variable, and several definitions for the return type. Your first instinct might be to use a union type, ` | `. The problem with this approach is that we won’t be associating the correct return types with the correct path types. For example:

In this example, the return type can be any matching result at any matching index of our array of keys.

We need to describe a set of signatures that map out expected results for provided parameters. There’s a feature of Typescript and many other languages called overloading that we can take advantage of for this exact situation. Let’s build out our example a bit more and see what this will look like.

What did we just do? Typescript allows you to define multiple signatures for a function with types, omitting the function body. Notice in the actual implementation, we used any types, because the overloads stated beforehand are used rather than the any types. It is important that the function overloads are defined next to the actual function implementation, otherwise Typescript will not attach the type requirements to the function as desired. Looking at our previous implementation example, we will now have proper type traversal. Also, since the object’s shape is a generic, and we’re looking up keys of that object, we can traverse arrays for free.

This code will give us coverage of nested keys up to 5 levels deep. If we were creating an API for use by others, we might continue down this path for more iterations.

We can easily extrapolate these types for a setIn function, which would have the following parameters:

If you’ve followed through the previous examples, this will hopefully make some sense. We don’t know about the type of the first parameter, so we assign a generic. Since the second parameter needs to be a tuple of keys, since we care about the orders and types of, we need to define a generic that represents each key. We have one new, third, parameter in this function, which is the value we want to set. We want to make sure that the type of the value we’re setting matches the type of the value at the key we’re traversing into, and luckily, we’ve done all of the work to figure that out. This is the same type as the value returned from our getIn implementation.

Finally, since we’re writing a set of immutable functions, the method should return the new object with changes made immutably. Here’s an abbreviated implementation:

Lastly, let’s take a look at how we might use this same approach to write an updateIn method. With this, we want to similarly work with an object, traverse a set of keys, but we now want to be able to pass a function to the third parameter which provides access to the previously set value, allowing you to return a new updated value. We’ll skip deriving the types and just look at an abbreviated bit of code.

You’ll notice that in our implementations for all of these functions we are typing out the parameters and return types as any. Under any other circumstance, this would be a bad thing to do. We lose type safety in our implementations. The benefit is that we gain type safety at a key access level in the API that we create. I haven’t tried, but I suppose you could create a type safe implementation, possibly, using another set of language features, conditional types and type inferences, but that would become unwieldy really quickly. Maybe we can save that one for another article?

Hopefully this gives you an idea of the power of the flexibility of in Typescript. That power does come at the cost of an increased responsibility as the implementing engineer, but gives you the tools to create flexible, safe tools for others to use.

--

--