Creating auto-complete types without restricting values in TypeScript

The problem

In my current role, we build internal libraries for use by our front-end developers. For a new library we're building we needed to do something I hadn't ever done before. Our library is in TypeScript and we wanted to provide IDE suggestions for the values of a parameter, but we couldn't actually restrict the value of that parameter. This is because 90% of the time, the value would be a couple of obvious values, however the other 10% of the time, we had no way to predict what the value would be!

The challenge

You would think that the solution would be simple. But it wasn't.

If you are familiar with TypeScript the initial problem seems easy. We need to declare a list of insects that you can use. This is easily accomplished through defining a Union Type.

type Insect = 'scorpion' | 'cockroach' | 'caterpiller';

The thing that's missing is that if I find a new insect and try to use it, I can't:

const foundInsect: Insect = 'bee';

That would cause an error.

Well, there is a type which would accept any of these strings, right?

const foundInsect: string = 'bee';

This would work, but we lose our IDE suggestions because now the IDE thinks we should just add any string value.

The next assumption is, if we have our Insect type as a union, why don't we just include the string type? That's one of the first things I thought to try!

type Insect = 'scorpion' | 'cockroach' | 'caterpiller' | string;

const foundInsect: Insect = 'bee';

At first look, this seems like it should work. We define our list of approved values and we also allow it to be a string.

This won't throw an error, but it turns out ... we still lost our IDE suggestions!

It turns out that

type Insect = 'scorpion' | 'cockroach' | 'caterpiller' | string;

gets simplified down to

type Insect = string;

which means no IDE suggestions for us!

That is when I discovered this issue on the TypeScript GitHub repo, where this problem has been discussed at length.

The problem is, we need to say that the final segment, "string", includes all strings except the ones we've defined. By doing that, TypeScript understands that it needs to hold on to the original literal string types that we define up front.

The solution, provided by @spcfran and @manuth is:

type LiteralUnion<T extends string | number> = T | Omit<T, T>;

Which works wonders!

The solution

All together you end up with:

type LiteralUnion<T extends string | number> = T | Omit<T, T>;

type Insect = 'scorpion' | 'cockroach' | 'caterpiller';

const foundInsect: LiteralUnion<Insect> = 'bee';

The first type, LiteralUnion is what's called a Generic. It needs a secondary type to be fully defined. In that definition, the letter T represents whatever type you provided.

So LiteralUnion takes another type called "T" which can extend either a string or a number. Then LiteralUnion defines itself as either: the type, or everything that doesn't match the type.

That's a bit abstract, so let's dive a little deeper.

The second type, Insect is just what we've been working with. It's a Union type, so it says that a value can be "A or B or C". In this case we're saying the value can be scorpion OR cockroach OR caterpiller.

When we combine these two types: LiteralUnion<Insect> we're saying that the generic type for LiteralUnion is going to be Insect. so T = 'scorpion' | 'cockroach' | 'caterpiller'. Which means that ultimately when the LiteralUnion's type is: T OR OMIT T, we essentiall get: type LiteralUnion = 'scorpion' | 'cockroach' | caterpiller' | OMIT<string, 'scorpion' | 'cockroach' | 'caterpiller'>, which is exactly what we need in order for TypeScript to hold on to all the types for them to be used by the IDE to suggest them as options!