1

I am trying to figure out the best way to assign types to this generic class factory. I've copied some of this code from another question: https://stackoverflow.com/a/47933133 It's relatively straightforward to map an enum value to a class. I can't however seem to figure out how to go one step further and type my creation method so that it realizes if the class I am creating does not in fact take the parameters that I've passed in. (I realize this is a convoluted and contrived way to construct an instance. I think I've distilled down something I'm trying to do in my app in the real world to this question though.)

class Dog {
    public dogName: string = ""
    public init(params: DogParams) { }
}
class Cat {
    public catName: string = ""
    public init(params: CatParams) { }
}
class DogParams { public dogValues: number = 0 }
class CatParams { public catValue: number = 0}

enum Kind {
    DogKind = 'DogKind',
    CatKind = 'CatKind',
}

const kindMap = {
    [Kind.DogKind]: Dog,
    [Kind.CatKind]: Cat,
};
type KindMap = typeof kindMap;

const paramsMap = {
    [Kind.DogKind]: DogParams,
    [Kind.CatKind]: CatParams,
}
type ParamsMap = typeof paramsMap;

function getAnimalClasses<K extends Kind>(key: K, params: ParamsMap[K]): [KindMap[K], ParamsMap[K]] {
    const klass = kindMap[key];
    return [klass, params];
}

// Cool: Typescript knows that dogStuff is of type [typeof Dog, typeof DogParams]
const dogStuff = getAnimalClasses(Kind.DogKind, DogParams);

// Now imagine I want to instantiate and init my class in a type-safe way:
function getAnimalInstance<K extends Kind>(key: K, params: InstanceType<ParamsMap[K]>): InstanceType<KindMap[K]> {
    const animalKlass = kindMap[key];

    // animalInstance : Dog | Cat
    const animalInstance = new animalKlass() as InstanceType<KindMap[K]>;

    // By this line, Typescript just knows that animalInstance has a method called init that takes `DogParams & CatParams`. That makes sense to me, but it's not what I want.
    // QUESTION: The following gives an error. Is there a type-safe way that I can make this method call and ensure that my maps and my `init` method signatures are 
    // are consistent throughout my app? Do I need more generic parameters of this function?
    animalInstance.init(params);

    return animalInstance;
}

// This works too: It knows that I have to pass in CatParams if I am passing in CatKind
// It also knows that `cat` is an instance of the `Cat` class.
const cat = getAnimalInstance(Kind.CatKind, new CatParams());

Playground Link

See the actual question in the code above.


UPDATE May 29, 2020:

@Kamil Szot points out that I don't have proper type safety in my non-overloaded function in the first place:

    // Should be an error but is not:
    const cat = getAnimalInstance((() => Kind.DogKind)(), new CatParams());

So, we really do need overloads, as he suggests, but I don't want to write them manually. So, here's what I've got now. I think that this is as good as it's going to get, but I wish I could define another type that made auto-generating these overloads less verbose and made it so that I didn't have to duplicate the function signature of my function implementation twice.

// We can use UnionToIntersection to auto-generate our overloads
// Learned most of this technique here: https://stackoverflow.com/a/53173508/544130
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never

const autoOverloadedCreator: UnionToIntersection<
    Kind extends infer K ?
    K extends Kind ?
    // I wish there was a way not to have to repeat the signature of getAnimalInstance here though!
    (key: K, p: InstanceType<ParamsMap[K]>) => InstanceType<KindMap[K]> :
    never : never
> = getAnimalInstance;

// This works, and has overload intellisense!
let cat2 = autoOverloadedCreator(Kind.CatKind, new CatParams());

// And this properly gives an error
const yayThisIsAnErrorAlso = autoOverloadedCreator((() => Kind.DogKind)(), new CatParams());

// Note that this type is different from our ManuallyOverloadedFuncType though:
// type createFuncType = ((key: Kind.DogKind, p: DogParams) => Dog) & ((key: Kind.CatKind, p: CatParams) => Cat)
type CreateFuncType = typeof autoOverloadedCreator;

Playground Link

Taytay
  • 11,063
  • 5
  • 43
  • 49
  • `const cat = getAnimalInstance(Kind.DogKind, new CatParams());` errors but `const cat = getAnimalInstance((() => Kind.DogKind)(), new CatParams());` does not. I don't think your setup gives you much safety. – Kamil Szot May 29 '20 at 10:48

2 Answers2

4

Another simpler general solution Playground link

class Dog {
    public dogName: string = ""
    public init(params: DogParams) { }
}
class Cat {
    public catName: string = ""
    public init(params: CatParams) { }
}
class DogParams { public dogValues: number = 0 }
class CatParams { public catValue: number = 0}

enum Kind {
    DogKind = 'DogKind',
    CatKind = 'CatKind',
}

const kindMap = {
    [Kind.DogKind]: Dog,
    [Kind.CatKind]: Cat,
};
type KindMap = typeof kindMap;

const paramsMap = {
    [Kind.DogKind]: DogParams,
    [Kind.CatKind]: CatParams,
}
type ParamsMap = typeof paramsMap;

type Tuples<T> = T extends Kind ? [T, InstanceType<KindMap[T]>, InstanceType<ParamsMap[T]>] : never;
type SingleKinds<K> = [K] extends (K extends Kind ? [K] : never) ? K : never;
type ClassType<A extends Kind> = Extract<Tuples<Kind>, [A, any, any]>[1];
type ParamsType<A extends Kind> = Extract<Tuples<Kind>, [A, any, any]>[2];

function getAnimalInstance<A extends Kind>(key:SingleKinds<A>, params: ParamsType<A>): ClassType<A> {
    const animalKlass: ClassType<A> = kindMap[key];

    const animalInstance = new animalKlass();

    animalInstance.init(params); 
    return animalInstance;
}


// this works
const cat = getAnimalInstance(Kind.CatKind, new CatParams());

const shouldBeError = getAnimalInstance(Kind.DogKind, new CatParams()); // wrong params
const shouldBeErrorToo = getAnimalInstance(f(), new CatParams());       // undetermined kind
const shouldBeErrorAlso = getAnimalInstance(f(), new DogParams());      // undetermined kind

var k:Kind;
k = Kind.CatKind;

const suprisinglyACat = getAnimalInstance(k, new CatParams());    // even that works! 
const shouldError = getAnimalInstance(k, new DogParams());

function f():Kind {
    return Kind.DogKind;
}

And another example of this written to mirror my other answer that required manual overloads. It also automatically gets Params types without needing separate manually defined map.

Playground link

class DogParam { public n: number = 0; }
class CatParam { public n: string = "a"; }
class BatParam { public n: boolean = true; }

class Dog { init(p: DogParam) { } }
class Cat { init(p: CatParam) { } }
class Bat { init(p: BatParam) { } }

enum Kind { Dog, Cat, Bat }

const kindMap = {
    [Kind.Dog]: Dog,
    [Kind.Cat]: Cat,
    [Kind.Bat]: Bat
} 

type Tuples<K = Kind> = K extends Kind ? [
    K,
    InstanceType<(typeof kindMap)[K]>,
    InstanceType<(typeof kindMap)[K]> extends 
        { init: (a: infer P) => any } ? P : never
] : never;
type SingleKinds<K> = [K] extends (K extends Kind ? [K] : never) ? K : never;
type ClassType<K> = Extract<Tuples, [K, any, any]>[1];
type ParamsType<K> = Extract<Tuples, [K, any, any]>[2];

function a<K extends Kind>(k: SingleKinds<K>, p: ParamsType<K>): ClassType<K> { 
    var ins:ClassType<K> = new kindMap[k];
    ins.init(p); 
    return ins;         
}


function f(): Kind {
    return Kind.Cat;
}

var k:Kind;
k = Kind.Cat;

a(Kind.Dog, new DogParam()); // works
a(Kind.Cat, new DogParam()); // error because mismatch
a(f(), new DogParam());      // error because kind undetermined
a(f(), new CatParam());      // error because kind undetermined
a(f() as Kind.Dog, new DogParam());      // works, but hey, it's your fault 
                                        // doing the wrong cast here manually
a(k, new CatParam());   // even this works
a(k, new DogParam());   // and this error

// you need to use exactly one kind at a time or it errors
var mixed: Kind.Dog | Kind.Cat = null as any;
var b = a(mixed, new DogParam());

var mixedfn = ():Kind.Dog | Kind.Cat => null as any;
var b = a(mixedfn(), new DogParam());

Solution that merges both my and Taytay ideas that generates everything it needs from "kinds to classes" map and uses automatically generated functions overload to provide nice intellisense Playground link

class Dog {
    public dogName: string = ""
    public init(params: DogParams) { }
}
class Cat {
    public catName: string = ""
    public init(params: CatParams) { }
}
class DogParams { public dogValues: number = 0 }
class CatParams { public catValue: number = 0}

enum Kind {
    DogKind = 'DogKind',
    CatKind = 'CatKind',
}

const kindMap = {
    [Kind.DogKind]: Dog,
    [Kind.CatKind]: Cat,
};
type KindMap = typeof kindMap;

type Tuples<K = Kind> = K extends Kind ? [
    K, 
    InstanceType<KindMap[K]>, 
    InstanceType<(typeof kindMap)[K]> extends 
        { init: (a: infer P) => any } ? P : never
] : never;

type ClassType<K> = Extract<Tuples, [K, any, any]>[1];
type ParamsType<K> = Extract<Tuples, [K, any, any]>[2];

type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never
type Fnc<T = Tuples> = UnionToIntersection<
    T extends Tuples ? (key: T[0], p: T[2]) => T[1] : never
>;
var getAnimalInstance:Fnc = function<K extends Kind>(key: K, params:ParamsType<K>):ClassType<K> {
    const animalKlass = kindMap[key];

    const animalInstance = new animalKlass();

    animalInstance.init(params);

    return animalInstance;
}

// works
const cat = getAnimalInstance(Kind.CatKind, new CatParams());

// errors
const shouldBeError = getAnimalInstance((() => Kind.DogKind)(), new CatParams());

User Taytay who asked the question did an investigation of this code here Playground link to determine how it works.

Kamil Szot
  • 17,436
  • 6
  • 62
  • 65
  • @Taytay I found another cool solution here https://stackoverflow.com/questions/62096767 and adapted it to your question. – Kamil Szot May 30 '20 at 06:36
  • @Taytay You might change accepted answer to this one because it's more concise than my other ramblings. ;-) – Kamil Szot May 30 '20 at 07:01
  • Nice! This is awesome stuff. I learned a lot reading through it, and added more experiments and comments to the bottom of the playground. Here is a gist so that I can make a short link: https://gist.github.com/Taytay/7c0f79115e3055cc0f8a234e395d8679 – Taytay May 30 '20 at 14:50
  • @Taytay Great investigation! I had no idea that it works with variable of type `Kind`. I suspect that the strength of this solution comes from `SingleKinds` (where `K` extends `Kind`) which somehow manages to express type `Kind.DogKind` xor type `Kind.CatKind` but doesn't allow for type `Kind` or `Kind.DogKind | Kind.CatKind` .. which made me wonder... Are two function declarations really necessary, and .. no, they are not. The first one is sufficient to provide all the functionality you tested. :-) I'll edit this answers to reflect our findings and include link to your playground directly. – Kamil Szot May 30 '20 at 17:19
  • @Taytay Also I think that the fact that `Extract` is somehow stronger than just `[K]` plays very important role to avoid loosing selectivity gained with `SingleKinds` – Kamil Szot May 30 '20 at 17:37
  • Oh fascinating. I need to play with this more. Thank you for looking into this so much. I've learned a lot going back and forth. One downside to the single function definition is that Intellisense now gives us this unhelpful suggestion when I type `a(`: `a(k: never, p: DogParam | CatParam): Dog | Cat` – Taytay May 30 '20 at 18:18
  • @Taytay I'm not sure which change actually broke the intellisense ... Already doesn't work in your last playground with two function declarations. In other news ... further investigation showed me that when you add third `Kind` you need the cast on `init()` call again but instead of casting to `any ` it's better just declare type of object instance to be `ClassType` (I updated the answer). – Kamil Szot May 30 '20 at 18:48
  • @Taytay I think I made my last tweak. :-) I changed definition of `Single` to match only single `Kinds`. The previous definition matched also any union smaller than whole Kind. I don't think I can make intellisense work with my solution, but I used my ideas to simplify your solution with automatic overload generation. I added it to this answer. – Kamil Szot May 31 '20 at 00:01
  • Sure enough, I just discovered that these types do resolve to `any` inside of the function. That's why casts weren't required. It also showed me that it possible to type generic functions in such a way as to look correct from the outside, but resolve to `any` inside of the function, which might be a neat trick in other circumstances. Here is a link to the gist/playground: https://gist.github.com/Taytay/7d79eb02c28f62d704e93a03e95e7b2d – Taytay May 31 '20 at 19:50
1

Two different more general solutions can be seen at the end of the question and in accepted answer.

I'm leaving this answer as well because it contains more readable and easier to understand solution, however it requires you to define function overload for each Kind manually.

Discussion

Try defining your your inits like this:

public init<P extends DogParams>(params: P) { }
//..
public init<C extends CatParams>(params: C) { }

It shouldn't change much but now TypeScript won't even allow you to make any call to init() on animalInstance (of type Dog | Cat) like so:

function f(): Dog | Cat {
    return new Dog();
}
const dc: Dog | Cat = f();
dc.init(new DogParams());
// ^ here is the error

because

This expression is not callable.   
Each member of the union type '(<P extends DogParams>(params: P) => void) | (<C extends CatParams>(params: C) => void)' has signatures, 
but none of those signatures are compatible with each other.(2349)

Or you can go even simpler and declare like that:

public init(params: string) { } // inside class Dog
//..
public init(params: number) { } // inside class Cat

and now here

const dc: Dog | Cat = f();
dc.init(5);

dc.init has signature of init(params: never): void and you can't call it as well.


I think the only way you can make a call to init in a type safe manner is if you do manual, runtime type checking and make separate manual casts and calls for each case like so:

const dc: Dog | Cat = f();
if (dc instanceof Dog) {
    dc.init("5");
} else if(dc instanceof Cat) {
    dc.init(5);
} else {
   throw Exception("I should implement call to init() of "+dc); // this will alert you if you add new kind of animal but forget to add it here.

If you prefer to be warned at compile time about forgetting to implement new type in this manual piece of code you might achieve that by using Discriminated Unions and exhaustiveness checking but you'll need compiler to be able to tell if the init() was called or not, for example by returning something from init().

// .. inside class Dog
public kind: Kind = Kind.DogKind; 
public init(params: string) { return true; } 

// .. inside class Cat
public kind: Kind = Kind.CatKind;
public init(params: number) { return true; } 
// ..

const dc: Dog | Cat = f();
enum Kind {
    DogKind = 'DogKind',
    CatKind = 'CatKind',
//    HamsterKind = 'HamsterKind'  // after uncommenting this, compiler alerts that function below does not always return boolean, and you know that you should implement the call to init() for new Kind there
}
(():boolean => {
    switch (dc.kind) {
        case Kind.DogKind: return (dc as Dog).init("5");
        case Kind.CatKind: return (dc as Cat).init(5);
    }
})();

Solution

Personally I'd go with something like this:

class DogParam {
    public n: number = 0;
}
class CatParam {
    public n: string = "a";
}

class Dog {
    init(p: DogParam) { }
}
class Cat {
    init(p: CatParam) { }
}

enum Kind {
    Dog, Cat //, Hamster  // if you add new kind compiler will fail 
                          // inside function a(), while trying to 
                          // get kindMap[k], because key k is potentially not 
                          // present in kindMap, and when you add it to 
                          // kindMap you still need to add new overload for 
                          // function a() to be able to use new Kind in your 
                          // code so at no point compiler lets you forget to 
                          // add anything
}
const kindMap = {
    [Kind.Dog]: Dog,
    [Kind.Cat]: Cat
} 

// The only drawback of this solution is that you have to list those 
// overloads manually.
function a(k: Kind.Dog, p: DogParam): Dog;
function a(k: Kind.Cat, p: CatParam): Cat;
function a(k: Kind, p: any) { 
    var ins = new kindMap[k];
    ins.init(p as any); // safe because overloads ensure it can be called 
    return ins;         // just for matching params
}


function f(): Kind {
    return Kind.Cat;
}

a(Kind.Dog, new DogParam()); // works
a(Kind.Cat, new DogParam()); // error because mismatch
a(f(), new DogParam());      // error because kind undetermined
a(f(), new CatParam());      // error because kind undetermined
a(f() as Kind.Dog, new DogParam());      // works, but hey, it's your fault 
                                         // doing the wrong cast here manually

Playground link

Additional benefit of this solution is that it doesn't generate any unnecessary runtime code.

Kamil Szot
  • 17,436
  • 6
  • 62
  • 65
  • 1
    Thanks for your quick answer. Okay, I've updated my answer to riff on this a bit. I like the overload signatures, and have a way of "auto-generating" them that I think gives me want I want. – Taytay May 29 '20 at 16:13
  • @Taytay Thanks for showing "auto-generating" code in you question/answer. It's pure magic! I had great fun trying to understand it. I mostly did but I'm still bit fuzzy of how unions and intersections of functions work. – Kamil Szot May 29 '20 at 17:48