Home > Software design >  Looking for a TypeScript type that consists of any possible combination of Template Literals Types
Looking for a TypeScript type that consists of any possible combination of Template Literals Types

Time:11-07

For my project, I need to come up with a TypeScript type, which is this so-called CardSize.

This type can take various forms. It can either be a static value, a responsive (breakpoint-specific) value, or a combination of either of them separated by a white space.

The possible (singular) values are as follows:

type CardSize =
'compact' |
'normal' |
'compact@small' |
'compact@medium' |
'compact@large' |
'normal@small' |
'normal@medium' |
'normal@large';

The type I would like to have in the end would look like this:

type CardSize = 
'compact' | 
... |
'normal@large' |
'compact normal@medium' |
'compact compact@small normal@medium' | 
'compact@small normal@large' etc.

The first step seems to be to make use of the Template Literal Types, so I cover all the singular types:

type CardSize = Size | `${Size}@${Breakpoint}`;

Next up, I tried looking into Permutations to have any combination of the possible values, but so far no luck.

It would also be nice if I could somehow have these two constraints in place:

To limit the number of possible combinations to only have one specific breakpoint-value at the same time assigned (e.g. not having both 'compact@small' and 'normal@small in the same string)

Secondly, it would be nice if the order of the permutation was irrelevant. I would consider the following the same:

const size: CardSize = 'compact@small @normal@large';
const size: CardSize = 'normal@large compact@small';

Would anyone know how to achieve this type of permutation? Even if that meant not having the two constraints in place, it would be a big help!

Re: I realize that a permutation type would be a bit overkill for what I'm trying to achieve. Could I enforce type safety for CardSize without relying on | string as a fallback?

CodePudding user response:

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

  • Related