We’re not writing our code in TypeScript, why?

Photo by Philip Swinburn on Unsplash
TypeScript is an amazing project, the support for it in tools like Visual Studio Code is amazing and the type system is pretty great. So why are we not writing our code in TypeScript?

The answer to that is a bit more subtle than a simple list of reasons, so I will break this article up in a few chunks. Feel free to pick and choose what you want to read.

  • Types? — What are types and what can you do with them?
  • A journey of adding types with TypeScript — I will guide you step by step turning a few lines of JavaScript into TypeScript, showing different challenges along the way.
  • The const problem — A serious threat to readability, partly caused by the desire to improve readability in TypeScript projects.
  • Finding the balance — Dealing with types is a game of trade-offs, what questions can you ask and where does TypeScript place itself?
  • Kaliber and TypeScript — How do we deal with TypeScript at Kaliber?

Types?

TypeScript is popular and some people like it so much that they refuse to write plain JavaScript. I have been programming for quite some time and have seen a lot of discussions about typed vs untyped (some may say: single typed) languages. My main takeaway is that people coming from a strongly typed background tend to favor the freedom of not having types and people coming from a non-typed background tend to favor the help the types give them. People with experience on both sides are generally more relaxed towards types.

Types are an interesting topic. As you use them more often, you’ll want to express more with them. This quickly becomes a cycle where the types increase in complexity.

For some of us the puzzles that can be solved with a type system are deeply satisfying. Getting the type inference just right and the journey to find a way to express a concept in great detail tickles the brain in just the right way.

You will eventually come across a thing called Category Theory, maybe side track into Type Theory, definitely encounter Function Programming and maybe even get a taste of languages like Idris. What I want to say is: before you know it you are not working on your code anymore, instead you are on an Alice in Wonderland style adventure. Discovering a world full of mathematical constructs that describe constructs of programming in a unique way, satisfying a desire you never knew was there.

Two programming languages

Eventually you might realize that a programming language with a sufficiently interesting type system is actually two programming languages rolled into one. One language for the runtime stuff and one language for the compile time stuff. The one we use for compile time stuff is the type system.

  • At runtime we might create an array like this: Array('a')
  • At compile time we might declare an array like this: Array<string>

Do you see the similarity? They are both ‘function calls’. One constructs an array value and the other constructs an array type. A question that might be interesting to ask:

  • What exactly is that second programming language, what can we do with it?

I often hear proponents of TypeScript talk about the safety net it provides. This however is only a small benefit you might get and can, in some cases, be disputed (you can look into discussions about ‘soundness’ if you are interested in this). To answer the first part of the question, there is this thing in computer science that I remember as “propositions as types and proofs as programs”, it is formally known as the Curry Howard Correspondence.

Propositions as Types and Proofs as Programs

This correspondence gives us a great insight into what this second programming language actually is: it is a way to write down a proposition (a formal statement of a theorem or problem). It also tells us something about the first language: it is a way to prove something. Don’t worry if this last sentence does not offer any insight, you might get a feel for this in a minute.

Now for the second part of the question: what can we do with it?

  • You could ask a question like this: ‘If I have something of type X, what can I do with it?’. This can help greatly with code / library discovery and code completion.
  • If you have a clear description of what you want to prove, it limits the options for constructing the proof. In simpler terms: it guides you when writing code.
  • A proof checker will let us know when we are not proving what we said we would be proving. This is the safety net that we know as ‘type errors’.
  • So, that’s great right? Why wouldn’t you want these things? Well, they come at a cost.

Before we dive in TypeScript specifics, let’s go back to the concept of ‘propositions as types and proofs as programs’. One answer to the question ‘What does my program prove?’ is: ‘Write types for it and you will know’. You might see here that this creates a form of friction: the way you can write your proof (runtime code) is limited by the expressiveness of the language of your propositions (type system). How to deal with this?

  • We can conform to the limitations and trade expressiveness for proven correctness. Please realize that the more expressive version does not have to be wrong, it simply can not be proven with the ‘limited’ type system.
  • Lift the restrictions and trade correctness for expressiveness. This can however be highly tricky because the situations that require this trade-off would often benefit from ‘proven correctness’.

A journey of adding types with TypeScript

With this background and (hopefully deeper) understanding about type systems I feel we can now talk a bit more about TypeScript.

In this chapter we will go on a journey of adding types to a JavaScript function.

Our example will involve the reduce function. Let’s start with this JavaScript function:

1
2function convert(a, { includedTypes }) {
3  return a.reduce(
4    (result, x) => includedTypes.includes(x.type) ? { ...result, [x.id]: x } : result,
5    null
6  )
7}

It receives an array of objects that at least have a type and id property. Based on the given array of includedTypes it returns an object with certain ids as keys or null if there were no matches.

Adding types

Now let’s add some types.

1function convert(
2  a: Array<{ id: string, type: string }>,
3  { includedTypes }: { includedTypes: Array<string> }
4) {
5  return a.reduce(
6    (result, x) => includedTypes.includes(x.type) ? { ...result, [x.id]: x } : result,
7    null
8  )
9}

Reducing type noise

That adds quite a bit of noise, so let’s try to minimize that noise without sacrificing too much.

1type ConvertInput = { id: string, type: string }
2
3function convert(a: Array<ConvertInput>, o: { includedTypes: Array<string> }) {
4  return a.reduce(
5    (result, x) => o.includedTypes.includes(x.type) ? { ...result, [x.id]: x } : result,
6    null
7  )
8}

Our first problem

Now let’s use our function:

1const x = convert([{ id: '1', type: 'a' }], { includedTypes: ['b'] })
2console.log(x.id)

Hmm, that does not look right, why did TypeScript suggest x.id?

It seems TypeScript used this definition of the reduce function:

1reduce(
2  callbackfn: (previousValue: T, currentValue: T, currentIndex: number, array: T[]) => T,
3  initialValue: T
4): T

This means it thinks the result will be an element T of the original array. You might point out that this is not an problem with TypeScript but with the type definitions. However these type definitions come from the TypeScript repository.

Helping TypeScript

Let’s help TypeScript select a more sophisticated definition by supplying a type parameter to the reduce function.

1type ConvertInput = { id: string, type: string }
2
3function convert( a: Array<ConvertInput>, o: { includedTypes: Array<string> } ) {
4  return a.reduce<{ [id: string]: ConvertInput }>(
5    (result, x) => o.includedTypes.includes(x.type) ? { ...result, [x.id]: x } : result,
6    null
7  )
8}
9view raw

This is better, now the function returns a more correct type and allows us to use it like this:

1const x = convert([{ id: '1', type: 'a' }], { includedTypes: ['b'] })
2console.log(x['1'].id)

Introducing strict

I forgot to set the strict option in the TypeScript configuration. With strict it would select the correct definition from the get-go, allowing us to remove the ‘hint’.

1
2type ConvertInput = { id: string, type: string }
3
4function convert( a: Array<ConvertInput>, o: { includedTypes: Array<string> } ) {
5  return a.reduce(
6    (result, x) => o.includedTypes.includes(x.type) ? { ...result, [x.id]: x } : result,
7                                                           ^^^^^^
8    null
9  )
10}

But now we have an error telling us that ‘Spread types may only be created from object types’. TypeScript is wrong on this one as the specification states:

If source is undefined or null, let keys be a new empty List.

But that is not the point, there are various ways to get rid of the error, we will ignore it for now and move on. Look at the more sophisticated definition of reduce:

1reduce<U>(
2  callbackfn: (previousValue: U, currentValue: T, currentIndex: number, array: T[]) => U,
3  initialValue: U
4): U

A problem with the supplied reduce definition

It states that the initialValue, the previousValue and the result of callbackfn should all be of the same type U. For us that is not what we are trying to prove. A naive ‘more correct’ version that would come closer to what we need would be this:

1reduce<U, V>(
2  callbackfn: (previousValue: U | V, currentValue: T, currentIndex: number, array: T[]) => U,
3  initialValue: V
4): U | V

While this more clearly states that there can be a difference between an initialValue V and the result of callbackfn U, it does not solve our problems and we would still have a compilation error.

Different ways of dealing with inaccuracy of type definitions

There is generally three outcomes when people land in this situation:

  • For someone like me this will become an interesting journey trying to find a way to get a correct signature for reduce that will work nicely with type inference.
  • Other people will simply help TypeScript by providing a value for the type argument of the reduce function.
  • Less experienced people will search the web and might choose a less optimal approach (casting for example).

Let’s try to be sensible and help TypeScript by supplying the type parameter in order to move on.

1type ConvertInput = { id: string, type: string }
2
3function convert( a: Array<ConvertInput>, o: { includedTypes: Array<string> } ) {
4  return a.reduce<null | { [id: string]: ConvertInput }>(
5    (result, x) => o.includedTypes.includes(x.type) ? { ...result, [x.id]: x } : result,
6    null
7  )
8}

As a side note: I find it interesting that TypeScript does not complain about the spread of result anymore while its type is null | { [id: string]: ConvertInput }. The null is still in there, but apparently having it in an | with an object changes things.

Are we now done converting it to TypeScript?

A missing use case

Not really, let’s look at the following use case:

1convert([{ id: '1', type: 'a', value: 3 }], { includedTypes: ['b'] })

Our convert function does not care about the value property, but to let this compile we should include it in the signature of ConvertInput right? Well, we actually want to say that we need an object with at least an id and type property, we don’t care about the other properties. So a simple approach would be this:

1type ConvertInput = { id: string, type: string, [other: string]: any }

The problem here is that after we call the convert function, we can no longer ‘see’ the value property and its type. We are effectively ‘losing’ information.

Introducing a type parameter

In order to fix that and ‘keep’ the information we need to use a type parameter.

1type ConvertInput = { id: string, type: string }
2
3function convert<T extends ConvertInput>( a: Array<T>, o: { includedTypes: Array<string> } ) {
4  return a.reduce<null | { [id: string]: T }>(
5    (result, x) => o.includedTypes.includes(x.type) ? { ...result, [x.id]: x } : result,
6    null
7  )
8}

Here we are stating that the incoming Array contains something we call T that extends (also has the properties of) the ConvertInput.

That’s it, that’s where we stop. We can now use our function and get ‘correct’ type hinting.

1const x = convert(
2  [{ id: '1', type: 'a', value: 3 }, { id: '2', type: 'b' }],
3  { includedTypes: ['b'] }
4)
5if (x !== null) console.log(x['1'].value)

Not all runtime exceptions are prevented

But wait, while this compiles, it would still throw a runtime exception. The type 'a' (associated with id '1') is not included in includedTypes, shouldn’t this give a type error?

Well, if you are inclined to find the type definitions required to make TypeScript tell you about this problem, you are like me. My suggestion for you is: don’t go down that rabbit hole while burning someone else’s money.

Comparison between the JavaScript and TypeScript version

Here is the original function again:


1function convert(a, { includedTypes }) {
2  return a.reduce(
3    (result, x) => includedTypes.includes(x.type) ? { ...result, [x.id]: x } : result,
4    null
5  )
6}

And here is the TypeScript version:

1type ConvertInput = { id: string, type: string }
2
3function convert<T extends ConvertInput>( a: Array<T>, o: { includedTypes: Array<string> } ) {
4  return a.reduce<null | { [id: string]: T }>(
5    (result, x) => o.includedTypes.includes(x.type) ? { ...result, [x.id]: x } : result,
6    null
7  )
8}

A few observations about these functions and the process to get here:

  • The TypeScript version is clearly harder to read.
  • The TypeScript version has more information. Experienced TypeScript programmers can more easily remove the type related code in their minds and focus on the runtime behavior. Inversely they can more easily switch to focus on the types.
  • People like me are likely off on an adventure for days to discover what the limitations are of TypeScript as a result of trying to create a ‘better’ type for reduce.
  • It took quite some steps and quite some knowledge to get to a ‘correct’ working version.
  • When using the typed version you can more easily discover what it does (code hinting).

The const problem

To combat the problem with readability it has become quite popular to use the following approach.

1type ConvertFunction = <T extends { id: string, type: string }>
2  (a: Array<T>, o: { includedTypes: Array<string> }) => null | { [id: string]: T }
3
4const convert: ConvertFunction = (a, { includedTypes }) => {
5  return a.reduce(
6    (result, x) => includedTypes.includes(x.type) ? { ...result, [x.id]: x } : result,
7    null
8  )
9}

Here you see most of the type definition is removed from the code, creating a more clear separation of the two languages. This allows you to more conveniently switch between reading the contract (or proposition or compile time code) and reading the method (or proof or runtime code).

The downside with that approach is that you lose ‘hoisting’: we can no longer use the functions before we define them. ‘hoisting’ allows us to write code ‘top down’, like a book (and all of our non-programming use of language). We read things from top to bottom everywhere. Without hoisting we have to scroll down, creating noise in our brains by ignoring details, to get to the beginning of our code.

Some people might argue that it is ‘weird’ to call a function before it is defined. I am however strongly convinced that it is a lot better to structure code in a way that clearly communicates its structure and meaning. Start with an overview and go into more and more detail if you wish to do so. Starting point, overview and coarse grained steps at the top of the file, more details and smaller steps at the bottom of the file.

You might be interested in comparing the const approach to one using JsDoc:

1/**
2* @template {{ id: string, type: string }} T
3* @param {Array<T>} a
4* @param {{ includedTypes: Array<string> }} o
5* @returns {null | { [id: string]: T }}
6*/
7function convert(a, { includedTypes }) {
8  return a.reduce(
9    (result, x) => includedTypes.includes(x.type) ? { …result, [x.id]: x } : result,
10    null
11  )
12}

This moves the type signatures into JsDoc style comments. If you have checkJs and allowJs set to true in your tsconfig.json file, you will get the exact same result as the const approach. The upside is that you will not lose ‘hoisting’ and is this version is arguably more readable. A big downside is that you need a third language: JsDoc.

Finding the right balance

If you’ve read all the way up to this point, chances are your brain is about to explode. Or maybe you’re still with me and are wondering about what this all means.

I can not guess the state of your brain so I will share what I think.

When it comes to working with TypeScript or any other typed language there are a few factors that require thoughts and decisions:

  • How do I deal with mixing the compile time and runtime worlds?
  • How do I deal with the tension between expressiveness and correctness?
  • What is the stance of the type system towards these things?

I personally think types should be clearly separated from runtime code. It improves readability and allows me to easily shift my attention between behavior and specification. TypeScript provides me with great tools to do that:

  • Type inference — TypeScript can figure out quite a lot about the types of my code without me writing types.
  • JsDoc comments — I can add extra typing information in comments that I can hide.
  • *.d.ts files — TypeScript allows me to write definition files separate from my runtime source code.

Side note: It would be awesome if VSCode would have the ability to hide types or hide runtime code.

When it comes to the trade-off between expressiveness and correctness TypeScript clearly chose the expressiveness side. Their method to do so is highly influenced by ‘ease of use for a large percentage of use-cases’. This approach forces you to ‘help’ TypeScript in more complex situations.

TypeScript also limits itself. For example, it can not infer the type of function arguments based on its usage. While this would improve expressiveness, it would also add a lot of complexity to the code base. If you are interested in this sort of thing, a project that is researching this space is Hegel.

In any case, try to make informed decisions and be aware of the trade-offs.

Kaliber and TypeScript

Given all of the subtleties and examples in the previous chapters, we at Kaliber came to the following conclusions. Please note:

This is what we think, these are not universal truths, you need to find out for yourself where you stand regarding the different trade-offs.
  • We love TypeScript.
  • We want the help of TypeScript (code hinting) for specific situations.
  • We want a safety net (TypeScript type errors) in some situations.
  • We think the cost of writing everything in TypeScript is too high.
  • The learning curve of TypeScript is substantial.
  • TypeScript limits expressiveness to a certain degree.
  • TypeScript degrades readability too much.
  • TypeScript causes some people (like me) to accidentally go on time consuming type related adventures.
  • We think it’s too complicated to write propositions in TypeScript for cases that could really benefit from it (example: definition of the route map from @kaliber/routing).

From these conclusions we extracted a set of rules:

  • We do not write our runtime code in `*.ts` files. Forcing types into our runtime code causes too many pitfalls (as described in this article).
  • Every project should have a tsconfig.json file. This ensures we get the help of TypeScript where it can determine types.
  • React components should destructure their properties. This is enforced by our eslint rules and gives us code hinting and a safety net when using a component and its attributes.
  • Help TypeScript with JsDoc comments when its not too time consuming. This can greatly improve code hinting and prevents typo’s. For example:
1/** @returns {typeof firebase} */
2function useFirebase() {
3  ...
  • If an object structure is used in a lot of places, think about adding a type for it in a *.d.ts file. This allows us to annotate function arguments and return statements.
  • Try to provide types for libraries. If they cause too much noise or too many errors when written in JsDoc, write a *.d.ts file with the type signatures. With libraries the trade-off is a bit different. The code is used more often and having types helps with discovery.

While this is a reasonably short list of rules, we find it to be a great way to get the most out of TypeScript without it costing too much. We lose some type safety compared to writing in .ts files, but we don’t fall prey to a false sense of ‘soundness’.

Even if you, after reading this article, make different choices, I hope you gained some insight or inspiration. Thank you for reading.