Issue
I'm trying to narrow down a type inside a .forEach
loop (last statement in the below snippet).
In that loop, TS thinks that obj
is ObjA | ObjB
(because TS simply made a union of all possible values for that key in the array) and the method needs ObjA & ObjB
(because TS made a list of possible arguments and made intersection out of them) which are obviously not the same.
However, the code is obviously correct (because the array elements' obj
and cls
keys are matched and can be used together):
type ObjBase = { id: string };
abstract class Base<T extends ObjBase> {
abstract processObj(obj: T): string;
useObj(obj: T) {
return this.processObj(obj);
}
}
// Module A
type ObjA = { someKey: string } & ObjBase;
class DerivedA extends Base<ObjA> {
processObj(obj: ObjA): string {
return obj.id + obj.someKey;
}
}
// ========
// Module B
type ObjB = { otherKey: string } & ObjBase;
class DerivedB extends Base<ObjB> {
processObj(obj: ObjB): string {
return obj.id + obj.otherKey;
}
}
// ========
const modules = [
{ obj: { id: 'a', someKey: '' } as ObjA, cls: new DerivedA() },
{ obj: { id: 'b', otherKey: '' } as ObjB, cls: new DerivedB() },
] as const;
modules.forEach(module => {
module.cls.processObj(module.obj);
});
How do I fix the error inside that .forEach
and make TS understand that it's safe?
The intent behind all this is to have several 'modules' in the application that re-use a lot of code (represented here by Base#useObj
and insides of forEach
loop). Maybe I need to use some other, better structure of my things that I'm just not seeing here?
Note that I do not want to refer to each individual module entities inside that loop as it would defeat the purpose of making them conform to one interface.
Solution
Essentially modules
is supposed to be a heterogeneous array, where instead of each element being some fixed Module
type, each element is of a different generic type, like [GenericModule<Obj1>, GenericModule<Obj2>, ⋯]
, where GenericModule<T>
is
interface GenericModule<T extends ObjBase> {
obj: T,
cls: Base<T>
}
And inside of your forEach()
loop you really just want to deal with GenericModule<T>
for some T
you don't care about. This is one of the canonical use cases for what's known as existentially quantified generic types (like "for each element of modules
there exists some T
such that the element is of type GenericModule<T>
). But like most other languages with generics, TypeScript does not directly support existential generics. (Although there is a longstanding open feature request for them at microsoft/TypeScript#14466.) So you need a different approach.
There are different approaches you can take:
If you have some fixed known set of T
s you want to support, you can replace existential generics with a union. (Existential generics can be thought of as "infinite unions", while regular generics, or "universal" generics, can be thought of as "infinite intersections.) But inside forEach()
, TypeScript will fail to see it as safe. Even though the union GenericModule<Obj1> | GenericModule<Obj2>
should allow you to safely write module.cls.processObj(module.obj)
, the compiler complains. It cannot see the correlation between module.cls
and module.obj
. Instead, module.cls.processObj
becomes a union of functions, and you can only safely call those with an intersection of arguments (see the release notes for TS3.3), and you get an error about ObjA & ObjB
.
This issue with correlated unions is the subject of microsoft/TypeScript#30581. The recommended/supported approach to this is described in microsoft/TypeScript#47109 and involves refactoring away from unions to a special form using a "basic" mapping interface where you give a key to each element of your union, like
interface Mapping {
a: ObjA,
b: ObjB
}
And then all your operations are written in terms of that interface, or in terms of generic indexes into that interface, or into mapped types over that interface. Like this:
type Module<K extends keyof Mapping = keyof Mapping> =
{ [P in K]: GenericModule<Mapping[P]> }[K]
const modules: Module[] = [
{ obj: { id: 'a', someKey: '' } as ObjA, cls: new DerivedA() },
{ obj: { id: 'b', otherKey: '' } as ObjB, cls: new DerivedB() },
] as const;
modules.forEach(<K extends keyof Mapping>({ obj, cls }: Module<K>) => {
cls.processObj(obj); // okay
});
The Module<K>
type takes a generic type argument for K
which is some subset of keyof Mapping
. Module<"a">
is equivalent to GenericModule<ObjA>
, and Module<"b">
is equivalent to GenericModule<ObjB>
, and just Module
is the same as the default Module<"a" | "b">
which is equivalent to the union GenericModule<ObjA> | GenericModule<B>
. So modules
is of type Module[]
.
And now the forEach()
callback is generic and accepts a Module<K>
. And this works, because the type of cls.process
is seen as the single generic function type (obj: Mapping[K]) => string
while obj
is seen as Mapping[K]
. No unions involved anymore, just a single generic function type being called with the expected argument type.
Another approach, if you don't have a fixed list of types to support or you don't want to assign keys to these types, is to just emulate existential generics by converting them to a Promise
-like structure. Consuming an existential generic is equivalent to providing a universal generic and vice versa. This is described inside microsoft/TypeScript#14466 at this comment.
Given GenericModule<T>
, we can define Module
, the existential version, like this:
type Module = <R>(cb: <T extends ObjBase>(module: GenericModule<T>) => R) => R;
So a Module
is like an already-resolved promise for a GenericModule<T>
where you don't know T
. All you can do is give it a then
-like callback which returns something whose type you do know. For any regular GenericModule
you can wrap it to make a Module
:
const someModule = <T extends ObjBase>(module: GenericModule<T>): Module => cb => cb(module);
And then your modules
array can be rewrapped:
const someModules: Module[] =
[someModule(modules[0]), someModule(modules[1])];
And now you can process them by giving each of them a callback:
someModules.forEach(m => m(({ obj, cls }) => {
cls.processObj(obj); // okay
}));
Each of these approaches preserves some level of type safety, and each has benefits and drawbacks, and neither one is without some effort and complexity. Which one, if any, is acceptable for a particular task depends on the use case and requirements.
Answered By - jcalz
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.