Home > OS >  Generic type of class method parameter type
Generic type of class method parameter type

Time:12-27

I'm having some issues trying to properly type a method in a class. First off, I have this class:

export class Plugin {
    configure(config: AppConfig) {}
    beforeLaunch(config: AppConfig) {}
    afterSetup(runtime: Runtime) {}
    afterStartup(runtime: Runtime) {}
    onUserError(error: UserError) {}
}

And another class that programmatically runs some of it's methods:

export class PluginManager {
    _plugins: Plugin[];
    _pythonPlugins: any[];

    constructor() {
        this._plugins = [];
        this._pythonPlugins = [];
    }

    private setUpPlugins = (property: keyof Plugin, parameter:AppConfig | Runtime | UserError ) => {
        for (const p of this._plugins) p[property](parameter); // <- parameter is erroring out
        for (const p of this._pythonPlugins) p[property]?.(parameter);
    }

The issue is that the parameter in the first programmatic call in setUpPlugins is complaining:

Argument of type 'AppConfig | Runtime | UserError' is not assignable to parameter of type 'AppConfig & Runtime & UserError'.
  Type 'AppConfig' is not assignable to type 'AppConfig & Runtime & UserError'.
    Type 'AppConfig' is missing the following properties from type 'Runtime': config, src, interpreter, globals, and 8 more. (tsserver 2345)    

I have no idea why the type it's expecting is an intersection and not a union. Anyway, I tried to solve this using generics:

    private setUpPlugins = <T extends keyof Plugin, U extends Parameters<Plugin[T]>> (property: T, parameter:U ) => {
        for (const p of this._plugins) p[property](parameter);  
        for (const p of this._pythonPlugins) p[property]?.(parameter);
    }

but now parameter is complaining with a new error:

Argument of type '[config: AppConfig] | [config: AppConfig] | [runtime: Runtime] | [runtime: Runtime] | [error: UserError]' is not assignable to parameter of type 'AppConfig & Runtime & UserError'.
  Type '[config: AppConfig]' is not assignable to type 'AppConfig & Runtime & UserError'.
    Type '[config: AppConfig]' is not assignable to type 'Runtime'. (tsserver 2345) 

What's the proper way to solve this? I'm trying to avoid using any.

CodePudding user response:

The version like

private setUpPlugins = (
  property: keyof Plugin, parameter: AppConfig | Runtime | UserError 
) => {
  for (const p of this._plugins) p[property](parameter); // error!
  // Argument of type 'AppConfig | Runtime | UserError' is not assignable to 
  // parameter of type 'AppConfig & Runtime & UserError'.
}

fails for a good reason; if property has the union type keyof Plugin and parameter has the union type AppConfig | Runtime | UserError, nothing would stop someone from calling setupPlugins() with mismatched arguments. These unions are uncorrelated; nothing ensures that the right key goes with the right value.

The only way to call a union of functions like p[property] with an argument that the compiler knows is safe is with an intersection of argument types, as described in the documentation. If parameter were of type AppConfig & Runtime & UserError, then it would be safe to call p[property](parameter) no matter what property is. But AppConfig | Runtime | UserError is not that type, so it's not safe to allow the call.


Ideally you'd restrict property and parameter to be properly correlated. This is possible to represent in a number of ways. For example, here is a (slightly fixed) version of your attempt with generics:

private setUpPlugins = <K extends keyof Plugin>(
  property: K, parameter: Parameters<Plugin[K]>[0]
) => {
  for (const p of this._plugins) p[property](parameter); // error!
  // Argument of type 'AppConfig | Runtime | UserError' is not 
  // assignable to parameter of type 'AppConfig & Runtime & UserError'.
}

But as you can see you get the same error. Here it is much less likely that mismatched terms will be passed in, but the compiler cannot follow the logic. It still acts like property and parameter are uncorrelated union types.

TypeScript doesn't have good support for so-called correlated union types. This is the subject of microsoft/TypeScript#30581, and for the longest time the best I knew to do would be to use some type assertions and move on.


However nowadays we have the approach described in microsoft/TypeScript#47109: refactor the types to a "basic" mapping interface that clearly represents the correlation we care about. And then perform all operations in terms of distributive object types, where we map over the mapping interface and immediately index into it to get the relevant union if we need one. This is usually more work than one thinks should be necessary, but it has the advantage that the compiler can actually follow it.

Given your example code, the refactoring might look like:

interface PluginParam {
  configure: AppConfig,
  beforeLaunch: AppConfig,
  afterSetup: Runtime,
  afterStartup: Runtime,
  onUserError: UserError
}

type PluginMethods = { [K in keyof PluginParam]: (arg: PluginParam[K]) => void }

The PluginMethods type, if you examine it, looks very much like your Plugins class, which is intentional. If Plugins were an interface and not a class, we could just use PluginMethods. Instead we need to keep the class. To make sure we still get type safety, we can use an implements clause:

export class Plugin implements PluginMethods { // okay
  configure(config: AppConfig) { }
  beforeLaunch(config: AppConfig) { }
  afterSetup(runtime: Runtime) { }
  afterStartup(runtime: Runtime) { }
  onUserError(error: UserError) { }
}

And now for the method:

private setUpPlugins = <K extends keyof PluginParam>(
  property: K, parameter: PluginParam[K]
) => {
  const _plugins: PluginMethods[] = this._plugins;
  for (const p of _plugins) p[property](parameter); // okay
}

We annotate a new _plugins variable to be of type PluginMethods[] and assign this._plugins to it. This seems pointless, but it's to make the compiler understand that this._plugins can be treated as the PluginMethods, a mapped type on PluginParam. Then, when we call p[property](parameter), everything works. p[property] is seen to be of type (arg: PluginParam[K]) => void, and parameter is seen to be of type PluginParam[K]. So the function is not a union of function types, it's a single generic function, whose argument is identical to the type of parameter. And thus it compiles with no error.

You will notice that if you change any of this (e.g., take the annotation off of const _plugins), the error will reappear; the compiler will lose the thread and you'll be back where you started. Only by having everything explicitly represented as generic mapped versions of a basic mapping interface can we get the compiler to verify what we're doing as safe, as much as it can.

Playground link to code

  • Related