Back to All Posts

I didn't know you could compose template literal types in TypeScript.

TypeScript's string literal types are a lot more useful than I originally thought.

When template literal types were announced in TypeScript v4.1, nearly every example you saw used it as simpler way to enforce a choice from a fixed set of options. Prior, it was more common to see this:

enum UserRole {
  Admin = 'admin',
  Editor = 'editor',
  Viewer = 'viewer',
}

function checkPermission(role: UserRole): void {
  console.log(`Checking permissions for ${role}`);
}

checkPermission(UserRole.Admin);

Or const assertions (introduced back in v3.1):

const roles = ['admin', 'editor', 'viewer'] as const;

function checkPermission(role: (typeof roles)[number]): void {
  console.log(`Checking permissions for ${role}`);
}

// good
checkPermission('admin');

// bad
checkPermission('urmom')

Template literal types, however, felt a lot more intuitive and lightweight:

type UserRole = `admin` | `editor` | `viewer`;

function checkPermission(role: UserRole): void {
  console.log(`Checking permissions for ${role}`);
}

// good
checkPermission('admin');

// bad
checkPermission('urmom')

This is the only way I've ever used the template literal types, and it's been extremely valuable. So, imagine my surprise when I found out you can compose string literal types with non-literal types, enabling you to do quite a bit you couldn't (as easily) do before.

Enforcing String Formats

If you've used Stripe, you know that every customer has an ID that starts with "cus_". You might've perviously annotated that value as a string and moved on.

interface User {
  customerKey: string;
}

const user: User = {
  customerKey: 'cus_RU8Xy39PJnNfk',
};

Much of the time, that's been fine. But it can become problem when you accidentally slip an extra character in there.

const user: User = {
  // still valid, but not correct.
  customerKey: 'cuss_RU8Xy39PJnNfk',
};

But a variable string literal type can prevent that, requiring a specific format for the value:

interface User {
  customerKey: `cus_${string}`;
}

const user: User = {
  // 🚫 invalid!
  customerKey: 'cuss_RU8Xy39PJnNfk',
};

I'm sure you can already see how useful that is. You can take it further too, interpolating multiple types of values in the same template literal.

type Version = `v${number}.${number}.${number}`

const goodVersion: Version = 'v4.2.3';
const badVersion: Version = '3.2'

That's a lot of flexibility to bring more rigidity to your code.

Combining w Other Features

Even if you stopped here, you'd likely see fewer mistakes & inconsistencies being introduced within your code. But as you might expect, it can be composed of other TypeScript features too, like its built-in utility types.

type ImageExtension = `png` | `jp${`e` | ``}g` | `webp`;
type ImageFileName = `${Lowercase<string>}.${ImageExtension}`;

const goodName1: ImageFileName = 'doggy1.jpeg';
const goodName2: ImageFileName = 'doggy2.jpg';
const badName: ImageFileName = 'KittyCat.webp';

One more example, using generics and conditional types:

type EvenNumber<T extends number> = `${T}` extends `${number}${
  | `0`
  | `2`
  | `4`
  | `6`
  | `8`}`
  ? T
  : never;

function doMath<T extends number>(num: EvenNumber<T>) {
  return num;
}

// good
doMath(444);

// bad
doMath(333);

As you can see, template literal types are way more useful than just for defining possible choices (I still can't believe it took me so long to learn of it). I'm looking forward to opportunities to leverage this in the future, and for the more stable, unambiguous code that'll result.


Alex MacArthur is a software engineer for Dave Ramsey in Nashville-ish, TN.
Soli Deo Gloria.

Get irregular emails about new posts or projects.

No spam. Unsubscribe whenever.
Leave a Free Comment

0 comments