Reputation: 533
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
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'.
Upvotes: 2