Search code examples
typescriptgeneric-function

Typescript - How do I narrow type possibilities of a generic type in a switch statement?


I'm trying to write a function that will perform a particular calculation based on the passed key and parameters. I also want to enforce a relationship between the passed key and parameters, so I have used a generic function with a constraint:

interface ProductMap {
  one: {
    basePrice: number;
    discount: number;
  },
  two: {
    basePrice: number;
    addOnPrice: number;
  }
}

function getPrice<K extends keyof ProductMap>(key: K, params: ProductMap[K]) {
  switch (key) {
    case 'one': {
      return params.basePrice - params.discount; // Property 'discount' does not exist on type 'ProductMap[K]'.
    }
    case 'two': {
      return params.basePrice + params.addOnPrice;
    }
  }
}

Maybe I'm thinking about this in the wrong way, but it seems like typescript should be able to narrow the generic type in the switch statement. The only way I could get it to work was with this awkwardness:

function getPrice<K extends keyof ProductMap>(key: K, params: ProductMap[K]) {
  switch (key) {
    case 'one': {
      const p = params as ProductMap['one'];
      return p.basePrice - p.discount;
    }
    case 'two': {
      const p = params as ProductMap['two'];
      return p.basePrice + p.addOnPrice;
    }
  }
}

Can anyone explain why #1 won't work or offer an alternative solution?


Solution

  • "Can anyone explain why #1 won't work or offer an alternative solution?"

    Here's why #1 won't work: Typescript has control-flow type narrowing for variables like key, but not for type parameters like K.

    The case 'one': check narrows the type of the variable key: K to key: 'one'.

    But it does not narrow from K extends 'one' | 'two' to K extends 'one', because no test has been done on the actual type variable K, nor can any test be done to narrow it. So params: ProductMap[K] is still params: ProductMap[K], and K is still the same type, so the type of params hasn't been narrowed.


    Here's an alternative solution: use a discriminated union, and switch on the discriminant (i.e. the __tag property in the code below).

    type ProductMap =
      {
        __tag: 'one';
        basePrice: number;
        discount: number;
      } | {
        __tag: 'two';
        basePrice: number;
        addOnPrice: number;
      }
    
    function getPrice(params: ProductMap): number {
      switch (params.__tag) {
        case 'one': {
          return params.basePrice - params.discount;
        }
        case 'two': {
          return params.basePrice + params.addOnPrice;
        }
      }
    }
    

    Playground Link