typescript

How to Automate Argument Types in a Handler Based on a Dynamic Event Registry in TypeScript?


I am developing a TypeScript application where events and their respective arguments are registered dynamically. However, I want the on method to automatically infer the type of its arguments based on these registrations, without manually defining the types for each event.

Here is an example code:

type ParameterType<T> = {
  regexSnippet: string,
  parse?: (value: string) => any,
  type: T // Type used for conversion in the .on method
}

const ParameterTypes = {
  "string": { regexSnippet: "\\w+" } as ParameterType<string>,
  "string?": { regexSnippet: "\\w*" } as ParameterType<string | undefined>,
  "number": { regexSnippet: "\\d+", parse: parseInt } as ParameterType<number>,
  "number?": { regexSnippet: "\\d*", parse: parseInt } as ParameterType<number | undefined>,
} as const

type ParameterTypesMap = typeof ParameterTypes
type ParameterTypeKeys = keyof ParameterTypesMap
type ParameterSchema = { [name: string]: ParameterTypeKeys }

class Test<RE extends ParameterSchema> {
  private registreEvents: Record<string, RE> = {};

  registerEvent(evname: string, args: RE) {
    this.registreEvents[evname] = args;
  }

  // I want to replace this:
  on(evname: "a", args: { name: string, age: number }): any;
  on(evname: "b", args: { nickname: string | undefined }): any;
  on(evname: "c", args: { count: number }): any;

  // With something automated based on the registrations:
  on<T extends keyof RE>(evname: T, args: { [K in keyof RE[T]]: ParameterTypesMap[RE[T][K]]["type"] }): any {
    // Handler implementation
  }
}

const test = new Test();

test.registerEvent("a", { name: "string", age: "number" });
test.registerEvent("b", { nickname: "string?" });
test.registerEvent("c", { count: "number" });

// Desired usage:
test.on("c", { count: 12 }); // OK
test.on("c", { zzzzz: 12 }); // Should give a type error

The main issue is that I currently need to manually declare the on methods for each event (as shown above for events a, b, and c). I would like these types to be automatically inferred from what is registered in the registerEvent method.

I tried something like:

on<T extends keyof RE>(evname: T, args: { [K in keyof RE[T]]: ParameterTypesMap[RE[T][K]]["type"] }) {}

But this does not work, and I get type-related errors in TypeScript.

Does anyone know how to make TypeScript correctly infer the argument types for the on method based on the registration done in the registerEvent method?

Constraints

  1. I cannot use hardcoded solutions.
  2. Type validation in the on method must be dynamic and directly tied to the registrations made in registerEvent.

Any help is greatly appreciated!


Solution

  • First it seems that we could use a utility type converting a ParameterSchema to the corresponding object type:

    type ParameterSchemaToObj<T extends ParameterSchema> =
        { [K in keyof T]: ParameterTypesMap[T[K]]["type"] }
    

    That's not strictly necessary; it just makes it easier to reason about the code, especially if T above turns out to be more complicated when used.


    If you have an object and you want calls to a void-returning method to update the apparent type of the object to a narrower type, you can make the method an assertion function whose return type is of the form asserts this is ⋯. There are definitely caveats and complications to assertion methods, but the basic functionality you're looking for is possible.

    One approach we can take here is to make the registerEvent() method return an asserts predicate that takes the current class instance this type and intersects it with an object type with a call signature for on() that corresponds to it. So each time you call registerEvent() you get a new overload for on:

    declare class Test {
      registerEvent<K extends string, V extends ParameterSchema>(
        evname: K, args: V
      ): asserts this is this & {
        on(evname: K, params: ParameterSchemaToObj<V>): any
      };
    }
    

    That's just a declaration; I'm not implementing it or worrying about the implementation. Let's test it out:

    const test: Test = new Test(); 
    //        ^^^^^^ -- annotation
    

    There's the first big caveat. You need to annotate the variable for this to work as intended. If you wrote the more obvious const test = new Test(), you'd find that calls to registerEvent() would result in a compiler error:

    const t = new Test();
    t.registerEvent("a", {}); // error!
    // Assertions require every name in the call target 
    // to be declared with an explicit type annotation.
    

    That requirement is there to limit assertion functions' effects on control flow analysis to avoid weird circularities and performance problems. See microsoft/TypeScript#45385 for a relevant issue with links to other relevant issues.

    Okay, so we've annotated test as Test. Let's test it out:

    test.registerEvent("a", { a: "number", b: "number", c: "string" });
    /* const test: Test & 
      { on(evname: "a", params: { a: number; b: number; c: string; }): any; } 
    */
    test.registerEvent("a", { name: "string" });
    /* const test: Test & 
      { on(evname: "a", params: { a: number; b: number; c: string; }): any; } &
      { on(evname: "a", params: { name: string; }): any; } 
    */
    test.registerEvent("b", { a: "number" });
    /* const test: Test & 
      { on(evname: "a", params: { a: number; b: number; c: string; }): any; } &
      { on(evname: "a", params: { name: string; }): any; } &
      { on(evname: "b", params: { a: number; }): any; }
    */
    test.registerEvent("c", { a: "string" });
    /* const test: Test & 
      { on(evname: "a", params: { a: number; b: number; c: string; }): any; } &
      { on(evname: "a", params: { name: string; }): any; } &
      { on(evname: "b", params: { a: number; }): any; } &
      { on(evname: "c", params: { a: string; }): any;
    */
    

    You can see that after each call to registerEvent(), the test object gets a corresponding overload call signature for on(). That gives the desired behavior:

    test.on("a", { a: 1, b: 1, c: "str" }) // ok
    test.on("a", { name: "str" }) // ok
    test.on("a", { z: 123 }); // error
    test.on("b", { a: 1 }) // ok
    test.on("c", { a: "str" }) // ok
    

    So there you go, it works.


    But do note another caveat about assertion functions: they have to be valid narrowings. Since this & X is a narrowing of this for any X, then the above declaration satisfies this. If you somehow fail to do a proper narrowing, though, then calls to registerEvent() can silently fail to narrow this. Consider the following conceptually similar approach:

    declare class Test<T extends Record<keyof T, object> = {}> {
      registerEvent<K extends string, V extends ParameterSchema>(
        evname: K, args: V
      ): asserts this is Test<{
        [P in keyof T | K]:
        (P extends keyof T ? T[P] : never) |
        (P extends K ? ParameterSchemaToObj<V> : never)
      }>
      on<K extends keyof T>(evname: K, params: T[K]): any;
    }
    

    Here we've made Test generic in the mapping T from event names to the corresponding arguments. The intent is if you call test.registerEvent() as shown above, your Test<{}> would evolve into a Test<{ a: { a: number, b: number, c: string } | { name: string }, b: { a: number }, c: { a: string } }>. But that doesn't happen:

    const test: Test = new Test()
    // const test: Test<{}>
    test.registerEvent("a", { a: "number", b: "number", c: "string" });
    // const test: Test<{}> ?!!
    test.registerEvent("a", { name: "string" });
    // const test: Test<{}> ?!!
    test.registerEvent("b", { a: "number" });
    // const test: Test<{}> ?!!
    test.registerEvent("c", { a: "string" });
    // const test: Test<{}> ?!!
    
    test.on("a", { a: 1, b: 1, c: "str" }) // error
    test.on("a", { name: "str" }) // error
    test.on("b", { a: 1 }) // error
    test.on("c", { a: "str" }) // error
    

    Oops, it didn't work as desired. TypeScript failed to see the resulting Test<> type as being a narrowing of the current Test<> type, and so you get a silent lack of change, and then none of the on() calls work.

    If we instead had written the exact same thing as a fluent builder:

    declare class Test<T extends Record<keyof T, object> = {}> {
      registerEvent<K extends string, V extends ParameterSchema>(
        evname: K, args: V
      ): Test<{
        [P in keyof T | K]:
        (P extends keyof T ? T[P] : never) |
        (P extends K ? ParameterSchemaToObj<V> : never)
      }>
      on<K extends keyof T>(evname: K, params: T[K]): any;
    }
    

    Then it would have worked just fine:

    const test = new Test()
      .registerEvent("a", { a: "number", b: "number", c: "string" })
      .registerEvent("a", { name: "string" })
      .registerEvent("b", { a: "number" })
      .registerEvent("c", { a: "string" });
    /* const t: Test<{
      a: { a: number; b: number; c: string; } | { name: string; };
      b: { a: number; };
      c: { a: string; };
     }> */
    
    test.on("a", { a: 1, b: 1, c: "str" }) // okay
    test.on("a", { name: "str" }) // okay
    test.on("a", { z: 123 }); // error
    test.on("b", { a: 1 }) // okay
    test.on("c", { a: "str" }) // okay
    

    Fluent builder patterns tend to be more stable and generalizable than assertion methods, and they play more nicely with TypeScript's type system. Mutations of types are always tricky and have weird caveats. So while you could use an assertion method, I'd probably recommend avoiding them.

    Playground link to code