打字稿:如何声明一个通用 class 工厂,以最安全的方式将枚举映射到类型?

Typescript: How do I declare a generic class factory that maps enums to types in the safest way?

我正在尝试找出为这个通用 class 工厂分配类型的最佳方法。 我从另一个问题中复制了一些代码: 将枚举值映射到 class 相对简单。但是,我似乎无法弄清楚如何更进一步并键入我的创建方法,以便它意识到我正在创建的 class 实际上是否采用了我传入的参数。 (我意识到这是构建实例的一种复杂而人为的方式。我想我已经提炼出我在现实世界中的应用程序中尝试做的事情来解决这个问题。)

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

请参阅上面代码中的实际问题。


2020 年 5 月 29 日更新:

@Kamil Szot 指出我的非重载函数首先没有适当的类型安全:

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

所以,正如他所建议的,我们确实需要重载,但我不想手动编写它们。所以,这就是我现在所拥有的。我认为这已经很好了,但我希望我可以定义另一种类型,使自动生成这些重载变得不那么冗长,这样我就不必两次复制我的函数实现的函数签名.

// We can use UnionToIntersection to auto-generate our overloads
// Learned most of this technique here: 
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

在问题的末尾和接受的答案中可以看到两个不同的更通用的解决方案。

我也留下这个答案,因为它包含更易读和更容易理解的解决方案,但是它需要您手动为每个 Kind 定义函数重载。

讨论

尝试像这样定义你的初始化:

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

它应该不会有太大变化,但现在 TypeScript 甚至不允许您在 animalInstanceDog | Cat 类型)上对 init() 进行任何调用,如下所示:

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

因为

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)

或者您可以更简单地声明:

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

现在在这里

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

dc.initinit(params: never): void 的签名,你也不能调用它。


我认为以类型安全的方式调用 init 的唯一方法是手动进行运行时类型检查,并对每种情况进行单独的手动转换和调用,如下所示:

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.

如果您希望在编译时收到有关忘记在本手册代码段中实现新类型的警告,您可以使用 Discriminated Unions and exhaustiveness checking 来实现这一点,但您需要编译器能够判断init() 是否被调用,例如从 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);
    }
})();

解决方案

就我个人而言,我会选择这样的东西:

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

此解决方案的其他好处是它不会生成任何不必要的运行时代码。

另一个更简单的通用解决方案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;
}

另一个例子是为了反映我的另一个需要手动重载的答案。它还会自动获取 Params 类型,而无需单独手动定义映射。

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());

合并我的和 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());

用户 Taytay who asked the question did an investigation of this code here Playground link 确定其工作原理。