You have a BaseVals
type consisting of a union of single words:
type BaseVals = "apple" | "banana" | "orange" // maybe up to 40 of these
Conceptually, you want your Vals
type to be a union of all possible permutations of subsets of those words separated by spaces. You could try to use template literal types to generate these, but unfortunately TypeScript can only handle unions with fewer than about 100,000 members. If BaseVals
has even 8 members, then the resulting union would need to contain 109,600* members. So it's really not going to work for your planned list of up to 40 members. That means there's no specific type in TypeScript that will represent Vals
.
You could give up on a specific Vals
type and instead write a generic ValidVals<T>
type which acts as a constraint on a candidate type T
. So you're not setting out a big list of possible acceptable values ahead of time; you're taking T
and checking it for acceptability. If it is acceptable, then ValidVals<T>
should just be T
. If it is not, then ValidVals<T>
should be some acceptable value "close" to T
.
Since ValidVals<T>
would not be a specific type, you really don't want to directly annotate a variable with it, which is redundant (e.g., let s: ValidVals<"apple"> = "apple"
). Instead you can write a generic helper function and use it to have the compiler infer the generic type (e.g., let s = asVals("apple")
).
Here is a possible implementation of ValidVals
:
type ValidVals<T extends string, U extends string = BaseVals> =
T extends U
? T
: T extends `${U} ${infer R}`
? T extends `${infer F} ${R}`
? `${F} ${ValidVals<R, Exclude<U, F>>}`
: never
: U;
ValidVals<T, U>
type takes a candidate type T
and a union of acceptable values U
(which defaults to BaseVals
). First, we check T extends U
to see if your candidate type is one of the values with no spaces. If so, then ValidVals<T>
is just T
. Otherwise we check T extends `${U} ${infer R}`
to see if your candidate type starts with one of the acceptable values, followed by a space and more stuff R
(which we use conditional type inference to grab). If not, then ValidVals<T>
is U
; we're essentially saying that if T
doesn't even start with something in U
then the "closest" acceptable value is U
itself.
That leaves the case where T
starts with something in U
followed by a space and then more stuff R
. We use conditional type inference again to grab the actual initial string F
(U
is a union, but we want to know exactly which element of the union we have. So F
will be one of the elements to U
). Here's where the recursive part comes in. In this case T
looks like `${F} ${R}`
where F
is some acceptable element, and R
is the rest of the candidate type. So then we output `${F} ${ValidVals<R, Exclude<U, F>>}`
. That means the output type will also start with F
, followed by a space, followed by ValidVals<R, Exclude<U, F>>
... that is, we convert the rest of the string R
into a valid type, where the list of acceptable elements no longer contains the F
element (we use the Exclude<T, U>
utility type to do this).
So for ValidVals<"apple blargh">
, it checks... is "apple blargh"
in BaseVals
? No. Does it start with BaseVals
followed by a space? Yes! So we grab "apple"
as F
and "blargh"
as R
. Then we evaluate ${ValidVals<R, Exclude<U, F>>}
, meaning ValidVals<"blargh", "banana" | "orange">
, which evaluates just to "banana" | "orange"
. And therefore ValidVals<"apple blargh">
becomes "apple banana" | "apple orange"
.
And here's the implementation of asVals()
the helper function:
const asVals = <T extends string>(
t: T extends ValidVals<T> ? T : ValidVals<T>
) => t;
You unfortunately can't write const asVals = <T extends ValidVals<T>>(t: T) => t
, since that's illegally circular. Instead we have to jump through hoops to have the compiler first infer T
and then check it with ValidVals<T>
. This works for heuristic reasons I can't delve too much into here, but basically if you pass in a value in a place accepting type T extends ... ? ... : ...
and ask the compiler to infer T
from it, it guesses that the value is of type T
and then checks it.
Let's just check that it works:
let str1 = asVals("apple") // OK
let str2 = asVals("banana") // OK
let str3 = asVals("orange") // OK
let str4 = asVals("apple banana") // OK
let str5 = asVals("banana apple") // OK
let str6 = asVals("banana orange") // OK
let str7 = asVals("banana strawberry") // NOT OK
// Argument of type '"banana strawberry"' is not assignable to
// parameter of type '"banana apple" | "banana orange"'.
let str8 = asVals("") // NOT OK
// Argument of type '""' is not assignable to
// parameter of type 'BaseVals'.
let str9 = asVals("apple apple") // NOT OK
// Argument of type '"apple apple"' is not assignable to
// parameter of type '"apple banana" | "apple orange"'.
Hooray! The good examples check out, while the bad examples yield errors messages that seem reasonable. Passing in "banana strawberry"
yields an error about how it was expecting "banana apple"
or "banana orange"
, which were the "closest" acceptable types to "banana strawberry"
. These "closest" types also help with autocompletion. If you type "banana "
and autocomplete, it will show you "banana apple"
and "banana orange"
.
Note that there are probably still caveats to this.
The big one is: if you are using TypeScript 4.4 or below, there's a fairly shallow recursion limit at around ~25 levels, so if BaseVals
really has 40 elements, and you call asVals(str)
where str
is a string literal consisting of more than 25 space-separated elements, you'll see an error like "Type instantiation is excessively deep and possibly infinite". TypeScript 4.5 increased this limit to around 100 levels in microsoft/TypeScript#45711 and even made it so you can get around 1000 levels if you use tail-recursive types only. Since you have 40, the above version should work fine in TypeScript 4.5 and above. If you needed more than 100, we'd have to rewrite ValidVals<T>
to be tail-recursive, but I don't think we need to get into that further here.
Playground link to code
* 8 + 8×7 + 8×7×6 + 8×7×6×5 + 8×7×6×5×4 + 8×7×6×5×4×3 + 8×7×6×5×4×3×2 + 8×7×6×5×4×3×2×1 = 109,600