Efficient Typescript
One of the most useful ways that a type-system can work is by preventing a class of errors. The simplest way to interpret that is that it will prevent you from using a number
where you need a string
, but there’s more to it. Typescript allows you to encode more complex restrictions into the type-system, so the compiler can help you avoid making some mistakes.
It’s an issue I see frequently, even in popular library code. For example, let’s take this snippet from react-query
’s documentation:
There is here an implicit constraint that if isPending
or error
is present, then data
isn’t. But ensuring that constraint is a task that’s left to you, the fallible programmer. Typescript actually allows you to turn that implicit constraints into an explicit one, which means the compiler could be doing work so that you don’t have to think about it.
For example, if the typings were defined as such:
Then the compiler would have enough information to tell you that you can’t use data
if it is null
:
This is essentially an implementation of the age-old programming saying, “make illegal state unrepresentable”.
Proper error handling was pioneered by monadic languages such as Haskell, which has the Either
monad and its two subtypes, Left
and Right
. A more pragmatic approach exists in Rust, which has the Result
type and its two subtypes Ok
and Err
. Those are lessons that we should apply to modern programming to get both safe and ergonomic error handling.
One example of ergonomic error handling: Result
being a Functor means we can implement it as a class with a .map
method, so we could transform the value inside a Result
the same way we can operate on values inside an Array
(Ok
would apply the mapping function, while mapping an Err
would be a no-op).
This allows you to delay error handling for later, while still allowing you to transform the value. It’s very similar to how Promise
let’s you chain .then
calls even if you haven’t attached a .catch
handler yet. In fact, the cases are related because Promise
is basically a monad, bar a few minor details.
Other methods from category theory would also be applicable, such as .flatMap()
or .fold()
, which in turn allow new expressive ways to write code:
But the greatest benefit of this kind of error-handling is that it encodes failure directly in the type-system, so you know at compile-time if you haven’t dealt with a failure, rather than have to wait to catch
errors at runtime.
Opaque types
One feature that I really miss in Typescript compared to type-systems not based on structural typings is being able to declare new types that can hold the same value type without being able to mix them up. Here is an easy example:
How wonderful would it be if the compiler could prevent us from mixing values that shouldn’t be mixed! This is a constraint that we’ll often try to encode in the variable name (e.g. all width variables ending with ...Width
), but it’s a shame that the type-system can’t eliminate this type of error. It is possible to create somewhat opaque types that can’t be mixed, but because we can’t express which operators are valid for a type, the compiler would consider Width + Width
as an error if it was defined as such. So most of the time, using opaque types to add constraints is not worth the hassle.
One notable exception could be safe/unsafe strings. For example, if you’re building a templating engine and need to pass user-input strings, being able to have a SafeString
type could allow the compiler to ensure safety:
Conclusion
Please, add explicit constraints to your APIs. Well placed constraints are not limitations, they are actually freedom. It lets the compiler do the dirty work of checking all the minor details your overlooked, and let’s you build more reliable software.
Typescript also has much more depth than what I covered here, so if you want to understand how to encode more complex constraints, I highly recommend reading these posts:
https://zhenghao.io/posts/ts-never
https://zhenghao.io/posts/type-programming
https://zhenghao.io/posts/type-functions