Search code examples
typescriptenumsmapped-types

How to restrict an array to have every member of an enum in TypeScript


enum AllowedFruits {
  Apple = 'APPLE',
  Banana = 'BANANA',
  Pear = 'PEAR'
}

const allowedFruits: AllowedFruits[] = [
  AllowedFruits.Apple, AllowedFruits.Banana, AllowedFruits.Pear
]

What I want to achieve is restricting an array to have every field of specific enum. I expect allowedFruits shows type error by adding or removing field of AllowedFruits.

Is there any way to achieve it?

If there are any articles or documents that I can refer to please let me know.


Solution

  • Option 1

    We can solve this by creating a type containing all possible combinations of AllowedFruits.

    type AllPermutations<T extends string | number> = [T] extends [never] 
      ? [] 
      : {
          [K in T]: [K, ...AllPermutations<Exclude<T, K>>]
        }[T]
    
    type AllFruitPermutations = AllPermutations<AllowedFruits>
    

    This may result in bad performance if you have a lot of elements inside the enum because every single combination needs to be calculated first.

    Let's see if this works:

    /* Error */
    /* Error */
    const t1: AllFruitPermutations = []
    const t2: AllFruitPermutations = [AllowedFruits.Apple] 
    const t3: AllFruitPermutations = [AllowedFruits.Apple, AllowedFruits.Banana]
    const t4: AllFruitPermutations = [AllowedFruits.Apple, AllowedFruits.Banana, AllowedFruits.Pear, AllowedFruits.Pear]
    
    /* OK */
    const t5: AllFruitPermutations = [AllowedFruits.Apple, AllowedFruits.Banana, AllowedFruits.Pear]
    

    Playground

    Option 2

    It is also possible to solve this by passing allowedFruits to a function with a generic type.

    We can create a generic helper type ExhaustiveFruits which checks if all enum values are present in the array.

    type ExhaustiveFruits<
      O extends AllowedFruits[],
      T extends AllowedFruits[] = O,
      P extends string = `${AllowedFruits}`
    > = [P] extends [never]
      ? O
      : T extends [`${infer L}`]
        ? [P] extends [L]
          ? O
          : never
        : T extends [`${infer L}`, ...infer R] 
          ? R extends AllowedFruits[]
            ? ExhaustiveFruits<O, R, Exclude<P, L>>
            : never
          : never
    

    The logic of ExhaustiveFruits is quite simple: It is a recursive type where we start with a union of all enum values as P and the tuple of AllowedFruits as T.

    For each element of T, the string value of the element is inferred with '${infer L}'. Afterwards this value is removed from the P union with Exclude<P, L>.

    Every iteration there is a check if P is empty with [P] extends [never] or if the last element of T is the last element of P with [P] extends [L]. If this is the case, the original tuple O can be returned. If T is empty but P has still AllowedFruits in its union, never is returned.

    The type can be used in a generic function createAllowedFruitsArray like this:

    function createAllowedFruitsArray<
      T extends AllowedFruits[]
    >(arr: [...ExhaustiveFruits<T>]) : T {
      return arr
    }
    

    Some checks to see if this is working:

    createAllowedFruitsArray(
      []                                                              // Error
    )                                                                
    createAllowedFruitsArray(
      [AllowedFruits.Apple]                                           // Error
    )                                             
    createAllowedFruitsArray(
      [AllowedFruits.Apple, AllowedFruits.Banana]                     // Error
    )                       
    createAllowedFruitsArray(
      [AllowedFruits.Apple, AllowedFruits.Banana, AllowedFruits.Pear] // OK
    ) 
    

    Right now it would also be possible to use the same enum value multiple times, as long as all are used.

    createAllowedFruitsArray(
      [AllowedFruits.Apple, 
       AllowedFruits.Banana, 
       AllowedFruits.Pear,
       AllowedFruits.Pear] // Also ok, even though Pear is twice in the array 
    ) 
    

    But with a slight modification, we can also change this:

    type ExhaustiveFruits<
      O extends AllowedFruits[],
      T extends AllowedFruits[] = O,
      P extends string | number = `${AllowedFruits}`
    > = [P] extends [never]
      ? O["length"] extends 0
        ? O
        : never
      : T["length"] extends 1
        ? [P] extends [`${T[0]}`]
          ? O
          : never
        : T extends [any, ...infer R] 
          ? R extends AllowedFruits[]
            ? [`${T[0]}`] extends [P] 
              ? ExhaustiveFruits<O, R, Exclude<P, `${T[0]}`>>
              : never
            : never
          : never
    

    Playground