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:

const { isPending, error, data } = useQuery({
  queryKey: ['repoData'],
  queryFn: async () => fetch(
    'https://api.github.com/repos/TanStack/query'
  ),
});
 
if (isPending) return 'Loading...'
 
if (error) return 'Error: ' + error.message
 
return (
  <div>
    <h1>{data.full_name}</h1>
    <p>{data.description}</p>
  </div>
)

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:

type PendingState   = { isPending: true,  error: null,  data: null }
type ErrorState     = { isPending: false, error: Error, data: null }
type LoadedState<T> = { isPending: false, error: null,  data: T }
 
type QueryResult<T> = PendingState | ErrorState | LoadedState<T>

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).

function findUser(): Result<User>;
 
const name = findUser().map(user => user.name) // Result<string>

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:

const result = useQuery()
 
return result.fold(
  () => <Spinner />,
  error => <Message error={error} />,
  data => <p>{data.name}</p>,
)

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:

type Width  = number
type Height = number
 
const baseHeight = 50  as Height
const baseWidth  = 100 as Width
 
const sectionWidth = 40 as Width
const diff  = baseWidth - sectionWidth
//    ^ is of type `Width`
 
// ...then later in the code
const newValue = baseHeight - diff
// Error, we are mixing Height and Width!
// But TS won't warn you :(

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:

declare const __safe: unique symbol;
type SafeString = string & { [__safe]: true };
 
function escape(value: string): SafeString {
  /* ... */
}
 
function format(template: string, values: Record<string, SafeString>) {
  /* ... */
}
 
// then use it as such
format(
  "<html><p>User name: %name%</p></html>",
  {
    name: "Unsafe! <script src=\"https://attacker.com/script.js\" />"
    //    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    //    Type 'string' is not assignable to type 'SafeString'.
  }
)

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