typescript: automatically create getters for all of a constructor's named parameters

Viewed 142

To make constructor calls more readable, I generally prefer to use named parameters as follows:

class MyClass {
  constructor(private args: {a: number, b: string, c: boolean}) {}
}

// Example constructor call:
const x = new MyClass({a: 1, b: "hello", c: true})

However, I also want a, b, and c to be publicly readable, i.e. I'd like to be able to call x.a, x.b and x.c rather than using x.args.a, x.args.b, and x.args.c. I can come up with a few different ways of doing this (see below), but they all involve repetitive boilerplate to explicitly create properties or getters.

Is there a way that I can create these getters automatically, e.g. by writing a decorator or using a generic?

Approach 1: explicitly declare properties and populate them in the constructor

This is pretty verbose!

class MyClass1 {
  a: number;
  b: string;
  c: boolean;
  
  constructor(private args: {a: number, b: string, c: boolean}) { 
    this.a = args.a;
    this.b = args.b; 
    this.c = args.c;
  }
}

Approach 2: store args and write getters

More concise than Approach 1, but I'd love to have these getters created automatically.

class MyClass2 {
  
  constructor(private args: {a: number, b: string, c: boolean}) {};

  get a(): number {return this.args.a}
  get b(): string {return this.args.b}
  get c(): boolean {return this.args.c}
}
2 Answers

This does what you want using decorators:

// Decorator setup

interface IndexedObject { [name: string]: any }
type Constructor = new(...args: any[]) => IndexedObject

function namedConstructorArgs <T extends Constructor> (targetConstructor: T): T {
  return class proxyConstructor extends targetConstructor {
    constructor(...args: any[]){
      super(args)
      Object.entries(args[0]).forEach(
        eachNamedArg => this[ eachNamedArg[0] ] = eachNamedArg[1]
      )
    }
  }
}

// In main program

abstract class MyClassLoggerProperties {
  a: string
  b: number
}

@namedConstructorArgs
class MyClassLogger extends MyClassLoggerProperties {

  constructor(_: MyClassLoggerProperties) {
    super()
  }

  log() {
    console.log(this.a, this.b)
  }

}

new MyClassLogger( { a: "i like flake number", b: 99 } )
  .log() // logs: I like flake number, 99

Note I had to turn off the strictPropertyInitialization compiler flag so the compiler accepts the properties of the class not being initialised in its own constructor. You could also initialise the properties to dummy values if you prefer to keep the compiler flag.

The abstract class is used to ensure the class properties in the statement of the class and in the constructor are the same as compiler won't check they are consistent otherwise. You could repeat the properties declaration in these places and omit the super() call in the constructor but it wouldn't be as type safe.

With many thanks to Simon Jacobs for this answer and the comment thread that followed, this seems to be the most concise approach:

// type containing the properties (not methods) of a type T
type PropertiesFilter<T> = {
  [K in keyof T as T[K] extends ((...args: any) => any) ? never : K]: T[K]
}

abstract class NamedConstructorArgsBaseClass<T> {

  // Constructor takes an object with all the 
  // properties (not methods) of T
  constructor(args: PropertiesFilter<T>) {
    // Set all of those properties on the object being constructed
    Object.entries(args).forEach(
      eachNamedArg => (this as any)[eachNamedArg[0]] = eachNamedArg[1]
    )
  }

}

// in main program

class MyClassLogger extends NamedConstructorArgsBaseClass<MyClassLogger> {
  a: string;
  b: number;

  log() { 
    console.log(this.a, this.b);
  }
}

new MyClassLogger({a: "hi", b: 99}).log() // logs: hi, 99
Related