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 pipe
s and task
s. 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 (orEither
, 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!
Or you can use ts-essentials'
DeepReadonly
. ↩︎There's a set of typescript-eslint rules called
prefer-readonly
andprefer-readonly-parameter-types
which would bring you some of the way, but they're not nearly as broad and advanced asprefer-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. ↩︎Disclaimer here: I created ts-strict-error. ↩︎
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 at.readonlyDeep(A)
that builds onts-fest
(and it's not too difficult). ↩︎