ndyr
ndyr

Reputation: 533

Property is missing in type which is an interface implemented by class

Node: 17.7.1

Typescript: 4.6.3

I was working with an older repo on DDD and I came across a Typescript error as I was trying to recreate the code, which I am not understanding how to fix.

The IDE "error" occurs in AfterSomethingCreated class when registering with the code of : Events.register(this.onSomethingCreatedEvent.bind(this), SomethingCreatedEvent.name);

Argument of type '(event: Event) => Promise<void>' is not assignable to parameter of type '(event: IEvent) => void'.
  Types of parameters 'event' and 'event' are incompatible.
    Property 'something' is missing in type 'IEvent' but required in type 'SomethingCreatedEvent'.ts(2345)

Class SomethingCreatedEvent implements IEvent interface. SomethingCreatedEvent also includes a property in addition to the properties from IEvent. When the property is included, the error is thrown, when taken out, the above error is thrown in the IDE

Code:

IEvent.ts

export interface IEvent {
    //.....
}

IHandle.ts

export interface IHandle<IEvent> {
  setupSubscriptions(): void;
}

Events.ts

export class Events {
    
    //Methods...

    public static register(callback: (event: IEvent) => void, eventClassName: string): void {
        //Do Stuff
    }

    //Methods...

 }

SomethingCreatedEvent.ts

export class SomethingCreatedEvent implements IEvent {
  //.....
  public something: Something;

  constructor (something: Something) {
    this.something = Something;
    //.....
  }
  //......

  }
}

AfterSomethingCreated (Where Error Is Occurring)

export class AfterSomethingCreated implements IHandle<SomethingCreatedEvent> {
  constructor () {
    this.setupSubscriptions();
  }

  setupSubscriptions(): void {
    ---> ERROR -> Events.register(this.onSomethingCreatedEvent.bind(this), SomethingCreatedEvent.name);
  }
  private async onSomethingCreatedEvent (event: SomethingCreatedEvent): Promise<void> {
    //Do stuff
  }
}

Upvotes: 3

Views: 4099

Answers (1)

jcalz
jcalz

Reputation: 330456

The error happens because Events.register() takes a callback that supposedly accepts any IEvent whatsoever. Thus it should be perfectly acceptable to actually call the callback with the minimal possible IEvent (in your case since IEvent is an empty interface this is just {}, the empty object):

public static register(callback: (event: IEvent) => void, eventClassName: string): void {
    callback({}); // <-- look, no error
}

On the other hand the onSomethingCreatedEvent() method expects that its input will be a SomethingCreatedEvent, and so it should be perfectly acceptable for this method to access event properties unique to SomethingCreatedEvent objects, like the something property (whose value I am assuming is string, since you didn't define the Something type in your code. That is, I'm acting as if type Something = string;):

private async onSomethingCreatedEvent(event: SomethingCreatedEvent): Promise<void> {
    console.log(event.something.toUpperCase());
}

But now inside setupSubscriptions() you are passing a callback which only accepts SomethingCreatedEvent events to Events.register(), which is an error:

setupSubscriptions(): void {
    Events.register(this.onSomethingCreatedEvent.bind(this), SomethingCreatedEvent.name); // error!
    // -----------> ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    // Argument of type '(event: SomethingCreatedEvent) => Promise<void>' is not
    // assignable to parameter of type '(event: IEvent) => void'.
}

And that's an error for good reason. If you call the code as modified above, you get a runtime error because somewhere we're calling a callback with the wrong input:

new AfterSomethingCreated();
// RUNTIME ERROR: Uncaught (in promise) TypeError: event.something is undefined

Since this was existing code, presumably this doesn't actually happen in practice. There's actually a bunch of existing JavaScript which is technically unsafe this way. TypeScript checks method parameters in a bivariant way, meaning that it will allow both safe narrowing and unsafe widening operations. Function parameters are checked more strictly (assuming you have the --strictFunctionTypes compiler option enabled, which is part of the --strict suite of compiler features).

If you want to get the more loosely typed behavior, you need to represent the type of callback as a method instead of a function. By the way, here's the difference:

interface Test {
    functionSyntax: (ev: IEvent) => void;
    methodSyntax(ev: IEvent): void;
}
const test: Test = {
    functionSyntax: (ev: SomethingCreatedEvent) => { }, // error!
    methodSyntax: (ev: SomethingCreatedEvent) => { } // okay!
}

See how the declaration of functionSyntax in Test is a property with an arrow function expression type, while methodSyntax looks more like a method declaration. And see how the implementation of test complains about the functionSyntax property accepting too narrow of a type, while the methodSyntax property does not complain.

So if you want to just suppress the error, you can rely on method syntax. Well, it's a little tricky, because there's no method syntax for standalone functions. You can't write (ev: IEvent): void as a type, and {(ev: IEvent): void} is treated like function syntax. The trick here is to make an actual method type and then index into the surrounding object:

type MethodSyntax = { method(event: IEvent): void }["method"]
// type MethodSyntax = (event: IEvent) => void, but marked as a method

And now if you write Events.register() with that:

public static register(callback: MethodSyntax, eventClassName: string): void { }

Then your call will suddenly work with no error:

Events.register(this.onSomethingCreatedEvent.bind(this), SomethingCreatedEvent.name); // okay

This isn't any more type safe, but at least it's not in error.


If you care about enforcing type safety, then you'll probably need to refactor so that nothing bad can happen when a handler handles a callback. Here's one possible approach:

class Events {
    static handlers: ((event: IEvent) => void)[] = [];
    public static register<T extends IEvent>(
        callback: (event: T) => void,
        eventClass: new (...args: any) => T
    ): void {
        this.handlers.push(ev => ev instanceof eventClass && callback(ev));
    }
    public static handleEvent(event: IEvent) {
        this.handlers.forEach(h => h(event));
    }
}

Now Events.register() is a generic function that accepts a callback that only accepts an event of type T, and an eventClass constructor (instead of a class name) for T. This way each handler can be called for each event... we don't call callback(event) unless event instanceof eventClass. With just a class name, it would be hard for the compiler to verify that any particular event would be appropriate for any particular callback, as the name property of classes is not strongly typed in TypeScript (see microsoft/TypeScript#43325 and issues linked within for more info).

And then the following is accepted now for SomethingCreatedEvent:

    setupSubscriptions(): void {
        Events.register(this.onSomethingCreatedEvent.bind(this), SomethingCreatedEvent);
    }

while something inappropriate would be flagged:

Events.register((o: SomethingCreatedEvent) => { }, Date) // error!
// Property 'something' is missing in type 'Date' but required in type 'SomethingCreatedEvent'.

Playground link to code

Upvotes: 2

Related Questions