Cleaner dynamic typescript class methods

Viewed 49

I'm converting a large project from flow to TS and am having trouble figuring out how to create dynamic class methods. I threw together a simple working example of what I'm trying to do:

ts playground link

declare type logMethod = (msg: string) => void;

class Logger {
  constructor(methods: string[]) {
    methods.forEach(method => this[method] = (msg: string) => Logger.logMethod.call(this, method, msg))
  }

  static logMethod(method: string, msg: string) {}

  [key: string]: any;
}

function LoggerFactory<T extends string>(methods: T[]): Logger & Record<T, logMethod> {
  const logger = new Logger(methods);
  return logger as Logger & Record<T, logMethod>;
}

const logger = LoggerFactory(['info', 'warn', 'error']);
logger.info('info');
logger.warn('warn');
logger.error('error');

this works just fine, as in info, warn, error, or any other method names I pass into the factory are typed properly. However I hate having extra JS just to make the type system happy. The LoggerFactory is completely unnecessary in JS and I'm trying to figure out how to get rid of it.

I was trying to go down a road like this:

class Logger<T extends string[]> {
  [method in keyof T]: logMethod;
}

but that mapping syntax doesn't work on classes or interfaces, only normal types. Just looking for ideas here!

1 Answers

TypeScript requires that all class or interface types have statically-known keys. So there's no way to annotate class Logger<K extends string> {} so that it has keys of type K. You can definitely describe the type of a class constructor which creates instances with dynamic keys, but you won't be able to apply such a type to something declared as a class statement.

What you can do is rename your class out of the way as class _Logger {...}, giving it the closest type you can to your desired behavior. Then you can write var Logger = _Logger as LoggerConstructor to assert that it's of the desired type LoggerConstructor, which you'll define so that its instances have dynamic keys as you want.

Here's one way to do it:

class _Logger {
  constructor(methods: string[]) {
    methods.forEach(method =>
      (this as Logger<any>)[method] = (msg: string) =>
        Logger.logMethod.call(this, method, msg))
  }
  foo() { return 42; }
  static logMethod(method: string, msg: string) {
    console.log("method: " + method + ", msg: " + msg)
  }
}

type Logger<K extends string> = Record<K, LogMethod> & _Logger;

interface LoggerConstructor {
  new <K extends string>(methods: K[]): Logger<K>;
  logMethod(method: string, msg: string): void;
}

var Logger = _Logger as LoggerConstructor;

The _Logger class works the way you want, but it is only known to have foo() as an instance method and logMethod() as a static method. You'd like the a Logger<K> instance to act as both Logger and as a Record<K, LogMethod>, so you can define it that way (with the [intersection])(https://www.typescriptlang.org/docs/handbook/2/objects.html#intersection-types). And finally, you can define LoggerConstructor as having a generic construct signature that produces instances of Logger<K> (as well as having whatever static properties you'd like to see on the constructor).

Now we can test it:

const logger = new Logger(['info', 'warn', 'error']);
// const logger: Logger<"info" | "warn" | "error">
logger.info('Info'); // method: info, msg: Info
logger.warn('Warn'); // method: warn, msg: Warn
logger.error('Error'); // method: error, msg: Error
console.log(logger.foo().toFixed(2)); // 42.00
logger.oops('Oops'); // error
// --> ~~~~ Property 'oops' does not exist

Looks good! The compiler knows that logger is of type Logger<"info" | "warn" | "error">, and thus that logger has info, warn, and error methods. It also knows about foo() from _Logger, and complains if you try to call a method like oops that doesn't exist.

Playground link to code

Related