2

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!

3
  • 1
    Does this or this seem like an improvement? There really isn't any way to do this without altering the JS... unless you just do the JS completely separately and just make a separate TS declaration file for it. Let me know if you want any or all of that as an answer. Commented Aug 17, 2021 at 20:54
  • @jcalz - woah, you did in 10 minutes what I've been banging my head against the wall for hours about. I didn't know about that new type syntax! With one minor tweak (playground), your first example is perfect! Though i realize it wasn't a requirement in my example, there are some other normal methods on the class. Also, I'm happy to add x=_x as normal JS!! I just didn't want entirely new functions, etc. If you want to answer I'll gladly accept, thanks!!! Commented Aug 17, 2021 at 21:15
  • Okay, answered. You may want to add the foo() bit to your example code, to motivate the intersection type. Commented Aug 18, 2021 at 20:44

1 Answer 1

0

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

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.