The Squandered Potential of TypeScript

Some patterns to help you get the most type safety out of a TypeScript project

Orion Kindel
JavaScript in Plain English

--

Photo of someone covered in TypeScript runtime errors, by jesse orrico on Unsplash

Disclaimer: This article has been edited heavily and the tone has changed. Note that the title is no longer “TypeScript is not worth your time,” rather “Here are ways of extracting more value out of TS projects.”

TypeScript is not free. It comes with heavy development-time penalties and minimal safety guarantees, all while preserving the worst aspects of JavaScript.

TypeScript makes JavaScript harder to write, longer to develop, much harder to test*, and lulls you into a false sense of type security. Too often do people forget that writing TypeScript is playing pretend. You’re participating in a type hint system, not a type system. You get no runtime benefits or safety guarantees, and the bare minimum of type inference. Any value added by TypeScript is based on the assumption that you decorate everything you write with accurate types.
* Much harder to test if you’re using a DI runtime a la Angular or NestJS

Many of the blights of TypeScript can be seen as having fallen out of their goals of TypeScript being a “non-binary” language choice; that JavaScript interop and gradual TypeScript adoption come first, and the language makes no assumptions about the type of code you write.

This lack of vision has cemented the JavaScript (now TypeScript) community in some pretty heavy anti-patterns like using classes for dependency injection, inheritance for code re-use, and exception-based error handling.

TypeScript has definitely shown that they’re willing to add constructs that modify runtime behavior, namely in decorators and constructor properties — why not take type safety further? Why not discourage classes & inheritance? Why not force null value declaration and handling? Why not discourage exceptions? Why not encourage using types to encapsulate behavior?

What I wish TS included

Graceful error handling with the Result type

Most languages, including Javascript and Typescript use control-flow based error handling aka. exceptions.

This means that when a function reaches a state where it can’t continue, it throws an error, which is re-thrown by the function that called it, and the function that called that function until hopefully someone catches it.

This works OK for JS, not so for TypeScript. The big problem is that you can’t tell if a function may fail from its type signature. This means that any TypeScript function signature you look at has the potential to be lying.

This function says it returns a number, but if you pass a denominator of 0 it throws an error.

Enter Result<T, E>

Result (sometimes also called Either) is a special type that says “Either it went Ok and you have a T, or there was an Error and you have an E.”

A minimal definition may look like this:

Note that this is not perfect and more robust implementations are available as purify-ts/Either or fp-ts/Either.

This means the divide function above could be rewritten as:

This has some great benefits:

  • We can handle errors fluently (the same way we interact with Arrays; there is usually a Result.map, Result.bind, Result.isOk / isErr)
  • The type signature says “I can fail!”
  • The caller of divide has to check if the Result is ok in order to extract the value in order for TypeScript to compile
  • The error doesn’t bubble up the call stack until someone catches it

Graceful null-value handling with Option

This is less valuable if you have strict mode on and use null-coalesce operators, but still valuable nonetheless.

Similar to Result, we can use types to encapsulate whether or not a value is present:

Note that this is not perfect and more robust implementations are available as purify-ts/Maybe or fp-ts/Option.

  • We can handle nulls fluently (the same way we interact with Arrays; there is usually a Option.map, Option.bind, Option.isSome / isNone)
  • The type signature says “I might return nothing!” (which you can already do with Nullable)
  • Receivers of Options have to make sure the value is Some in order to get it from the Option

Composition instead of Inheritance

This one’s a little harder to imagine in a language committed to being a superset of another. There exist 3 ways of declaring types in TS; type, interface and class. type and interface are similar, allowing you to declare a function type but not implement it. class allows you to mix type definition and implementation.

A common example of modeling data as classes is shapes; e.g. Triangle, Quadrilateral. This example is usually presented as each shape derives from some base class called Shape.

The problem with this model is that if you’re using a base class to share interface & implementation, you’ve coupled Shape and the subclasses extremely tightly.

A better pattern is to use composition — flip the script from “Triangle is a Shape” to “Triangle has Area” and “Triangle has Sides

By replacing the base class with multiple single-purpose interfaces, we’ve composed the individual shapes from the qualities that each have. This means that each one can be extended or changed without affecting the others.

The composition pattern is taken to its limits in languages like Rust and Haskell, who replace classes and inheritance entirely with composition (like above) and algebraic data types.

More content at plainenglish.io

--

--

Advocating for quality, user experience, and thoughtful design in code and in products!