Home > front end >  Interface that defines a property based on another class literal property, without generics
Interface that defines a property based on another class literal property, without generics

Time:01-22

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?

CodePudding user response:

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

CodePudding user response:

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[];
}
  •  Tags:  
  • Related