Typescript nested self referencing

Viewed 110

Note: following code is a simplified version to focus only on the issue. Intro is a bit long, but needed for clarity.

I have a Foo class that represent a complex object.

interface Config {
    bars:{
        [key:string]: {
            on?: {
                [key:string]: (m:any) => void
            }
        }
    }
}

class Foo<T extends Config> {

    public constructor(private config:T) {}

    public doSomething(eventName: keyof T["bars"]) {}
}

Its configuration comes from an object passed in the constructor. So with a initialization like this :

const foo = new Foo({
    bars: {
        buz1: { },
        buz2: { }
    }
})

foo.doSomething("buz1");
foo.doSomething("foo");

The first doSomething is fine, the second raise an error which is expected and desired. My problem is in the nested buz* which needs to expose an on property that will have eventNames and associated callback called when the event is raised :

const foo = new Foo({
    bars: {
        buz1: {
            on: {
                "event": (f:Foo<THereIsTheIssue>) => {
                    f.doSomething("buz2")
                }
            }
        },
        buz2: { }
    }
})

I want f to be of the same type as foo, but I can't find a way to tell that to Typescript. Closest I came to a working solution so far is with this :

interface Config<U extends Config<U>> {
    bars:{
        [key:string]: {
            on?: {
                [key:string]: (m:Foo<U>) => void
            }
        }
    }
}

class Foo<T extends Config<T>> {

    public constructor(private config:T) {}

    public doSomething(eventName: keyof T["bars"]) {}
}    

function tmp() {
    const foo = new Foo({
        bars: {
            buz1: {
                on: {
                    "event": (f) => {
                        f.doSomething("")
                    }
                }
            },
            buz2: { }
        }
    });

    foo.doSomething("buz1");
    foo.doSomething("foo");
}

Only issue f is of type Foo<Config<unknown>> which prevents it from being assigned to event.

So how could I make Typescript know that type from the what is passed to the constructor (if it's even possible) ?

The constraints :

  • types can be split up or in a single type/interface (but there are many other properties)
  • bars and on are fixed keywords which needs to be nested as they are here
  • buz* are not known and will be specific to the developer/project

Gist and TS playground for the code where I'm at so far.

1 Answers

I think that what you are trying to accomplish is not possible because either way you end up in some sort of recursive/circular type declaration.

For example, let's first extract the object literal used in Foo's instantiation to a separate variable:

const config = {
    bars: {
        buz1: {
            on: {
                event: (m: Foo<TypeWeAreLookingFor>) => {
                    m.doSomething("")
                }
            }
        },
        buz2: { }
    }
}
const foo = new Foo(config)

How do we replace TypeWeAreLookingFor? We would need a way to refer to the current variable (config) type, which doesn't exist in Typescript as far as I know, or we could also create a new type for config. But that doesn't make sense either because you would end up having the same problem in the new type declaration (defining this new ConfigType in terms of Foo<ConfigType>).

You can also see the auto-reference directly in the type schema: Foo<V> is nothing other than Foo<T>, so you end up describing Foo<T> in terms of itself, which is a recursive reference.

In general, the issue you are dealing with (creating instance methods from object properties in a programmatic way) is a big topic of debate because using some sort of reflection pattern can lead to issues at runtime (which goes against TypeScript philosophy), but at the same time if there is not enough information a priori about what methods to create in a non-programmatic way, then there doesn't seem to be much of a choice.

In particular, I think probably the easiest approach would be to create some sort of "method factory" method where you can define doSomething for each of the events; or perhaps even better, if you have some information of which are the events that the code will be dealing with, instantiating classes that will handle the specific behavior for each case.

Related