TypeScript generic factory function to return type based on instance argument without overloading

Viewed 141

I want to create instance based on instance argument given to factory method. I was able to write following code using overloading. How to express it using TypeScript generics?

class Car {
    brand = 'Skoda';
}

class Human {
    name = 'Jan';
}

class Garage {
    lots = 5;
}

class House {
    floors = 2;
}

class NotAllowed {
    anything = 'anything';
}

class Selection {

    pick(what: Car) : Garage;
    pick(what: Human) : House;
    pick(what: any) : any {
        if (what instanceof Car) {
            return new Garage();
        } else if (what instanceof Human) {
            return new House();
        } else {
            throw Error('Not supported')
        }
    }

}

const selection = new Selection();
selection.pick(new Car()).lots; // compiler successfully autocompletes 
selection.pick(new Human()).floors // compiler successfully autocompletes 
selection.pick(new NotAllowed()); // compiler error

1 Answers

You can change pick()'s call signature to be a generic method whose parameter is of a generic type constrained to the union of your different desired input types, and whose return type is a conditional type which chooses the output type based on the input type:

declare class Selection {
  pick<T extends Car | Human>(what: T): T extends Car ? Garage : House;
}

This behaves the same for your example calls:

const selection = new Selection();
selection.pick(new Car()).lots; // compiler successfully autocompletes 
selection.pick(new Human()).floors // compiler successfully autocompletes 
selection.pick(new NotAllowed()); // compiler error

Unfortunately your implementation will not be seen as type safe; the type T will be unspecified inside of the body of pick(), and so the compiler does not and cannot verify that T is assignable to Car even if what instanceof Car is true:

pick<T extends Car | Human>(what: T): T extends Car ? Garage : House {
  if (what instanceof Car) {
    return new Garage(); // error!
    // Type 'Garage' is not assignable to type 'T extends Car ? Garage : House'
  } else if (what instanceof Human) {
    return new House(); // error!
    // Type 'House' is not assignable to type 'T extends Car ? Garage : House'.
  } else {
    throw Error('Not supported')
  }
}

Instead of going on about why this happens, I will just direct you to microsoft/TypeScript#33912, the relevant open feature request to improve language support for implementing generic functions returning conditional types.

For now you will need to do something like a type assertion:

pick<T extends Car | Human>(what: T): T extends Car ? Garage : House {
  if (what instanceof Car) {
    return new Garage() as T extends Car ? Garage : House;
  } else if (what instanceof Human) {
    return new House() as T extends Car ? Garage : House;
  } else {
    throw Error('Not supported')
  }
}

or equivalently, a single call-signature overload:

pick<T extends Car | Human>(what: T): T extends Car ? Garage : House;
pick(what: Car | Human) {
  if (what instanceof Car) {
    return new Garage();
  } else if (what instanceof Human) {
    return new House();
  } else {
    throw Error('Not supported')
  }
}

I know you said you didn't want overloads, but this is usually my solution since adding type assertions everywhere is messier and more tedious.

Playground link to code

Related