In a master router function, I am trying to detect the type of one parameter by a separate string parameter and then call the most appropriate secondary function for handling the first parameter. Example (playground link here):
interface Animal {[index: string] : number | string;}
interface Food<A extends Animal = Animal> {isFor: A;}
interface Dog extends Animal {barkVolume: number;}
interface Cat extends Animal {miceCaught: number;}
interface Gerbil extends Animal {cages: number;}
interface Snake extends Animal {length: number;}
interface Fish extends Animal {idealWaterTemp: number;}
interface Bird extends Animal {song: string;}
//Seeks to follow handling of e.g. GlobalEventHandlersEventMap in lib.dom.d.ts
interface BreedTypeMap {
"copperhead": Snake;
"garter": Snake;
"python": Snake;
"burmese": Cat;
"manx": Cat;
"persian": Cat;
"siamese": Cat;
"pug": Dog;
"poodle": Dog;
"canary": Bird;
"betta": Fish;
"guppy": Fish;
"mongolian": Gerbil;
}
//The steps involved in feeding each type of animal are quite different:
const feedSnake = function(food: Food<Snake>, breed: keyof BreedTypeMap) {/*...*/}
const feedDog = function(food: Food<Dog>, breed: keyof BreedTypeMap) {/*...*/}
const feedCat = function(food: Food<Cat>, breed: keyof BreedTypeMap) {/*...*/}
const feedGerbil = function(food: Food<Gerbil>, breed: keyof BreedTypeMap) {/*...*/}
const feedBird = function(food: Food<Bird>, breed: keyof BreedTypeMap) {/*...*/}
const feedFish = function(food: Food<Fish>, breed: keyof BreedTypeMap) {/*...*/}
//Naming a second generic type does not help, as in:
//const feedAnimal = function<B extends keyof BreedTypeMap, T extends BreedTypeMap[B]>(
// food: Food<T>,
const feedAnimal = function<B extends keyof BreedTypeMap>(
food: Food<BreedTypeMap[B]>,
breed: B,
category: BreedTypeMap[B], //absent in real use; just included for generics demonstration
) {
if(breed === 'copperhead' || breed === 'garter' || breed === 'python') {
//Here, breed is correctly narrowed to "copperhead" | "garter" | "python"
//Why can't TypeScript figure out food is of type Food<Snake>?
//Instead it gives error ts(2345):
//Argument of type 'Food<BreedTypeMap[B]>' is not assignable to parameter of type 'Food<Snake>'.
//Type 'BreedTypeMap[B]' is not assignable to type 'Snake'.
//Type 'Dog | Cat | Gerbil | Snake | Fish | Bird' is not assignable to type 'Snake'.
//Property 'length' is missing in type 'Dog' but required in type 'Snake'.
feedSnake(food, breed);
console.log(category); //type Snake | Dog | Cat | Gerbil | Bird | Fish; should be just Snake
} else if(breed === ('burmese') || breed === ('manx') || breed === ('persian') || breed === ('siamese')) {
feedCat(food, breed);
} else if(breed === ('pug') || breed === ('poodle')) {
feedDog(food, breed);
} else if(breed === ('canary')) {
feedBird(food, breed);
} else if(breed === ('betta') || breed === ('guppy')) {
feedFish(food, breed);
} else if(breed === ('mongolian')) {
feedGerbil(food);
}
}
How do I write the function signature to properly narrow the type of food
, ideally without casting or the use of any
?
I'd also ideally like to avoid having to manually write overload signatures, especially as that would mean separately writing type signatures for the rest of a pretty large class where this router function is found.
The TypeScript compiler is not currently able to use control flow analysis to narrow unspecified generic type parameters like
B
inside the implementation offeedAnimal()
. There is an open feature request at microsoft/TypeScript#33014 asking for support for this, but for now it's not a part of the language.You could refactor to use overloads or unions-of-tuples as mentioned in the other answer.
Or you could use the added support for generic indexed access introduced in TypeScript 4.6 which allows us to process correlated union types. You can think of
food
andbreed
as both being of union types which are correlated to each other, and by refactoring all thefeedXXX
functions into a single map of functions, you can represent all the type operations "at once" as a function of the properties ofBreedTypeMap
. Here's how it could look:That compiles without error, hooray!
I've refactored away from
if
/else
blocks: now the same logic is represented by indexing into thefeed
object with thebreed
key as a single expression, instead of switching onbreed
iteratively.Note that the type of
feed
is annotated{ [K in keyof BreedTypeMap]: (food: Food<BreedTypeMap[K]>, breed: K) => void }
, which explicitly represents the values as functions of each keyK
inkeyof BreedTypeMap
. This annotation is essential, since it lets the compiler know that it's okay to usefeed[breed]
generically as a function which can be passed thefood, breed
arguments. If you remove the annotation, the compiler will balk at thefeed[breed](food, breed)
call because even thoughfeed
is of the same structural type, the compiler will lose track of the correlation:That big intersection of all possible
Food
types is the compiler saying it has no idea which functionoopsFeed[breed]
is going to be, so the only thing it would accept is some food which can simultaneously feed all those different animals. This isn't a restriction you want to live with, so remember to annotate thatfeed
object!Finally, the
feedGerbil
function doesn't actually take a second parameter, but it's still acceptable to assign it tofeed.mongolian
, because in practice a function will generally ignore extra properties passed in (unless they do something weird witharguments
). So even though it's considered an error to call a function directly with more parameters than it's needed, the compiler will still let you assign it to a place that expects more parameters. So the approach of writing a singlefeed
object still works here.Playground link to code