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'.
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
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
It works! 🎉 It's a way to achieve loose autocomplete on literal string unions:
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
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