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.
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
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 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 and explicitly ignoring any exceptions.
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.
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.
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.
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!
There's a set of typescript-eslint rules called
prefer-readonly-parameter-typeswhich 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. ↩︎
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 a
t.readonlyDeep(A)that builds on
ts-fest(and it's not too difficult). ↩︎