You can definitely generate a union type of permutations/combinations of string values, recursively concatenated via template literal types. Of course, the number permutations and combinations grows quite rapidly as you increase the number of elements to permute and combine. TypeScript can only handle building unions on the order of tens of thousands of elements, and compiler performance tends to suffer when you get close to this. So this approach will only work for small numbers of elements.
Your CardSize
example will be fine because you only have two sizes and four breakpoints:
type CardSize = BuildCardSizes<'compact' | 'normal', '' | '@small' | '@medium' | '@large'>
where BuildCardSizes<S, B>
is an appropriately defined type function which allows you to use anything in S
as much as you want, but only lets you use elements of B
at most once. Here's how I'd define it:
type BuildCardSizes<S extends string, B extends string, BB extends string = B> =
B extends any ? (`${S}${B}` | `${S}${B} ${BuildCardSizes<S, Exclude<BB, B>>}`) : never;
What this does is take the union B
of breakpoints and use a distributive conditional type to split it into its constituent members. Thats the B extends any ? (...) : never
part, and inside the parentheses, B
is just a single element of that union. Note that we also need the full union. TypeScript doesn't make it easy to do that, so I'm using BB
, another type parameter, which defaults to the original B
. In what follows, B
means "some particular element of the current union of breakpoints", while BB
means "the full current union of breakpoints".
So, for each B
, the acceptable card sizes are either `${S}${B}`
, the concatenation of some element of S
with the particular element B
; or `${S}${B} ${BuildCardSizes<S, Exclude<BB, B>>}`
, which is the same thing followed by a space and then BuildCardSizes<S, Exclude<BB, B>>
... which is the set of card sizes you get with the same S
, but with B
removed from the full element list BB
.
Let's test it on your example:
/* type CardSize = "compact" | "normal" | "compact@small" | "normal@small" | "compact@medium" | "normal@medium" |
"compact@large" | "normal@large" | "compact@medium compact@large" | "compact@medium normal@large" |
"normal@medium compact@large" | "normal@medium normal@large" | "compact@large compact@medium" |
"compact@large normal@medium" | "normal@large compact@medium" | "normal@large normal@medium" |
"compact@small compact@medium" | "compact@small normal@medium" | "compact@small compact@large" |
"compact@small normal@large" | "compact@small compact@medium compact@large" |
"compact@small compact@medium normal@large" | "compact@small normal@medium compact@large" |
"compact@small normal@medium normal@large" | "compact@small compact@large compact@medium" |
"compact@small compact@large normal@medium" | "compact@small normal@large compact@medium" |
"compact@small normal@large normal@medium" | "normal@small compact@medium" | "normal@small normal@medium" |
"normal@small compact@large" | "normal@small normal@large" | "normal@small compact@medium compact@large" |
"normal@small compact@medium normal@large" | "normal@small normal@medium compact@large" |
"normal@small normal@medium normal@large" | "normal@small compact@large compact@medium" |
"normal@small compact@large normal@medium" | "normal@small normal@large compact@medium" |
"normal@small normal@large normal@medium" | "compact@large compact@small" | "compact@large normal@small" |
"normal@large compact@small" | "normal@large normal@small" | "compact@medium compact@small" |
"compact@medium normal@small" | "compact@medium compact@small compact@large" | ... */
Uh, whoa, the compiler has no problem with this union of... checks notes... 632 elements, but it's too big for me to write out in this answer or check completely. Anyway, though, you can see from above that the sizes are reused but the breakpoints are not.
Let's spot check it:
c = 'normal compact@small' // okay
c = 'compact@small normal' // okay
c = 'compact@small normal normal@large compact@medium' // okay
c = 'normal@small normal@medium normal@large normal' // okay
c = 'compact@small normal@small' // error
c = 'compact normal' // error
c = 'normal@small normal@medium normal@large normal normal@big' // error
c = '' // error
Looks good!
As I mentioned in the comments, there are other approaches for larger numbers of elements; instead of generating a specific union of all possible acceptable values, you use a generic constraint to check that some given value is acceptable. It's more complicated, though, and out of scope for this question.
Playground link to code