4 min read

Predictable programming 2: making Typescript more like Rust

This is the second post in a series of blog posts where I explore ways to make Typescript development more predictable. I describe my motivations in-depth in my first post. In this post, I'll focus on a list of concrete techniques to make Typescript development more predictable.

Optional results

Typescript by itself provides a lot of great protections already, but it's not enough to fully satisfy my requirements. The biggest change from regular Typescript development will be transitioning largely to fp-ts paradigms. Its Option<T> type and tooling are a great replacement for a lot of Rust's equivalent tools, and the Either<E, A> type is a good start for an equivalent of Rust's Result<A, E> type.

I don't believe these are necessarily complete solutions: we don't get the ? operator ergonomics here, which means that a lot of function definitions start looking very different. It forces the user to use fp-ts's less intuitive pipes and tasks. There are many alternatives that I'll be looking at, like neverthrow, ts-belt, boxed, true-myth and others. Maybe one of these has better ergonomics.

However, I don't see how anything can add a ? operator. The most signficant improvement we may get is the pipeline operator, which could replace calls to pipe with a more readable syntax.

Read or write?

A commonly hailed paradigm even in current frontend development is immutability. To minimize side-effects, it's become common practice to avoid ever modifying objects. In Rust, this is actually enforced by the type system through mut variables: everything's immutable by default. While we probably can't reproduce that in Typescript, we can get quite far by just making as many things immutable as possible.

Typescript comes with the readonly keyword and some built-in readonly types that we can use on our function definitions. For types coming from outside of our system we can use type-fest's ReadonlyDeep[1] to restrict it.

However, this does not enforce "immutable by default", and it's quite difficult to do so. There are some open issues on Typescript's Github (and a TC39 proposal) that would make this a lot easier, but for now we'll have to rely on linting. For that, I recommend the functional/prefer-immutable-types[2] and explicitly ignoring any exceptions.

Ergonomics

One of the big gamechangers of my Typescript work has been ts-pattern. It works almost the same as Rust's match statement, can do exhaustive matching on every type, extractions from patterns, etc. It's great.

Additionally errors in Javascript are awful to work with. But there is a cure, and combining ts-strict-error[3] with ts-pattern makes for a much nicer experience handling errors.

Guarding the gateways

Many of the errors I've run into (both in Typescript and in other languages) stem from badly defined or badly followed API contracts. Nowadays, I make sure to always validate data at the boundary. Either I use a strictly validated codegen library, or I use something like io-ts for defining and validating the data structure. I use this for APIs without a client library, but also sometimes for flaky external libraries. Just be strict about your typings, and make sure to make everything readonly[4].

There are almost no Javascript libraries I've seen that properly encode their errors. This makes it extremely difficult to work with the failure cases of external libraries. I am starting to build out a library of convertors for external libraries inside ts-strict-error, but it is so far incomplete and I very much welcome pull requests.

Linting

The last big component of making this work will be static analysis of our code. A lot of lints already exist that we can reuse. I'll list some lints and why I believe you should activate them:

  • functional/prefer-immutable-types: as explained before, types should only be mutable when we need them to be mutable, and we should always be explicit about this where we use them.
  • @typescript-eslint/explicit-function-return-type: in Rust, all functions must have an explicit return type defined. Automatically inferring types is great, but being explicit at some points helps prevent type inferrence accidents across the board. I find functions to be a perfect boundary for this.
  • functional/no-throw-statements: errors should be returned as results (or Either, in fp-ts), never thrown.

Remember that you can explicitly ignore eslint rules with // eslint-disable-line rule-name -- comment and use it whenever needed. I recommend using eslint-comments/require-description to ensure you're always explaining why.

One more thing I'd like to lint for, but can't, is wrapping external calls in fp-ts's either.tryCatch, to map any errors from external libraries into StrictErrors. This would ensure that all errors are explicitly caught and handled at calling time. I might try to write a custom lint for this to try it out.

What's next?

I'm collecting these recommendations in my digital garden for now. While testing out this style guide of sorts, I will also be amending that page. If I ever get to a significantly nicer version, I'll probably write another blog post here.

Additionally, there's another blog post coming up in this series. One major improvement in my frontend development workflow has been state machines. Subscribe for email notifications or to the RSS feed to learn more about developing predictable frontends with XState.js!


  1. Or you can use ts-essentials' DeepReadonly. ↩︎

  2. There's a set of typescript-eslint rules called prefer-readonly and prefer-readonly-parameter-types which would bring you some of the way, but they're not nearly as broad and advanced as prefer-immutable-types. The latter applies everywhere, not just in parameters and classes, and it actually does some smart type analysis to see if the types are actually immutable, and not just readonly. ↩︎

  3. Disclaimer here: I created ts-strict-error. ↩︎

  4. io-ts has a t.readonly(A) type, but that doesn't provide deep readonly capabilities. However, it's probably a good exercise anyway to implement a t.readonlyDeep(A) that builds on ts-fest (and it's not too difficult). ↩︎