Flexible Types that Support Autocomplete with Template Literals (Oh My!)

Graham Murdoch
Graham Murdoch
Design & Engineering

3 minute read

In Typescript, when defining a type, it's nice to use a union of literals:

1type Sizes = "sm" | "md" | "lg"

This allows developers to quickly enter a valid value without having to import and expand an enum. In an editor like VSCode, you get a nice autocomplete prompt with the possible values.

However, sometimes you want a union of literals plus some flexibility for custom values. In this example, we want to allow CSS length units (e.g. "100px").

A simple solution is to combine the literals with a generic type that is a supertype of the literals:

1type Sizes = "sm" | "md" | "lg" | string

Adding the generic type weakens the type coverage since you can enter something potentially invalid that compiles (like "lgg"). You've also broken autocompletion for this type. VSCode, for example, won't show the literals as options in a useful way anymore.

One possible approach is to use a generic type that doesn't match the literal values (e.g. an object):

1type Sizes = "sm" | "md" | "lg" | { custom: string }

This restores autocompletion, some type coverage, and preserves a fallback option. However, it isn't quite what you want for CSS length units (and requires some superfluous decoration).

LiteralUnion

Prior to Typescript 4.1, a better solution was to use the LiteralUnion utility from the type-fest package:

1import { LiteralUnion } from "type-fest"
2type Sizes = LiteralUnion<"sm" | "md" | "lg", string>

While it still allows invalid values, autocompletion of the literal values works again!

Template Literals

As of Typescript 4.1, we can do better. Using template literal types, we can more accurately model (and restrict) the range of values:

1type Sizes = "sm" | "md" | "lg" | `${number}fr` | `${number}px`;
2
3const size: Sizes = "sm"     // ✓
4const size: Sizes = "1fr"    // ✓
5const size: Sizes = "100px"  // ✓

Working with Enums

At Ashby, some of our older code uses enums and, for consistency, we continue to use them as the basis for possible values.

Historically, you couldn't really combine the approaches:

1enum DesignSystemSizes {
2  sm = "sm",
3  md = "md",
4  lg = "lg"
5}
6
7type Sizes = DesignSystemSizes.sm | DesignSystemSizes.md | DesignSystemSizes.lg
8
9const size: Sizes = "sm"     // 𝒙 - no literals in autocompletion

With template literal types we can!

1enum DesignSystemSizes {
2  sm = "sm",
3  md = "md",
4  lg = "lg"
5}
6
7type Sizes = `${DesignSystemSizes}` | `${number}fr` | `${number}px`
8
9const size: Sizes = "sm"     // ✓ - autocompletes literals and DesignSystemSizes!
10const size: Sizes = "1fr"    // ✓
11const size: Sizes = "100px"  // ✓

Conclusion

With Typescript 4.1 and later, you can do some pretty interesting things to construct accurate types using literals and template literals. Exciting stuff!

Share this post

Subscribe to Updates

Ashby products are trusted by recruiting teams at fast growing companies.

QuoraVantaDockerWeTransferIroncladDeelRampHackerOneFullStoryJuniAstronomerTalentfulModern Treasury
QuoraVantaDockerWeTransferIroncladDeelRampHackerOneFullStoryJuniAstronomerTalentfulModern Treasury
NotionVerkadaRetoolMarqetaDuolingoRedditMercuryDeliveroo
NotionVerkadaRetoolMarqetaDuolingoRedditMercuryDeliveroo