Interface that defines a property based on another class literal property, without generics
The problem you're running into is TypeScript's lack of direct support for so-called existentially quantified generic types. Like most languages with generics, TypeScript only has universally quantified generics.
The difference: with universally quantified generics, the consumer of a value of a generic type Param<T>
must specify the type for T
, while the provider must allow for all possibilities. Often this is what you want.
But in your case, you want to be able to consume a generic value without caring or knowing exactly which type T
will be; let the provider specify it. All you care about is that that there exists some T
where the value is of type Param<T>
(which is different from Param<any>
, where you essentially give up on type safety). It might look like type SomeParam = <exists. T> Param<T>
(which is not valid TS).
This would enable heterogeneous data structures, where you have an array or a tree or some other container in which each value is Param<T>
for some T
but each one may be a different T
that you don't care about.
TypeScript does not have existential generics, and so in some sense what you want isn't possible as stated. There is a feature request at microsoft/TypeScript#14466 but who knows if it will ever be implemented.
And you can choose to use either Param<any>
and give up completely, or go the other direction and keep detailed track of each and every type parameter via some mapped type which gets worse the more complicated your data structure is.
But let's not give up hope yet. Since universal and existential generics are duals of each other, where the roles of data consumer and data provider are switched, you can emulate existentials with universals, by wrapping your generic type in a Promise
-like callback processor.
So let's start with Param<T>
defined like this:
interface Param<T> {
type: new () => T;
action: {
(this: T): void
};
children?: Param<any>[] // <-- not great
}
From this we can define SomeParam
like this:
type SomeParam = <R>(cb: <T>(param: Param<T>) => R) => R;
So a SomeParam
is a function which accepts a callback which returns a value of type R
chosen by the callback supplier. That callback must accept a Param<T>
for any possible value of T
. And then the return type of SomeParam
is that R
type. Note how this is sort of a double inversion of control. It's easy enough to turn an arbitrary Param<T>
into a SomeParam
:
const someParam = <T,>(param: Param<T>): SomeParam => cb => cb(param);
let p = someParam({ type: Date, action() { }, children: [] });
And if someone gives me a SomeParam
and I want to do something with it, I just need to use it similarly to how I'd use the then()
method of a Promise
. That is, instead of the conventional
const dateParam: Param<Date> = { type: Date, action() { }, children: [] };
const dateParamChildrenLength = dateParam.children?.length // number | undefined
You can wrap the original dateParam
with someParam()
to get a SomeParam
and then process it via callback:
const dateParam: SomeParam = someParam({ type: Date, action() { }, children: [] });
const dateParamChildrenLength = dateParam(p => p.children?.length) // number | undefined
So now that we know how to provide and consume a SomeParam
, we can improve your Param<T>
type and implement buildClassAndExecute()
. First Param<T>
:
interface Param<T> {
type: new () => T;
action: {
(this: T): void
};
children?: SomeParam[] // <-- better
}
It's perfectly valid to have recursively defined types, so a Param<T>
has an optional children
property of type SomeParam[]
, which is itself defined in terms of Param<T>
. So now we can have a Param<Date>
or Param<MyClass>
without needing to know or care exactly how the full tree of type parameters will look. And given a SomeParam
we don't even need to know about Date
or MyClass
directly.
Now for buildClassAndExecute()
, all we need to do is pass it a callback that behaves like the body of your existing version:
function buildClassAndExecute(someParam: SomeParam) {
someParam(param => {
let obj = new param.type()
let func = param.action // <-- don't need as Function
func.call(obj)
if (param.children) { // <-- do have to check this
for (let child of param.children) {
buildClassAndExecute(child)
}
}
})
}
Let's see it in action. First, the someParam()
function gives us the type inference you wanted to avoid forcing someone to manually provide a this
parameter to action()
:
let badParam = someParam({
type: MyClass,
action() { this.getFullYear() } // error!
// -----------> ~~~~~~~~~~~
// Property 'getFullYear' does not exist on type 'MyClass'.
});
And here's our heterogeneous tree structure
let param = someParam({
type: MyClass,
action() {
this.xyz()
},
children: [
someParam({ type: Date, action() { console.log(this.getFullYear()) } }),
someParam({ type: MyOtherClass, action() { this.abc() } })
]
});
(I added a class MyOtherClass { abc() { console.log('abc') } };
also).
And does it work?
buildClassAndExecute(param); // "xyz", 2022, "abc"
Yes! So you can operate on a heterogeneous tree of <exists. T> Param<T>
with type safety. As mentioned in your comment, you could rename these functions to be more evocative of your workflow, especially since the name someParam
only really makes sense to someone thinking of existential types, which users of your library hopefully wouldn't.
Playground link to code