Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sub-class generics not working correctly with super-class constraint/default generics #30480

Closed
milesj opened this issue Mar 19, 2019 · 17 comments
Labels
Question An issue which isn't directly actionable in code

Comments

@milesj
Copy link

milesj commented Mar 19, 2019

TypeScript Version: typescript@^3.4.0-dev.20190316

Search Terms: generic constraints, generic inference, generic inheritance

Code

I'm updating my event emitter to type all possible listeners, so that all on and emit methods are strictly typed. This is done by using conditional and mapped types, to infer the argument and function signatures from an interface like so:

export interface TaskEvents {
  // Define args 
  fail: [Error];
  // Define args and return (entire func)
  pass: (value: any) => Promise<any>;
}

This works well in isolation, as defined here: https://github.com/milesj/boost/pull/45/files#diff-af915a397c4f33b89e6fba137b261584

However, when consumed in another package that is using generics to define the events, the type system breaks down. A few examples.

Constraints + default generics (DOESNT WORK)

This approach uses generic constraints and defaults to define the events. This is ideal so that sub-classing can inherit the parents events and everything works correctly.

export interface TaskEvents {
  fail: [Error];
  pass: [any];
  run: [any];
  skip: [any];
}

class Task<Events extends TaskEvents = TaskEvents> extends Emitter<Events> {}

task.emit('run', [something]);

However, this doesn't seem to work:
Screen Shot 2019-03-18 at 19 44 39
Screen Shot 2019-03-18 at 19 44 50

Defaults only (DOESNT WORK)

This approach only uses defaults and no constraints, but obviously doesn't work since the class isn't aware of the explicit events.

class Task<Events = TaskEvents> extends Emitter<Events> {}

task.emit('run', [something]);

Screen Shot 2019-03-18 at 19 46 36

Constraints only (DOESNT WORK)

Inverse of the previous example. This has the same problem as the first example.

class Task<Events extends TaskEvents> extends Emitter<Events> {}

task.emit('run', [something]);

Screen Shot 2019-03-18 at 19 44 39

Screen Shot 2019-03-18 at 19 44 50

No sub-class generics / Super-class explicitly defined (DOES WORK)

This approach does work.

class Task extends Emitter<TaskEvents> {}

task.emit('run', [something]);

Screen Shot 2019-03-18 at 19 49 52

However, this is not ideal since sub-classes can't override the defined events. An example as so:

export interface RoutineEvents extends TaskEvents {
  command: [string];
  'command.data': [string, string];
}

class Routine<Events extends RoutineEvents = RoutineEvents> extends Task<Events> {}

// Should inherit and work
routine.emit('run', [something]);

Expected behavior:

The constraints/defaults on class generics are inherited correctly.

Actual behavior:

They don't seem to be inherited correctly unless explicitly defined.

Playground Link:

PR has a handful of usage: milesj/boost#45

Related Issues:

Looked around a bit, found this?

@rjamesnw
Copy link

I tried this:

type ListenerType<T> =
    T extends (...args: any[]) => any ? T :
    T extends unknown[] ? (...args: T) => boolean | void :
    never;

type Arguments<T> =
    T extends (...args: infer A) => unknown ? A :
    T extends unknown[] ? T :
    never;

class Emitter<T>{
    //listeners: { [K in keyof T]?: Set<ListenerType<T[K]>> } = {};

    emit<K extends keyof T>(eventName: K, args: Arguments<T[K]>): this {
        /*Array.from(this.getListeners(eventName)).forEach(listener => {
            listener(...args);
        });*/
        return this;
    }
}

interface TaskEvents {
    fail: [Error];
    pass: [any];
    run: [any];
    skip: [any];
}

class Task<Events extends TaskEvents = TaskEvents> extends Emitter<Events> { }

var task = new Task();
task.emit('run', [true]);

And it works. I'm using TS 3.3.3333.

@rjamesnw
Copy link

I noticed someone else had an issue with changes in 3.4 regarding conditional types; not sure if the underlying reason is related: #30489

@milesj
Copy link
Author

milesj commented Mar 19, 2019

@rjamesnw I don't see any differences with your example? Does the emit() call work when within the Task class body? That's usually where I see the issues.

@rjamesnw
Copy link

rjamesnw commented Mar 19, 2019

Ok, I've confirmed this doesn't work:

class Task<Events extends TaskEvents = TaskEvents> extends Emitter<Events> { 
	run() {
		return this.emit('run', [true]);
	}
}

This seems to be an issue with the conditional type. It works when I do this:

type Arguments<T> = T;

The moment I add any condition it fails.

@milesj
Copy link
Author

milesj commented Mar 19, 2019

Yeah, definitely seems like a bug then. Or perhaps one of those cases not supported by design.

@milesj
Copy link
Author

milesj commented Mar 20, 2019

I tried another approach where the Emitter is an instance on a class property. This also doesn't work in sub-classes (Routine), but works in the super-class (Task), but this is expected.

export interface TaskEvents {
  fail: [Error];
  pass: [any];
  run: [any];
  skip: [any];
}

class Task {
  protected emitter: Emitter<TaskEvents>;

  constructor() {
    this.emitter = new Emitter();
  }
}

export interface RoutineEvents extends TaskEvents {
  command: [string];
  'command.data': [string, string];
}

class Routine extends Task {
  protected emitter: Emitter<RoutineEvents>;

  constructor() {
    super();
    this.emitter = new Emitter();
  }
}

Screen Shot 2019-03-19 at 18 53 37

Screen Shot 2019-03-19 at 18 53 47

@milesj
Copy link
Author

milesj commented Mar 20, 2019

And also this approach which sort of combines the 2. Also doesnt work. Has the same problem as the original post.

export interface TaskEvents {
  fail: [Error];
  pass: [any];
  run: [any];
  skip: [any];
}

class Task<Events extends TaskEvents = TaskEvents> {
  protected emitter: Emitter<Events>;

  constructor() {
    this.emitter = new Emitter();
  }
}

@milesj
Copy link
Author

milesj commented Mar 23, 2019

Tried a few more things, still no luck. Any word? Is this a lost cause?

@RyanCavanaugh
Copy link
Member

@milesj I was hoping someone would come up with a TL;DR isolated repro that works in the Playground and demonstrates the behavior considered wrong here. Or is this a question? It's not obvious

@milesj
Copy link
Author

milesj commented Mar 25, 2019

@RyanCavanaugh All the examples above do not work, but heres a playground based on @rjamesnw code examples. https://www.typescriptlang.org/play/#src=type%20ListenerType%3CT%3E%20%3D%0D%0A%20%20%20%20T%20extends%20(...args%3A%20any%5B%5D)%20%3D%3E%20any%20%3F%20T%20%3A%0D%0A%20%20%20%20T%20extends%20unknown%5B%5D%20%3F%20(...args%3A%20T)%20%3D%3E%20boolean%20%7C%20void%20%3A%0D%0A%20%20%20%20never%3B%0D%0A%0D%0Atype%20Arguments%3CT%3E%20%3D%0D%0A%20%20%20%20T%20extends%20(...args%3A%20infer%20A)%20%3D%3E%20unknown%20%3F%20A%20%3A%0D%0A%20%20%20%20T%20extends%20unknown%5B%5D%20%3F%20T%20%3A%0D%0A%20%20%20%20never%3B%0D%0A%0D%0Aclass%20Emitter%3CT%3E%7B%0D%0A%20%20%20%20%2F%2Flisteners%3A%20%7B%20%5BK%20in%20keyof%20T%5D%3F%3A%20Set%3CListenerType%3CT%5BK%5D%3E%3E%20%7D%20%3D%20%7B%7D%3B%0D%0A%0D%0A%20%20%20%20emit%3CK%20extends%20keyof%20T%3E(eventName%3A%20K%2C%20args%3A%20Arguments%3CT%5BK%5D%3E)%3A%20this%20%7B%0D%0A%20%20%20%20%20%20%20%20%2F*Array.from(this.getListeners(eventName)).forEach(listener%20%3D%3E%20%7B%0D%0A%20%20%20%20%20%20%20%20%20%20%20%20listener(...args)%3B%0D%0A%20%20%20%20%20%20%20%20%7D)%3B*%2F%0D%0A%20%20%20%20%20%20%20%20return%20this%3B%0D%0A%20%20%20%20%7D%0D%0A%7D%0D%0A%0D%0Ainterface%20TaskEvents%20%7B%0D%0A%20%20%20%20fail%3A%20%5BError%5D%3B%0D%0A%20%20%20%20pass%3A%20%5Bany%5D%3B%0D%0A%20%20%20%20run%3A%20%5Bany%5D%3B%0D%0A%20%20%20%20skip%3A%20%5Bany%5D%3B%0D%0A%7D%0D%0A%0D%0Aclass%20Task%3CEvents%20extends%20TaskEvents%20%3D%20TaskEvents%3E%20extends%20Emitter%3CEvents%3E%20%7B%20%0D%0A%20%20%20%20run()%20%7B%0D%0A%20%20%20%20%20%20%20%20this.emit('run'%2C%20%5Btrue%5D)%3B%0D%0A%20%20%20%20%7D%0D%0A%7D%0D%0A%0D%0Avar%20task%20%3D%20new%20Task()%3B%0D%0Atask.emit('run'%2C%20%5Btrue%5D)%3B%0D%0A

@RyanCavanaugh
Copy link
Member

It really seems like you want

class Task extends Emitter<TaskEvents> { 
    run() {
        this.emit('run', [true]);
    }
}

Without a concrete type for Events, it's never going to be possible/correct to instantiate a value that matches a deferred type.

@RyanCavanaugh RyanCavanaugh added the Question An issue which isn't directly actionable in code label Mar 25, 2019
@milesj
Copy link
Author

milesj commented Mar 25, 2019

Do you have any suggestion on trying to implement a feature like this?

@RyanCavanaugh
Copy link
Member

I'd need to understand why you think Task needs its type parameter to advise further. The existence of the Events type parameter there necessarily implies that Task itself cannot manufacture suitable values to emit, which is why you see the error.

@milesj
Copy link
Author

milesj commented Mar 25, 2019

The main use is that Task can be inherited from another class, and that class may provide additional events to emit.

export interface RoutineEvents extends TaskEvents {
  command: [string];
  'command.data': [string, string];
}

class Routine extends Task<RoutineEvents> {}

Not a 100% blocker but would be really really nice to have.

@RyanCavanaugh
Copy link
Member

That pattern is problematic because it's unsound (because RoutineEvents might have a more-specific type for one of its properties).

Pushing the derived type in separately fixes this:

class Task<TAdditional = {}> extends Emitter<TaskEvents> {
    protected emitter: Emitter<TaskEvents>;

    constructor() {
        super();
        this.emitter = new Emitter();
    }

    run() {
        this.emit('run', [true]);
    }
}

export interface RoutineEvents extends TaskEvents {
    command: [string];
    'command.data': [string, string];
}

class Routine extends Task<RoutineEvents> {
    protected emitter: Emitter<TaskEvents & RoutineEvents>;

    constructor() {
        super();
        this.emitter = new Emitter();
    }
    command(s: string) {
        this.emit("run", [s]);
    }
}

@milesj
Copy link
Author

milesj commented Mar 26, 2019

Thank you, will give this a shot.

@milesj
Copy link
Author

milesj commented Apr 3, 2019

Went with another approach, since the API changes are either breaking, or just make it uglier/too complicated. It's basically Angular events: milesj/boost#45

Thanks for the help/info!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Question An issue which isn't directly actionable in code
Projects
None yet
Development

No branches or pull requests

3 participants