Follow the type

TypeScript has been around for a while, in many new projects it started to be a standard, even old projects slowly migrate into TS. That is really good, good until we use it with common sense. In this article I will show how we can create a problem by doing things in contrast to the type system.

I am not sure if I have you

We have following types in our codebase

type Movie = {
  uuid: string,
  title: string,
  comments: Comment[]
}
type Comment = {
  uuid: string,
  content: string,
}

Now these types are used by some function which is responsible for showing comments. In our example this function will be React component

const Comments = (movie: Movie) => {
  if (movie?.comments?.length > 0) {
    return movie.comments.map(comment =>
      <p>comment?.content</p>)
  } else {
    return "No comments"
  }
}

Yes it works, but... But we have used a lot of optional chaining operator and what was the reason?

My code says A, my type says B

We use TypeScript, so we should seek for the reason in types, and our type definitions say following things:

  • movie is always there
  • movie always has comments array
  • comments array have comment objects inside

And our code says:

  • movie can be not there
  • movie can not have comments array
  • comments array can have elements with null/undefined values

Ok, so why do we need types if we don't believe them. The whole idea of having type annotation is to have live documentation of our assumptions for the code. Now we have different type assumptions and clear indications in the code that we don't believe in them. And such a situation is very risky, if we continue doing that the whole project will start to be unstable, as nobody will believe that type is correct. Such a thing ends very badly, and better would be to not have a type system at all.

Now some points in defence of this approach which I have heard:

  • But we always can get corrupted data
  • But BE can send null or undefined

Yes BE can send smth wrong, but it does not mean we on the FE side should "fix" broken data. And to be clear, using such a defensive approach doesn't fix anything, it just hides under the carpet real problems, leaving application still not working properly. Although BE can break our data and contract in so many ways that trying to defend that is more like Sisyphean work and nothing more.

Code always has some data assumptions, even this code without types. If you access an object by property "name" it means your code assumes there is an object with such property. Everything we do has some assumptions over data we transform, types only show these assumptions in an explicit way. Having explicit assumptions different from implicit one (these in code directly) means we have two different data assumptions.

But the problem is real

What if we really see that comments sometimes are not in movie object?

Aha, yes so we should use optional chaining then, but we should firstly change the contract, and contract is our type definition.

type Movie = {
  uuid: string,
  title: string,
  comments?: Comment[] // optional property
}
type Comment = {
  uuid: string,
  content: string,
}

Pay attention that comments is now optional property, and TS will now check if we do the check before we use this property as an array. Now after the type change we can follow the type doing code changes. In that way, types always define the contract and code follows them.

I want full defence though

Ok, I get that. We don't want the code to fail, we want to show some info to the user rather than just having an unexpected crash. That is reasonable, but doing defensive checks everywhere without knowing what to do in the negative path is no solution.

Make the defence, but as close to the data source as possible. In Elm world for example nothing can get to your application code before it will not be validated and parsed to the wanted form by using type constructors. This thing is called decoder. And yes, even in TS we can follow this kind of defence, so not believe third parties, and servers that they send valid data. Just validate that data, and if something is different than assumption, show some user friendly error, as our app doesn't work with this case. For example one of projects which does that is io-ts, or runtimes. Also we can validate types manually by creating our own decoders, but this will be hard as we need to have a way to keep these decoders aligned with types always. But yes, it can be done. And the simplest start of such decoders is having them as function from unknown to our wanted type.

Know how to defend yourself

Doing defensive checks in every place of your codebase, even though types say differently is a special kind of foot gun. Don't do it, believe in your types, make the guard close to the source of the data, do not assume wrong or corrupted data can go through your app, as if so, it cannot be fixed in a meaningful way outside of having a validator/decoder before the data flows through your app.

22