'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 1:[1]
"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;
}
}
}
Solution 2:[2]
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;
}
}
}
Solution 3:[3]
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;
}
}
}
Sources
This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.
Source: Stack Overflow
| Solution | Source |
|---|---|
| Solution 1 | kaya3 |
| Solution 2 | Valeriy Katkov |
| Solution 3 | Eduardo Cuomo |
