4

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?

Weasel
  • 53
  • 4
  • Does this answer your question? [Is it possible to generate string literal combinations with template literal in TypeScript?](https://stackoverflow.com/questions/68252446/is-it-possible-to-generate-string-literal-combinations-with-template-literal-in) – captain-yossarian from Ukraine Nov 06 '21 at 17:08
  • 1
    I'm having a hard time understanding your constraints exactly, is `"compact normal"` allowed? Or is that considered two of the same breakpoint (the "empty" breakpoint, that is)? Anyway does [this](https://tsplay.dev/WvYpkm) work for you? The union has got something like ~630 members so I can't check them all easily. If I'm missing a use case let me know what it is. – jcalz Nov 06 '21 at 18:32
  • 1
    Thanks for pointing me to that solution @captain-yossarian. It comes very close to what I was looking for, only in my case there would be too many redundant variants, which means the types would potentially be the same, but in a different order. And the solution that jcalz provided addresses these important constraints as well. – Weasel Nov 06 '21 at 19:38
  • 1
    Okay I will write up the answer when I get a chance. – jcalz Nov 06 '21 at 22:28
  • @jcalz Thanks! Just as a follow-up, let's say the `CardSize` instead had 6 sizes (`x-small` to `xx-large`) and 5 breakpoints (`@small` to `@xx-large`). In this case, the tsc would complain that the type would become too complex to compile. Would it be possible to have a type that checks each word (a `CardSize` entry) of the space-separated string? In this case, my 2 complaints would not apply, but I still check the single words. Would such an approach exist for strings, or would an array type be the better approach here (`CardSize[]`)? – Weasel Nov 06 '21 at 23:01
  • 1
    If the type is too complex too compile then no single type will work, and checking each word without limiting the possibilities makes it *worse* (because something like `"compact normal"` will now be accepted, increasing the size of the union). Instead you could do something like a generic checker with a helper function. It's more complicated. Like [this](https://tsplay.dev/WzLyQN). But that's out of scope of this question; if you want to ask a new question for that, I might be able to answer also (when I get to it! ) – jcalz Nov 06 '21 at 23:20

1 Answers1

3

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

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Thank you so much for the elaborate answer, the samples, and the reasoning behind all of this! Compiler performance was not really something I considered while writing the question, nor have I ever come across something like a distributive conditional type. Very, very insightful stuff and it definitely steers me in the right direction. Thank you also for pointing out alternative approaches to type-check more complex types with the generic constraint approach. Much, much appreciated!! – Weasel Nov 07 '21 at 01:10