7

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?

azwier
  • 163
  • 6
  • See [microsoft/TypeScript#13995](https://github.com/microsoft/TypeScript/issues/13995) for why this doesn't work. Your type assertion solution is probably the most reasonable way of dealing with this. – jcalz Nov 27 '19 at 17:34
  • 2
    Does [this](http://www.typescriptlang.org/play//#code/JYOwLgpgTgZghgYwgAgApQPYBMCuCwCycADsgN4CwAUMrchiBAFznV3vIBGcAzhOsCQsQOALadoAbjYdaWYDwQYc4YWIlRpNOgF8ANDNpgA7hhaVts7nwFDkI8VMMc4WLAHkQt5vfVPLyDrUQVTUMCr4wAzIAOYQYN4APADSyBAAHpAgWDzIANYQAJ4YMGiYuPhExAB8ABQFhSzJesjEcFBwojws6Nh4hCQA2skAugCUrAFKIDxgre1gwHAANlU9C0vLib0VAzXIALzkyIMNIyxtHV2BWuzApbWXiytVAHQMEBMWsrRQ8ThQEDzKDPVYkd6MV7WfhQQQoAC0wNBbw+r3kimU4FuujSyz4yHuyEeGxe4JMGC+znYfzAAKBT02b3JUN4MLhyAA1EjGWTTK9XB4vLCkNjaDpcfiwAALTDGewQOUAUSgmCgtTGWhCQA) seem like a reasonable alternative? It's not using an assertion, so it's "safer". – jcalz Nov 27 '19 at 17:43

3 Answers3

6

"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

kaya3
  • 47,440
  • 4
  • 68
  • 97
  • The problem with this, of course, is that if you want a different return type based on each discriminant, the return type would be a union of all possible return types, rather than the one corresponding to the actual discriminant passed, like generics would do. – Madara's Ghost May 12 '20 at 10:20
  • That's not a problem with this approach; it's a different but related question with a more complicated answer. You can still use a discriminated union and write a suitable generic signature to allow a more specific return type to be inferred at some call-sites. – kaya3 May 13 '20 at 01:23
1

Indeed, looks like TypeScript isn't so smart, but there is a workaround, which is better that casting:

function getPrice(productMap: ProductMap, key: keyof ProductMap) {
  switch (key) {
    case 'one': {
      const params = productMap['one'];
      return params.basePrice - params.discount;
    }
    case 'two': {
      const params = productMap['two'];
      return params.basePrice + params.addOnPrice;
    }
  }
}
Valeriy Katkov
  • 33,616
  • 20
  • 100
  • 123
0

Cast to specific type is one solution (but not the best):

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': {
      const params = _params as ProductMap['one'];
      return params.basePrice - params.discount;
    }
    case 'two': {
      const params = _params as ProductMap['two'];
      return params.basePrice + params.addOnPrice;
    }
  }
}

And, in order to keep a single return type, define the function return type:

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

function getPrice<K extends keyof ProductMap>(
  key: K,
  _params: ProductMap[K]
): number {
  switch (key) {
    case 'one': {
      const params = _params as ProductMap['one'];
      return params.basePrice - params.discount;
    }
    case 'two': {
      const params = _params as ProductMap['two'];
      return params.basePrice + params.addOnPrice;
    }
  }
}
Eduardo Cuomo
  • 17,828
  • 6
  • 117
  • 94