Open-Ended Unions and Autocomplete With TypeScript

Picture of the author
Nicolas CharpentierSeptember 26, 2023
3 min read
Open-Ended Unions and Autocomplete With TypeScript

In TypeScript, types are typically precise, leaving little room for ambiguity. While this exactness is beneficial for objects, it can sometimes feel restrictive for unions. In this post, we'll explore how to craft open-ended unions in TypeScript.

Unions shine when you need flexibility in parameter types or when working with literal values. They're especially handy when you aim to type something broadly, like a string, but also wish to offer specific value suggestions or autocompletions.

Take colors, for instance. You could type them simply as strings, but why not also provide a union of potential color options?

type Colors = 'red' | 'blue' | 'yellow';
//   ^? type Colors = "red" | "blue" | "yellow"

function paint(color: Colors) {
  console.log(color);
}

paint('red'); // Works as expected
paint('green'); // Argument of type '"green"' is not assignable to parameter of type 'Colors'.

🔗 TypeScript Playground

It works well, but as soon as someone will try to use a new color, it won't be assignable to the Colors type. This is where open-ended unions come in handy, what if the actual type is a string, but we would like to provide a few suggestions?

A naive way of doing it could be to use a union of literal strings and then string:

type Colors = 'red' | 'blue' | 'yellow' | string;
//   ^? type Colors = string

🔗 TypeScript Playground

Unfortunately, it is not yet supported by TypeScript (see Literal String Union Autocomplete #29729—but it's scheduled in TypeScript 5.3! 🤞).

Doing that will simplify Colors as string because they are ultimately all extending string, so the compiler aggressively reduces such unions to string.

Solution

Use string & {} instead:

type Colors = 'red' | 'blue' | 'yellow' | (string & {});
//   ^? type Colors = (string & {}) | "red" | "blue" | "yellow"

function paint(color: Colors) {
  console.log(color);
}

paint('red'); // Works as expected
paint('green'); // Works as expected

🔗 TypeScript Playground

It works! 🎉 It's a way to achieve loose autocomplete on literal string unions:

IntelliSense working

The reason behind it is that in order to prevent literal types from being squashed as string, we can use the base type string and make an intersection with {} or Record<never, never> (matches any non-null and non-undefined type) and therefore literal types will be treated as distinguishable types as they are seen as different by the compiler string vs. string & {}. Technically speaking, string and string & {} are the same, but it tricks the compiler to not eagerly reduce them:

type AreTheSame = string extends string & {} ? true : false;
//   ^? type AreTheSame = true

🔗 TypeScript Playground

⚠️ Be careful, empty intersections will be reduced to never (e.g., string & number).

The same thing could be done for number:

type Gap = 0 | 8 | 16 | (number & {});
//   ^? type Gap = 0 | (number & {}) | 8 | 16

function doSomething(gap: Gap) {
  console.log(gap);
}

doSomething(0); // Works as expected
doSomething(100); // Works as expected

🔗 TypeScript Playground