6
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.

2 Answers 2

4

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

Sign up to request clarification or add additional context in comments.

8 Comments

Appreciate your detailed and perfect answer. Thank you.
For option 1... I tried to look up the flow of type definition. And I have few more questions. I hope you don't mind to answer to me. 1. T extends string - Should enums be treated as string when it used as generic parameter? It's confused bit for me cause I thought it's an object. 2. It seems like recursively call AllCombination to create every cases and will return a set of array at the end. If so what does curly brace do? cause final result is an string array not object array. Thank you.
Enums are very different from objects. They can be basically two things: Aliases for strings or Aliases for numbers. In this case you created an enum with only string values. Passing an enum to a function or assigning it to a variable is the same as using a string. That is why const str: string = AllowedFruits.Apple is possible.
2. The curly braces is used to create a mapped type. We need to iterate over every element of the string union. Normally this would create an object with a property for each element. But we want a union of all iterations. That is why I used [T] to convert this object back to a union
Thank you for answering me again. It helps me to understand bit more clear. Let me dive into related part that you explained. Thank you again.
|
2

Tobias's solution is quite clever, but if you don't mind adding a wrapper function you can achieve an equivalent result with less type complexity. First off, add this helper function:

export function enumOrderedValues<U extends string, T extends {[K in keyof T]: U}>(
  _enumType: T,
  values: [...U[]],
): U[] {
  return values;
}

You can then write something like:

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

const FruitsBySweetness = enumOrderedValues(AllowedFruits, [
  AllowedFruits.Banana,
  AllowedFruits.Pear,
  AllowedFruits.Apple,
]);

Now if you add a new fruit to the enum, "Strawberry", and forget to add it to the array you'll get an error:

Argument of type 'typeof AllowedFruits' is not assignable to parameter of type '{ readonly Apple: AllowedFruits.Apple | AllowedFruits.Banana | AllowedFruits.Pear; readonly Banana: AllowedFruits.Apple | AllowedFruits.Banana | AllowedFruits.Pear; readonly Pear: AllowedFruits.Apple | ... 1 more ... | AllowedFruits.Pear; readonly Strawberry: AllowedFruits.Apple | ... 1 more ... | AllowedFruits.Pear...'.
  Types of property 'Strawberry' are incompatible.
    Type 'AllowedFruits.Strawberry' is not assignable to type 'AllowedFruits.Apple | AllowedFruits.Banana | AllowedFruits.Pear'.

Note that this solution doesn't work for non-string enums (or at least I haven't been able to make it work). It also won't catch duplicate entries in the array, but should be enough to add some type validation if you want to make an ordered list of all members of an enum.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.