5

I'm trying to create a type that accepts any combination of certain values separated by spaces. The order doesn't matter. I can't define each combination using string literals because the number of acceptable values is very long. How can I achieve this?

type vals = "apple" | "banana" | "orange"; // much longer list

let str1:vals = "apple" // OK
let str2:vals = "banana" // OK
let str3:vals = "orange" // OK
let str4:vals = "apple banana" // OK
let str5:vals = "banana apple" // OK
let str6:vals = "banana orange" // OK
// etc.

let str7:vals = "banana strawberry" // NOT OK

jonrsharpe
  • 115,751
  • 26
  • 228
  • 437
wongz
  • 3,255
  • 2
  • 28
  • 55
  • Does this answer your question? [How to define a regex-matched string type in Typescript?](https://stackoverflow.com/questions/51445767/how-to-define-a-regex-matched-string-type-in-typescript) – Daniel Hilgarth Jan 06 '22 at 19:16
  • 1
    @DanielHilgarth To me, that answer says there's no support for regex type checking. So I'm left to assume I can only use a series of combinations and permutations of union types for what I need. But that's not practical as the list of acceptable values is very long, so I'd still like to ask if there are any alternative solutions to this – wongz Jan 06 '22 at 19:20
  • If you only want singles and pairs you could do `type foo = vals | \`${vals} ${vals}\`;`, but if you want an arbitrary number of values then I don't think there's anything short of manually outlining how many values you may have at most. – Etheryte Jan 06 '22 at 19:25
  • Is `"banana banana"` okay? How long is the "much longer list"? – jcalz Jan 06 '22 at 19:30
  • Seems like a near-duplicate of [this question](https://stackoverflow.com/q/67184269/2887218) – jcalz Jan 06 '22 at 19:31
  • If the list of acceptable base values is longer than about 8, then you can't represent your type as a specific type in TypeScript (it would be a very big union type). You can make a generic constraint instead, like [this](https://tsplay.dev/mZbq9m). If that meets your needs I can write up an answer; otherwise let me know what I'm missing. – jcalz Jan 06 '22 at 19:41
  • @jcalz Thanks. In my use case, duplicates should not be allowed. Yes it will be more than 8, maybe 40. I think your generic constraint is what I'm looking for. If you don't mind could you explain the components of it as well. Thank you. Also, is it possible to create another string type that runs the function you wrote, or does it have to be a function? – wongz Jan 06 '22 at 19:44
  • Ah, and in practice, how many of those 40 do you expect to see in one of the strings... is someone really going to write out a list of 40 things separated by spaces or is there some reasonable maximum (like 10 or 20?). – jcalz Jan 06 '22 at 19:47
  • @jcalz It could be all 40. The use case is for inline css styling like with tailwindcss or other inline styling packages. So you enter in a string of css styles as a string and that string can only have certain values. I found [this article](https://www.kirillvasiltsov.com/writing/type-check-tailwind-css/) that I think answers it and is similar to your solution with a few other additions – wongz Jan 06 '22 at 19:50
  • Well I hope you're using TS4.5, because even the generic constraint will hit recursion limits for TS4.4 and below after about 20-something values. – jcalz Jan 06 '22 at 19:51

1 Answers1

2

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

wongz
  • 3,255
  • 2
  • 28
  • 55
jcalz
  • 264,269
  • 27
  • 359
  • 360