1

I've written a function that takes an object with three properties:

  • type: a class literal
  • action: a function to call on an object of the above type
  • children?: an optional array of objects that also match this format

The actual code is fairly straightforward and works as expected:

class MyClass {
  xyz() {
    console.log('xyz')
  }
}

let param = {
  type: MyClass,
  action: function() {
    //@ts-ignore
    this.xyz() // value of this will be bound to an instance of MyClass
  }
}

function buildClassAndExecute(param: any) {
  let obj = new param.type()
  let func = param.action as Function
  func.call(obj)
  for (let child of param.children) {
    buildClassAndExecute(child)
  }
}

buildClassAndExecute(param)
// outputs 'xyz'

Now I'd like to create a typescript interface that defines param. I've found out about newable types which seems to satisfy the first property:

interface param {
  type: new (...args: any[]) => any
}

Generic types could be used to satisfy the first two parameters like so:

interface param<T> {
  type: new (...args: any[]) => T
  action: (this: T) => void
  children?: param<any>[]
}

But since the interface becomes generic, there is nothing accurate to pass into the generic type of children - and users implementing this will just see this as being of type any, when defining the action function in the children array. The Generic type needs to come from the resulting type of param.type instead of the other way around.

Can this be done WITHOUT using generics, so the this value of action() can be dynamically assigned based on what the user provides the first property?

tuckie
  • 15
  • 4
  • 1
    You need generics if you want type safety. I don't understand why you think you can't use them with arrays... does [this](https://tsplay.dev/WG5zXN) not meet your needs? – jcalz Jan 21 '22 at 04:21
  • The reason is so the type isn't declared outside of the object literal. There are some places where generics cannot be added. A list is one of those: consider adding an array of Param children inside the Param interface - If I wanted to add that, what value should the generic be there? additionally - in your example, without typecasting the interface the type safety for `this` only comes from having manually specified it as the argument to the function, which is prone to user error. this may just be something the typescript language server isn't capable of, which if thats the case is fine – tuckie Jan 21 '22 at 06:30
  • Generics could be used if there were some way to infer the generic from the type of the first property. I have seen behaviour like this, for example, when using the defineComponent() feature in Vue. You write functions on an object literal, but the language server provides type hints for `this` without specifying a generic or a function argument, but based on the return type of a different function on that same object literal – tuckie Jan 21 '22 at 06:52
  • Can you provide a [mre] of a situation where mapped generics don't work? Like, actual code, preferably with a supplementary link to an IDE showing it? I don't have an environment where I can look at `defineComponent()` and see what it's doing. Are you looking for the answer to the *broad* question "given some naturally generic type `F`, is it possible to make a type that represents `F` for some `T` I don't know or care about, so that I don't have to specify `T` or carry it around in subsequent uses"? Or are you looking for something more specific to the particular example? – jcalz Jan 21 '22 at 14:35
  • I'm not sure what to say about the `this` annotation; that was just something I did so that I didn't have to use `//@ts-ignore`, which is best avoided in favor of almost anything else (it would be better to write `(this as any).xyz()`, for example), and doesn't have much to do with the rest of the code that I can see. [Example](https://tsplay.dev/mxBX1w). – jcalz Jan 21 '22 at 14:39
  • By the way, the answer to the broad question is: you'd need [*existentially quantified* generic types](https://stackoverflow.com/q/292274/2887218), which TS doesn't directly support. Like most languages with generics, TS only has *universally quantified* generics. It is possible to emulate the former with the latter, though, at the cost of wrapping stuff in a `Promise`-like callback. See [this example](https://tsplay.dev/wQ5lGw) based on your code. The `Param` type is still generic, but there is now a `SomeParam` type which is not. It's not *terrible*, but might confuse people. – jcalz Jan 21 '22 at 15:50
  • Thanks for following up on this. I read another question on existential types where you answered - and you are right that this interface needs a generic to supply type to both properties. I think for this particular problem, the best solution is to avoid exposing the generic interface to the user. So your example of someParam() is the best solution, since the user inserts an object literal and doesn't provide a generic type. So the 'library' should only export buildAndExecute() and someParam(). Naming them to executeTree() and build() would probably make more syntactic sense to the end user – tuckie Jan 21 '22 at 20:46
  • So would you like me to write up an answer with the existential type solution (and you are free to package and name those however you see fit)? – jcalz Jan 21 '22 at 20:51
  • The way the question is asked, I was at the time looking for an existential type (though I didn't have the terminology), to which it sounds like the answer is that there isn't. The question asking for existential types, and the question of how to create a function that provides users accurate typings seem like two seperate things, so I'm not sure if that should be included or not? I do accept your comments as helping solve my particular problem though! – tuckie Jan 21 '22 at 21:02
  • I'm happy to write up an answer saying: you want existential types, that TS doesn't directly support them, but they can be accurately emulated, and use the code in your question as an example. It *might* help if you first refactor your example to [this](https://tsplay.dev/WoD6Lw) where you start off with generics and show that you quickly run out of good places to specify them (like a set of heterogeneous `children`). Let me know. – jcalz Jan 21 '22 at 21:09
  • I edited the question to better represent my actual problem and question, so an answer based on the comment chain would make more sense now. Thanks for helping me understand this problem – tuckie Jan 21 '22 at 21:27

2 Answers2

1

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

jcalz
  • 264,269
  • 27
  • 359
  • 360
0

Two things to try that may get you there, but first just for some clarity: you have two things called param in your two code blocks. For the sake of my answer, I will refer to the interface called param<T> as IParam<T> and the variable in the first code block will still be just param.

First then, declare the type on your let param declaration so that you don't need the @ts-ignore, as in:

//...

let param:IParam<MyClass> = {
  type: MyClass,
  action: function() {
    // removed @ts-ignore - not needed now
    this.xyz(); // works
  }
}

//...

Second, use this directly in the interface declaration for the children property - typescript supports this as a type when used in this manner.

interface IParam<T> {
  type: new (...args: any[]) => T;
  action: (this: T) => void;
  // Just reference type as `this`
  children?: this[];
}
otto-null
  • 593
  • 3
  • 15
  • I did not know about the use of `this` in that manner for interfaces. The problem this causes is that the type property of entries in the children ends up matching the generic type as well. So children entries need to match, or extend the class of , What if I want a child element to be a sibling class to ? So I would still be looking for something like `children?: this[]`. or `children?: this[]` - which is not syntactically correct - but even if it was, when i would go to implement an action() on a child, the `this` value would be typed as any, which is back where I started – tuckie Jan 21 '22 at 22:42
  • @tuckie In that case, the `T` must then be a super-class of the `T` you are starting with - i.e. sibling means 'common parent' or perhaps 'common ancestor' - if that's the case, just specify the `T` as a super-class when you use it: `const abc:IParam = { type: () => new SomeSubClass(), children: [instanceOfDifferentSubClass, ...]` – otto-null Jan 23 '22 at 15:08