I have an Observer that allows passing a payload and doesn’t care about the arguments passed to the callback. At the same time, I want to type the arguments when creating the callback so that I can access them without using any. Unknown doesn’t allow passing typed arguments.
The link includes part of the current Observer implementation. The issue with generics is that the ObservableObj interface is used when the constructor is called, at which point it doesn’t yet know the types of the arguments that will be passed to the callbacks.
and the code itself:
interface ObservableObj {
id: string;
once: boolean;
callback: (data?: unknown) => void;
}
export default class Observable {
private listeners: Map<string, Array<ObservableObj>>;
constructor() {
this.listeners = new Map<string, Array<ObservableObj>>();
}
public on(
eventType: string,
callback: (data?: unknown) => void,
once = false
): { id: string; eventType: string } {
if (!this.listeners.get(eventType)) {
this.listeners.set(eventType, []);
}
const id = this.generateId();
this.listeners.get(eventType)?.push({ id, callback, once });
return { id, eventType };
}
public fire(eventType: string, data: unknown) {
if (!(this.listeners.get(eventType) && this.listeners.get(eventType)!.length > 0)) {
return;
}
const listeners = this.listeners.get(eventType);
let i = listeners!.length - 1;
while (i >= 0) {
const f = listeners![i].callback;
if (listeners![i].once) {
listeners!.splice(i, 1);
}
i--;
f(data);
}
}
private generateId(): string {
return Math.random().toString(36).substring(2, 9);
}
}
const observable = new Observable();
observable.on('test-event', (data: {prop: boolean}) => console.log(data.prop));
observable.fire('test-event', {prop: true});
The only way this could work and behave the way you want is to use generics. The call to observable.on(eventName, callback)
has to either return something whose fire()
method cares only accepts arguments that callback
accepts, or it has to change the type of observable
to be something that does that to fire()
. Either way, ObservableObj
simply must be generic:
interface ObservableObj<T> {
id: string;
once: boolean;
callback: (data: T) => void;
}
It looks like you're expecting the situation where observable
itself has some state that updates every time you call on()
. This is technically possible to do if on()
is an assertion method and if the subsequent change to observable
is considered a narrowing, although it's somewhat limited and fragile. It might look like this:
declare class Observable<T extends object = {}> {
__x: T; // need some structural dependency on T
constructor();
on<K extends string, V>(
eventType: K, callback: (data: V) => void, once?: boolean
): asserts this is Observable<T & Record<K, V>>;
fire<K extends string & keyof T>(eventType: K, data: T[K]): void;
}
Note that the implementation of Observable
is out of scope here, since we mostly care about types. So I just use declare class
above.
Here Observable
is generic in T
, an object type that maps keys (the eventType
type) to values (the corresponding data
type). I needed to add a virtual property of type T
to the class instance type (it doesn't have to exist at runtime) to convince TypeScript that Observable<T>
actually depends structurally on T
.
When you create a new Observable
, the type of T
defaults to the empty object type. When you call on
, TypeScript will narrow this
from Observable<T>
to Observable<T & Record<K, V>>
. The intersection of T
with Record<K, V> means that T
is effectively augmented with a new property whose key is K
(the type of eventType
) and whose value is V
(the type of the data
expected by callback
).
Then fire()
only accepts eventType
s in keyof T
, and the data
must be the corresponding property type T[K]
(the indexed access type).
Let's test that out:
const observable: Observable = new Observable();
// ^^^^^^^^^^^^
// need to annotate this
observable.fire('test-event', { prop: true }); // error!
// ~~~~~~~~~~~~
// Argument of type 'string' is not assignable to parameter of type 'never'.
observable.on('test-event', (data: { prop: boolean }) => console.log(data.prop));
observable.fire('test-event', { prop: true }); // okay
try {
observable.fire('test-event', null); // error!
// ~~~~
// Argument of type 'null' is not assignable to parameter of type '{ prop: boolean; }'
} catch (e) {
console.log(e)
}
This works as you expect. If I call fire()
before on()
, TypeScript complains that "test-event"
is unexpected. Once I call on()
with test-event
and a callback expecting {prop: boolean}
, then I can call fire()
that way. If I try to call fire()
with null
instead of {prop: boolean}
, I get an error.
This is all well and good but it's fragile. First, you need to annotate observable
as having type Observable
. If you don't do that, then on()
will fail to act like an assertion method. It's a limitation of assertion methods that they have to be explicitly annotated as such.
And having a single variable whose state changes is tough, because it means things depend on the order of your calls, and if you pass that variable into functions, then TypeScript can't necessarily tell which things happen when and so your narrowings can reset.
And you can't return anything from an assertion method. So the {id, eventType}
receipt you used to return from on()
is absent now.
It would be easier for TypeScript to handle if your types don't need to track state. You could do this by, for example, having on()
return a new Observable
that has the added thing in it (and it can return a receipt also, or whatever you want):
declare class Observable<T extends object = {}> {
__x: T; // need some structural dependency on T
constructor();
on<K extends string, V>(
eventType: K, callback: (data: V) => void, once?: boolean
): Observable<T & Record<K, V>>;
fire<K extends string & keyof T>(eventType: K, data: T[K]): void;
}
And then when you call on()
you capture the return value as your new observable:
const o = new Observable();
o.fire('test-event', { prop: true }); // error!
// ~~~~~~~~~~~~
// Argument of type 'string' is not assignable to parameter of type 'never'.
const o1 = o.on('test-event', (data: { prop: boolean }) => console.log(data.prop));
o1.fire('test-event', { prop: true }); // okay
try {
o1.fire('test-event', null); // error!
// ~~~~
// Argument of type 'null' is not assignable to parameter of type '{ prop: boolean; }'
} catch (e) {
console.log(e)
}
This is effectively stateless. You can pass o1
into any function you want and rest assured that it will remember 'test-event'
.
Anyway, there are lots of different changes one might make here and this answer is already getting too long. The main point is that if you want to keep track of the types of eventName
and the corresponding data
, there is no alternative but to use generics.