Narrow related generic types

617 views Asked by At

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.

2

There are 2 answers

0
jcalz On

The TypeScript compiler is not currently able to use control flow analysis to narrow unspecified generic type parameters like B inside the implementation of feedAnimal(). 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 and breed as both being of union types which are correlated to each other, and by refactoring all the feedXXX functions into a single map of functions, you can represent all the type operations "at once" as a function of the properties of BreedTypeMap. Here's how it could look:

const feed: { [K in keyof BreedTypeMap]: (food: Food<BreedTypeMap[K]>, breed: K) => void } = {
    copperhead: feedSnake,
    garter: feedSnake,
    python: feedSnake,
    burmese: feedCat,
    manx: feedCat,
    persian: feedCat,
    siamese: feedCat,
    pug: feedDog,
    poodle: feedDog,
    canary: feedBird,
    betta: feedFish,
    guppy: feedFish,
    mongolian: feedGerbil
}
const feedAnimal = function <B extends keyof BreedTypeMap>(
    food: Food<BreedTypeMap[B]>,
    breed: B,
    category: BreedTypeMap[B],
) {
    feed[breed](food, breed); // okay
}

That compiles without error, hooray!

I've refactored away from if/else blocks: now the same logic is represented by indexing into the feed object with the breed key as a single expression, instead of switching on breed 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 key K in keyof BreedTypeMap. This annotation is essential, since it lets the compiler know that it's okay to use feed[breed] generically as a function which can be passed the food, breed arguments. If you remove the annotation, the compiler will balk at the feed[breed](food, breed) call because even though feed is of the same structural type, the compiler will lose track of the correlation:

const oopsFeed = { 
    copperhead: feedSnake, garter: feedSnake, python: feedSnake,
    burmese: feedCat, manx: feedCat, persian: feedCat, siamese: feedCat,
    pug: feedDog, poodle: feedDog,
    canary: feedBird,
    betta: feedFish, guppy: feedFish,
    mongolian: feedGerbil
}
const oopsFeedAnimal = function <B extends keyof BreedTypeMap>(
    food: Food<BreedTypeMap[B]>,
    breed: B,
    category: BreedTypeMap[B],
) {
    oopsFeed[breed](food, breed); // error!
    // -----------> ~~~~        
    // Argument of type 'Food<BreedTypeMap[B]>' is not assignable to 
    // parameter of type 'Food<Snake> & Food<Dog> & Food<Cat> &
    // Food<Gerbil> & Food<Bird> & Food<Fish>
}

That big intersection of all possible Food types is the compiler saying it has no idea which function oopsFeed[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 that feed object!

Finally, the feedGerbil function doesn't actually take a second parameter, but it's still acceptable to assign it to feed.mongolian, because in practice a function will generally ignore extra properties passed in (unless they do something weird with arguments). 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 single feed object still works here.

Playground link to code

0
Cody Duong On

This has been a widely misunderstood (and fairly so) aspect of the generics system: https://github.com/microsoft/TypeScript/issues/13995. There have been many issues/PR's regarding this, but overall has consistently been declined because

TL;DR from design discussion: It's somewhat obvious what the "right" thing to do is, but would require a large rework of how we treat type parameters, with concordant negative perf impacts, without a corresponding large positive impact on the actual user-facing behavior side.

There is also the reason that someone could override the parameters upon entry, or maybe they are using JS. TS is used as a complement to runtime code, and as such makes it so the developer should have runtime code to handle any possible mistakes.

You can use overloads, but this gets extra large, and unmaintainable as you might add animals or whatever else.

Regardless, my recommended workaround is inline with @kellys comment

Using created overloads - Or even better using discriminated tuple union

type PossibleArgs<T> = {
    [K in keyof T]: [Food<T[K] & Animal>, K];
}[keyof T];

const feedAnimal = function (
    ...args: PossibleArgs<BreedTypeMap>
) {...}

You can also recast the function to the old type, if you want the old inferencing, but notice the as any, this makes it very easy and possible for the implementation to desync from the returned type, so will have to take care with that. This is built/inspired by this answer here: https://stackoverflow.com/a/53143568/17954209

type FeedAnimalOverloads<T extends BreedTypeMap> = {
    [K in keyof T]: <T2 extends keyof T>(food: Food<T2 & Animal>, breed: T2) => void;
}[keyof T]

const feedAnimal: FeedAnimalOverloads<BreedTypeMap> = function(
    ...args: PossibleArgs<BreedTypeMap>
) {...} as any

feedAnimal({} as any, "betta")
//  ^? const feedAnimal<"betta">(food: Food<Fish>, breed: "Betta") => void

View this on TS Playground

There's an excellent related answer by @jcalz, which reiterates and adds onto some of what I've said, here: https://stackoverflow.com/a/62701483/17954209