At work, I'm involved in maintaining our React component library, which we're very slowly in the process of converting to TypeScript. (But my question is about Typescript more generally and not specific to React - that's just how the question has arisen and how I imagine it might arise for many others!)
Like many such component libraries, we often use a union of string literals as the type for some props, to ensure they always have one of a small number of approved values - a typical example might be type Size = "small" | "medium" | "large";
. And while we don't always use a type alias for them as shown there, it is frequent enough that we do, as the type may need to be referred to in a few different places, and it's nice (particularly with larger unions than this) to not have to type out the same thing all the time, as well as knowing that if the design team ever want us to add a new extra-large
size or whatever, the type at least will only have to be updated in one place.
This works fine of course, but we've discovered that in terms of intellisense it leaves quite a lot to be desired. We want our consumers, who likely don't know all the valid values off my heart, to be told what these are by their IDE when rendering the component. But by doing this in the most obvious way - ie like this:
type Size = "small" | "medium" | "large";
interface Props {
size: Size;
}
then when consuming the props in any form, IDEs such as VSCode on hovering over the size
prop will simply display the rather unhelpful Size
as the prop's type, rather than the explicit union of 3 strings which would be far more helpful.
(Note that although the above is using an interface rather than a type alias, which is the way we've decided to go after some debate, the same issue is present when using type
for
the props type.)
This seems like it should be a common question, but many searches on google and Stack Overflow have failed to turn anything up that's specific to simple unions of strings.
There are discussions about ways to get TS to "expand" or "simplify" complex types - like this Stack Overflow question and its great answers, or this article, which basically show the same solution although they differ in details. But these seem to be aimed at - and certainly work on - object types specifically. Sadly, no matter which of these transformations is applied to the type of the size
prop in the above example, TypeScript still stubbornly shows the unhelpful Size
as the prop's type when actually consuming it. (For those for whom this makes sense - Haskell is among my favourite languages - I would phrase this more succinctly as: these solutions appear to work on product types but not on sum types.)
This is demonstrated in this TS playground example - specifically the size2
prop. (This shows only one form of the Expand
type, but I've tried every slight variation I've either found online or have come up with myself, with no success.)
The others - size3
and size4
- are attempts at using template literal types based on the same "trick" that is behind the Expand
example. I understand they're based on using a conditional type to force distribution of the operation across a union and then making sure the operation is essentially a no-op, so the actual type is still the same but after hopefully forcing TS to compute something across the union to output a "new", "plain" union. Since the Expand
type suggested above iterates across keys, fine for an object type with properties but unclear if it has any meaning for a union of string literals, it seemed using a template literal as the operation in this trick was the same idea but adapted to such a union of literal string types. And specifically, concatenating with an empty string is the only obvious way to make this keep the strings as they were.
That is, I thought
type NoOpTemplate<T> = T extends string ? `${T}${""}` : T;
might work. But as you will see from the playground link above (size3
prop), it doesn't.
I even hit upon the idea of using a generic type with two parameters:
type TemplateWithTwoParams<T, U extends string> = T extends string ? `${T}${U}` : T;
This "works" in a sense, because the prop defined as notQuiteSize: TemplateWithTwoParams<Size, "x">;
displays as an explicit union as desired: "smallx" | "mediumx" | "largex"
. So surely supplying an empty string as the U
parameter will do what we want here - keep the strings in the union the same, while forcing explicit display of the options?
Well no, it doesn't, as you can see from the size4
prop in the example! It seems the TS compiler is just too clever, as all I can assume is that it spots that in this case we're just concatenating with an empty string, which is a no-op, and therefore doesn't need to actually compute anything and thus outputs the same type T
as it was given - even via the type alias.
I'm out of ideas now, and surprised this doesn't seem to be a common problem with clever solutions like the above Expand
that I can read about online. So I'm asking about it now! How can we force TS to display a union type as an explicit union, when used as part of an object or interface, while still keeping the convenience of using an alias for it?