Flexible Types that Support Autocomplete with Template Literals (Oh My!)
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!