Tom Frost
Tom Frost

Reputation: 1005

How can I programmatically create class functions in Typescript?

I have a log class that needs to define various convenience functions such as error, warn, info, etc that all do the same thing: make a call to a log function with their severity and the data to be logged.

This is verbose and wastes KBs:

class Logger {
  error(...args: any[]): void {
    this.log('error', ...args)
  }
  warn(...args: any[]): void {
    this.log('warn', ...args)
  }
  // ...and so on
}

I currently have an enum of all the severities, and a union of strings of all the severities:

enum severityLevels {
  error,
  warn,
  info
}

type severities = keyof typeof severityLevels

What I'd like to do is programmatically create functions for each severity level in the logger class's constructor (a very simple Object.keys(severityLevels).forEach(...) in vanilla JS), however I can't figure out how to get TypeScript to allow this to happen. If I make an interface with all the severity function definitions, I can't implements it (the functions don't already exist). If I make them optional, then calling those log functions (which "might not exist") gets messy.

Any ideas?

Upvotes: 3

Views: 2785

Answers (1)

jcalz
jcalz

Reputation: 328262

You can programmatically add methods to a constructor's prototype in JavaScript and therefore in TypeScript. The tricky part is getting the type system to understand what you're doing, which is doable but requires a bit of type assertion.

First, let's come up with an actual runtime list of those severities:

const severityKeys =
  Object.keys(SeverityLevels).filter(k => k !== (+k) + "") as Severities[];

Note, I init-capitalized the types to SeverityLevels and Severities, as per TS convention. Also note that numeric enum objects in TypeScript also have reverse mappings: Severities.error is 0, and Severities[0] is "error". To get the list of just the keys, I have to filter out the "numeric" keys with k !== (+k)+"".

Then, let's make an example BaseLogger class that represents your logger's functionality without the added methods:

class BaseLogger {
  constructor(public loggerName: string) {

  }
  log(level: string, ...args: any[]) {
    console.log("[" + this.loggerName + "]", "[" + level + "]", ...args);
  }
}

Here's how we might do the programmatic extension:

type Logger = BaseLogger & Record<Severities, (...args: any) => void>;
const Logger = (
  class Logger extends BaseLogger { }
) as new (...args: ConstructorParameters<typeof BaseLogger>) => Logger;
severityKeys.forEach(k => Logger.prototype[k] = function (this: Logger, ...args: any) {
  this.log(k, ...args);
})

This creates both a type and a value named Logger, so it is usable like a normal class. The type corresponds to the type of the instances of Logger, which look just like BaseLogger with the added method properties with keys in Severities and values of (...args: any[])=>void.

The value Logger is the constructor of Logger instances, which starts off as an empty extension of BaseLogger (although we assert that it constructs Logger instances). After that we use severityKeys.forEach() to add the actual methods to Logger.prototype as needed.

Let's see if it works:

const logger = new Logger("My Logger");
logger.error(123); // [My Logger] [error] 123
logger.info(456); // [My Logger] [info] 456
logger.warn(789); // [My Logger] [warn] 789

Looks good to me. All right, hope that helps; good luck!

Link to code

Upvotes: 8

Related Questions