Figurative Literals: Sharpen your Strings

  • March 11, 2022
  • Alex Jarvis
  • 4 min read

Recently, I decided to brush off an old project and tune it up with some modern day Typescript know-how. In my weeks-long dice-driven fugue state, I stumbled on some Typescript features that I hadn't had a chance to use before, and I think they're neat.

This is the second article in the series - check out the first article on overloading.

Revisiting the Strange Program

In a previous blog post, I showed how leveraging Typescript overloads can help us keep our code in line, and help anyone who might be consuming our code use it the way we intended.

Our task was to write a program that could accept a number or string, reject non-number-like strings, and then add 2 to given value. Then - for some reason - it needs to return the same type of value as was passed in. Here's that code:

export function addTwo(baseNumber: string): string
export function addTwo(baseNumber: number): number
export function addTwo(baseNumber: string | number) {
  const baseNumberValue = Number(baseNumber)

  if (Number.isNaN(baseNumberValue)) {
    throw new TypeError('baseNumber must be number or number-like string')
  }

  const numericTotal = baseNumberValue + 2

  return typeof baseNumber === 'string' ? String(numericTotal) : numericTotal
}

and in action:

addTwo(2) // 4
addTwo('6') // '8'
addTwo('Four') // Runtime TypeError 'baseNumber must be number or number-like string'
addTwo('Foo') // Runtime TypeError 'baseNumber must be number or number-like string'
addTwo({}) // Nonsense! Fails to compile before you run it.

That works, but I don't love that TypeError. This feels like the kind of thing that we could solve before runtime, something that Typescript could yell at us about when we try to pass in any value that would result in an error. That way, consumers would have a clearer idea of what this function really needs in their editor, just like if you passed in a random value ({} above).

Template Literals to the Rescue

Turns out, Typescript has a clean solution for us: template literals.

Template literals share their syntax with the template literals in javascript, but by using types instead of values. This means that we can construct sub-types out of the content of strings, allowing a deeply integrated piece of documentation right there for us. Typescript could always use specific strings as type values; this is just allowing a little more dynamism.

Before we tackle the solution for addTwo, let's look at a more complicated template literal. Let's imagine a situation where we write a function that spits out simple messages to the console, and we want to make sure the messages are always appropriately enthusiastic.

We might construct a type like this:

type ExcitedMessage = `${string} ${string}!`
export function sayHello(message: ExcitedMessage) {
  console.log(message)
}

Let's break down ExcitedMessage. Use use backticks and string interpolation syntax to wrap two familiar types, string, and then end it with a !. This means that ExcitedMessage will match any string that contains a string, a space, a string, and an exclamation mark.

const foo: ExcitedMessage = 'Hello Greg!' // Good
const bar: ExcitedMessage = 'Ach Hans!' // Good
const baz: ExcitedMessage = 'Unintended Consequences!' // Good
const luhrmann: ExcitedMessage = 'Help!' // Bad - Type '"Help!"' is not assignable to type '`${string} ${string}!`

We can get more flexible, too. We can use union types to optionally allow a comma:

type ExcitedMessage = `${string}${',' | ''} ${string}!`
const foo: ExcitedMessage = 'Hello Greg!' // Good
const bar: ExcitedMessage = 'Ach, Hans!' // Good
const baz: ExcitedMessage = 'Panic; Disco!' // Bad!

Template literals are pretty flexible - they can take any value of the following union type: string | number | bigint | boolean | null | undefined

So how can we leverage them inside of addTwo?

Getting Number Strings

We just wrap a number type inside of a template literal - it's kind of magical.

type NumberString = `${number}`

export function addTwo(baseNumber: NumberString): NumberString
export function addTwo(baseNumber: number): number
export function addTwo(baseNumber: NumberString | number) {
  const baseNumberValue = Number(baseNumber)

  const numericTotal = baseNumberValue + 2

  return typeof baseNumber === 'string' ? String(numericTotal) : numericTotal
}

By specifying that we don't accept a string - we accept a NumberString - we no longer need to check for NaN inside of our code - we can be certain that Typescript will fail to compile if the given string is not number-like.

addTwo(2) // 4
addTwo('6') // '8'
addTwo('Four') // Nonsense! Fails to compile before you run it.

We've just moved an error report from run-time to compile-time - or dev time, if our editor is Typescript-aware.

The Power of Documentation

With these tricks, we've built a powerful suggestion system to ourselves and to future devs. This is really why I love writing Typescript; confidence that those who touch this code in the future might have a helpful computer friend that helps them understand what I intended to communicate.

And that's good, because this function is so odd out of context I'd likely assume it was an accident otherwise.

type NumberString = `${number}`

export function addTwo(baseNumber: NumberString): NumberString
export function addTwo(baseNumber: number): number
export function addTwo(baseNumber: NumberString | number) {
  const numericTotal = Number(baseNumber) + 2

  return typeof baseNumber === 'string' ? String(numericTotal) : numericTotal
}

For a real-life example of these ideas in action, check out my dice rolling library, randsum. Happy Typing!