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



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

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

游乐场链接

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


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: 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;

游乐场链接

另一个更简单的通用解决方案 游乐场链接

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;
}

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

游乐场链接

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想法的解决方案,生成从"种类到类"映射所需的一切,并使用自动生成的功能重载来提供良好的智能感 游乐场链接

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在这里对这段代码进行了调查 游乐场链接 以确定它是如何工作的。

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

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

讨论

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

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

它应该不会有太大变化,但现在 TypeScript 甚至不允许你在animalInstance(类型Dog | 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.

如果您希望在编译时收到有关忘记在此手动代码中实现新类型的警告,则可以通过使用可区分联合和穷举性检查来实现这一点,但您需要编译器能够判断是否调用了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

游乐场链接

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

最新更新