TypeScript Narrowing Rest Arguments to Type

53 views Asked by At

I am attempting to write a TypeScript wrapper around an existing JavaScript system that posts and sends events, for the sake of this question, the underlying JavaScript system cannot be changed.

The underlying event system is untyped, and essentially has publish and subscribe events, like so.

publish(eventName: string, ...args: any[])
subscribe(eventName: string, callback: Function)

Without typing, you would subscribe or publish to an event like so.

subscribe("myEvent", (myNumber: number, myString: string) => { 
    //... 
};

publish('myEvent', 1,  'hello');

I am attempting to write a type safe wrapper around this, which currently looks like so.

type MyEvent = {
    myNumber: number;
    myString: string;
};

type EventDefs = {
    "myEvent": MyEvent
};

type Event = keyof EventDefs;

function typedSubscribe<T extends Events>(
  eventName: T,
  callback: (payload: EventDefs[T]) => void
): void {
  subscribe(eventName, (...args: unknown[]) => {
    callback(args);
  });
}

However obviously at this point unknown[] or any[] cannot be infered to EventDefs[T] by the compiler.

On the point of subscribe it is safe to assume the arguments are correct.

How do I narrow the unknown[] type of ...args to EventDefs[T]? For example, when subscribing to myEvent, the event type would be MyEvent.

1

There are 1 answers

0
Vasily Liaskovsky On

Tricky part is to convert record type to tuple of its fields' types. I used this answer, slightly modified. Note that in some cases this transformation is ambiguous and faulty (see mentioned question).

type SomeEvent = {
  n1: number;
  b1?: boolean;
};

type EventDefs = {
  'someEvent': SomeEvent;
}

type UnionToIntersection<U> = (U extends never ? never : (arg: U) => never) extends (arg: infer I) => void ? I : never;
type UnionToMappedTuple<R, T> = UnionToIntersection<T extends never ? never : (t: T) => T> extends (_: never) => infer W ? [...UnionToMappedTuple<R, Exclude<T, W>>, W extends keyof R ? R[W] : never] : [];
type RecordToTuple<R> = UnionToMappedTuple<R, keyof R>;

type EventTuples = {
  [K in keyof EventDefs]: RecordToTuple<EventDefs[K]>;
}

declare function typedPublish<T extends keyof EventTuples>(type: T, ...args: EventTuples[T]): void;
declare function typedSubscribe<T extends keyof EventTuples>(type: T, callback: (...args: EventTuples[T]) => void): void;

/* Examples */
typedPublish('someEvent', 1, true);
typedPublish('someEvent', 1);            // (!) Expected 3 arguments, but got 2
typedPublish('someEvent', 1, true, 'b'); // Expected 3 arguments, but got 4
typedPublish('someEvent', true, 1);      // Argument of type 'boolean' is not assignable to parameter of type 'number'.
typedPublish('someEvent', 1, 2);         // Argument of type 'number' is not assignable to parameter of type 'boolean'.

typedSubscribe('someEvent', (n1: number, b1?: boolean) => {});         // ok
typedSubscribe('someEvent', (n1: number, b1: boolean) => {});          // Type 'boolean | undefined' is not assignable to type 'boolean'
typedSubscribe('someEvent', (n1: number, b1: boolean, x3: any) => {}); // Argument of type '(n1: number, s1: boolean, x3: any) => void'
    // is not assignable to parameter of type '(args_0: number, args_1: boolean | undefined) => void'.
typedSubscribe('someEvent', (n1: number, n2: number) => {});           // Type 'boolean | undefined' is not assignable to type 'number'
typedSubscribe('someEvent', (s1: string) => {});                       // Type 'number' is not assignable to type 'string'.
typedSubscribe('someEvent', (n1: number) => {});                       // ok

Unfortunately, there are still problems with optional properties/parameters.