How to use a switch statement to narrow down a type using conditional types?

Viewed 1154

I have a 'Food' object that can be of multiple types depending on the value of the 'category' prop. The object comes from a json, so it is impossible to know the type before.

I'm trying to use a switch statement on the category prop in order to cast the Food object to the right type


export type Category = 'fruit' | 'grain' | 'meat'

interface Food<IngredientCategory extends Category> {
  name: string;
  category: IngredientCategory
  [key: string]: string;
}

interface Fruit extends Food<'fruit'> {
  color: string;
}

interface Grain extends Food<'grain'>{
  size: string;
}

interface Meat extends Food<'meat'> {
  temperature: string
}

type FoodFromCategory<IngredientCategory extends Category> = IngredientCategory extends 'fruit' ? Fruit : IngredientCategory extends 'grain' ? Grain : Meat;

const castFood = <IngredientCategory extends Category>(category: IngredientCategory, food: Food<any>):
   Food<IngredientCategory> | undefined => {
  switch (category) {
    case "fruit":
      return food as Fruit
    case "grain":
      return food as Grain
    case "meat":
      return food as Meat
  }
  return undefined
};


This creates errors on the lines with the return:

TS2322: Type 'Fruit' is not assignable to type 'Food'.   Types of property 'category' are incompatible.     Type '"fruit"' is not assignable to type 'IngredientCategory'.       '"fruit"' is assignable to the constraint of type 'IngredientCategory', but 'IngredientCategory' could be instantiated with a different subtype of constraint 'Category'.

Why doesn't the switch statement narrow down the "IngredientCategory" generic param? Is there another way I could do this?

TS-Playground

1 Answers

This is not currently possible in TypeScript. The control flow analysis which narrows the type of category does not act to narrow the type parameter IngredientCategory, and so there is no way for the compiler to deduce that Fruit is assignable to FoodFromCategory<IngredientCategory> even if category === "fruit".

The canonical issue asking for some solution here is probably microsoft/TypeScript#33912, and there are some specific feature suggestions which might allow this if implemented, such as microsoft/TypeScript#33014.


But for now, the compiler just cannot do this by itself, and if you want this to compile you will need to do something like a type assertion to tell the compiler that it can treat (for example) Fruit as FoodFromCategory<IngredientCategory>:

const castFood = <IngredientCategory extends Category>(
  category: IngredientCategory, food: Food<any>
): FoodFromCategory<IngredientCategory> | undefined => {
  switch (category) {
    case "fruit":
      return food as Fruit as FoodFromCategory<IngredientCategory>
    case "grain":
      return food as Grain as FoodFromCategory<IngredientCategory>
    case "meat":
      return food as Meat as FoodFromCategory<IngredientCategory>
  }
  return undefined
};

That works and suppresses the errors. But do note that this is you taking the responsibility for verifying type safety away from the compiler, and that you should be careful that you're doing it correctly.


Please note that, at runtime, the switch statement is doing nothing useful anyway. JavaScript doesn't know about Fruit or FoodFromCategory<IngredientCategory>; all that will be emitted to JavaScript as return food for each case. And if you're going to return food no matter what category is, you might as well write castFood this way instead:

const castFood = <IngredientCategory extends Category>(
  category: IngredientCategory, food: Food<any>
) => food as FoodFromCategory<IngredientCategory>;

which completely eliminates the switch statement. It also eliminates undefined, since there's no valid way to call castFood() if category is not in IngredientCategory.

So that's the answer to the question as asked. Stepping back, I'm a little concerned that you're trying to do "cast"ing here at all; the category parameter is not used at runtime at all, so it's only to help the compiler determine what type food is supposed to be. But in that case you would probably be better off using Fruit | Grain | Meat as a discriminated union with the common category property and then test that property instead of passing it in as a separate parameter.

But without seeing how or why you plan to call castFood() I wouldn't presume to venture in that direction (but maybe it looks like this code). Instead, I'll just reiterate that narrowing generic type parameters via control flow is not currently possible, and that you will need to use type assertions or some other type-loosening technique to get this to compile.

Playground link to code

Related