Tagged with

Over the past few months, I've been experimenting with leveraging TypeScript's type system to give us more reliable code that lines up well with our product and customer success teams' language, in true domain-driven design fashion. Along the way I've been poking at lots of different corners of TypeScript's type system. It turns out that while it has a lot of quirks that arise from needing to be a strict superset of (and able to accurately type) JavaScript, it also has an incredibly capable type system. It's no exaggeration to say that TypeScript has one of the most advanced structural type systems in the wild; it's certainly at the cutting edge of anything that's not a research language.1 One of the most interesting things it can do is literal types, so in this post we're going to explore a few of the wild and crazy things those literal types can let us do.

First: what is a literal type? It's a type definition whose type is any value you'd simply type out exactly. The most common of these (we'll get to the less common ones in a bit) are strings, numbers, and booleans. So if we had these type definitions:

type ThisIsASpecificString = "very, very specific"
type ThisIsOnlyEver42 = 42
type ThisCannotBeFalse = true

Then these would compile:

const validString: ThisIsASpecificString: "very, very specific"
const validNum: ThisIsOnlyEver42 = 42
const onlyTrue: ThisCannotBeFalse = true

These, on the other hand, would all be type errors:

const otherString: ThisIsASpecificString = "not that string"
const not42: ThisIsOnlyEver42 = 100
const somethingFalse: ThisCannotBeFalse = false

This is a kind of narrowing of the types. Anything with the type ThisIsASpecificString is of course a string, but it's much more specifically defined than that. Not just any string will do – only this specific string. The same is true of 42 and true. That last one is particularly notable: ThisCannotBeFalse is not the same as a boolean. You can't assign it false, and accordingly although you can use it wherever you'd use a boolean, the same is not true in reverse. In fact, if you're using plain old true, you have to write the slightly strange invocation true as true to get it to typecheck!

Now, with union types, we can them combine and stack these:

type OneOfThose =
  | ThisIsASpecificString
  | ThisIsOnlyEver42
  | ThisCannotBeFalse

Then these are fine:

const something: OneOfThose = 42
const valid: OneOfThose = "very, very specific"
const neatUnion: OneOfThose = true

But these aren't:

const somethingWrong: OneOfThose = 43
const evenWorse: OneOfThose = false
const willNeverCompile: OneOfThose = "nope nope nope"

Of course, most of the time you wouldn’t be combining a random string, a random integer, and a boolean value in a union type—you'd be doing it with various strings. For example, prior to TypeScript 2.4, the only way you had to account for scenarios where an argument could take one of a set of string values as its arguments—e.g. when using them as ways of dispatching the function differently—the only way to do it was using a union of several different string values. For example, at one spot in the UI of our application we needed to specify which step a given process was in

type Status = 'Pending' | 'Active' | 'Completed' | 'Error'

Now, in another language, we might have modeled these as basic union types, which often don't have to have a value. For example, in Elm, that would have simply been:

type Status = Pending | Active | Completed | Error

But in TypeScript, union types can't be just bare variant names. They can only be other types. But since the point is to model the kinds of things that JavaScript actually does, that has to include things like strings. And the TypeScript language designers very sensibly decided that since they were supporting literal types, they would support literal types more generally. In fact, in a turn that might surprise you (it certainly did me!), any literal can be a type in TypeScript.

type LikeObjects =
  { anyOld: 'value will do'
  , evenObjects: true
  , andEvenTuples: ['like', 'this', 1]
  }

The only thing that's valid to assign to that type is something which includes those exact values. TypeScript's literal types are just that: types which correspond to any literal you can write out in JavaScript. Interestingly and surprisingly, this exact capability isn't explicitly documented anywhere in the TypeScript handbook, though you can infer it from other things which are documented there (see especially the discussions of type aliases in general and string literal types in particular). The only way to learn this particular bit is to read the spec or to just play with it. (I just did a lot of experimenting.)

Okay, so that's all interesting enough if you're a type theory nerd, but how would you actually put that to use? Besides the aforementioned (and fairly common) scenario of needing to limit arguments to a specific set of strings—not least because as of TypeScript 2.4 that's better covered by string enums—this can be a very useful tool for constraining types which have to have certain values under certain conditions.

type Some<T> = { isSome: true, value: T }
type None = { isSome: false }

type Option<T> = Some<T> | None

(This isn't actually a good implementation of an optional type. For lots of reasons I'd implement it as a pair of private constructable classes which conform to an IOption<T> interface, with public standalone functions as constructors. But it will do as a good-enough example here!)

Let's say we now wanted to write standalone constructor functions for those, some and none:

const some = <T>(value: T): Some<T> => ({ isSome: true, value })

const none = (): None => ({ isSome: false })

Now, let's say we had been silly when creating the none() function there, and just copied and pasted the result from some() and then deleted the value element. We'd have this:

const none = (): None => ({ isSome: true })

And that would fail to typecheck, because the object literal value would be wrong for the None type!

I put this to good use recently. We had a set of types where they all had in common a boolean property for whether the given input type could be repeated, but given instances of the type could only have either true or false. To help with building out a set of transformation functions and make sure that I got them right, I just defined that in the types up front:

type Variant1 =
  { repeatable: false
  , // other definitions...
  }

type Variant2 =
  { repeatable: true
  , // other definitions...
  }

// more variants...

Then anywhere I just needed to have any one of those types, I could combine them into a union:

type Variants = Variant1 | Variant2

This had two really powerful effects:

  1. I knew I had the repeatable value available anywhere I used Variants, because I'd encoded it into the types at the most basic level. It's impossible to construct a type-checking Variant without a repeatable property. That meant that I could rely on its presence as I built out the business logic and the display logic which depended on that rule.

  2. I could construct the variants individually, but if I did so the compiler made sure I got the properties correct along the way. This helped because there were variants that were closely related to each other but with different values for repeatable; I could do things like this:

    const variant2FromVariant1 =
      (v1: Variant1): Variant2 => ({ ...v1, repeatable: false })
    

    If I had failed to write that repeatable: false at the end, the compiler would have let me know—and in fact, that's exactly what happened when I added that field! All my existing implementation and tests started failing to compile, and I was able to just go chase down each failure and fix it.

If you haven't played with literal types in TypeScript, I strongly encourage you to take a look and see what you can do with them! They're surprisingly powerful. In fact, they're sophisticated enough that I end up missing them even when I use other languages whose type systems I like better in many other ways!


  1. Much the same could be said of Flow; I'm focusing on TypeScript here because it's the one I have much more experience with. I spent a couple months using Flow in our app, before switching to TypeScript mostly because that's what we had everywhere else in-house. 


C096ed07142659408dc6651f8320acd3?s=184&d=mm

Chris Krycho Chris Krycho is a Senior Software Engineer at Olo, currently hard at work on modernizing the online ordering experience. He's also the host of the New Rustacean podcast and a total fanboy for both Rust and Elm.