Sasgorilla
Sasgorilla

Reputation: 3130

How can I avoid constructor boilerplate with an object-literal TypeScript class constructor?

TypeScript has convenient "parameter property" shorthand for class constructors so that I don't have to explicitly define every property, like this:

class Pizza {
  constructor(
    private sauce: string,
    private cheese: string,
    public radius: number
  ) {}
}

However, I can also use a constructor that takes an object rather than an argument list (essentially, keyword arguments):

const pizza = new Pizza({sauce: 'marinara', cheese: 'Venezuelan Beaver', radius: 8})

This syntax has a number of conveniences, not least of which is the ability to use the spread operator to easily copy objects:

const anotherPizza = new Pizza({...pizza, radius: 10})

But unfortunately it means I can't use the constructor shortcut to avoid all the boilerplate (I think):

class Pizza {

  private sauce: string;
  private cheese: string;
  private radius: number;

  constructor({sauce, cheese, radius}: {
      sauce: string,
      cheese: string,
      radius: number
  }) {
    this.sauce = sauce;
    this.cheese = cheese;
    this.radius = radius;
  }
}

Is there some way to use an object-literal constructor like this and still avoid assigning every property explicitly?

Upvotes: 5

Views: 643

Answers (2)

Jens
Jens

Reputation: 329

IMHO the copy idea is flawed in this context: When you use the new operator, you create a new object anyway and the fields are (only) initialised in the constructor.

That is

const anotherPizza = new Pizza({...pizza, radius: 10})

actually creates a new object (almost a copy of pizza), then creates another object anotherPizza and sets its fields to the (slightly modified) copy of pizza. This is a performance nightmare as you do not need the intermediate copy, although I would hope that V8 and other engines would detect the use case and omit the superfluous copy.

Besides that, I like your idea and I had similar issues. When the number of fields grow, code becomes much more readable when using named arguments by means of an object literal. So, what I was looking for is something like that:

class Pizza {

  constructor({
      private sauce: string,
      private cheese: string,
      private radius: number
  }) {
  }
}

which does not work of course.

Unfortunately, I'm afraid your example cannot be optimised (by means of shortened) since you are using private fields: Using mapped types does not work with private fields as the keyof type operator only works on public fields (cf. TS issue 46802).

If all your fields are public, you could use the following construct:

type Fields<T> = {
    [Property in keyof T as T[Property] extends Function ? never : Property]: T[Property]
};

class Pizza {

  sauce: string;
  cheese: string;
  radius: number;

  constructor(src: Fields<Pizza>) {
     this.sauce = src.sauce
     this.cheese = src.cheese
     this.radius = src.radius
  }
}

That is, you do not need to list all the fields in the type of the parameter of the constructor, and you also get error messages if you add or remove fields. I sometime use this pattern when converting an interface or type to a class. As you can see I also do not use destructering so that I do not have to write the fields in the signature at all.

In order to filter out readonly properties (getters), you need to add another filter type as described in another stackoverflow thread:

// Part 1: Filter out readonly properties
// cf. https://stackoverflow.com/questions/52443276/how-to-exclude-getter-only-properties-from-type-in-typescript/52473108#52473108
type IfEquals<X, Y, A, B> =
    (<T>() => T extends X ? 1 : 2) extends
    (<T>() => T extends Y ? 1 : 2) ? A : B;
type WritableKeysOf<T> = {
    [P in keyof T]: IfEquals<{ [Q in P]: T[P] }, { -readonly [Q in P]: T[P] }, P, never>
}[keyof T];
type Writeable<T> = Pick<T, WritableKeysOf<T>>

And then the constructor signature looks like this:

constructor(src: Writeable<Fields<MyClass>>)

Back to the copy idea: Maybe it would make sense to rewrite the code:

const anotherPizza = new Pizza(pizza)
anotherPizza.radius = 10;

Or another constructor:

   constructor(src: Fields<PizzaShorter>, changes?: Partial<Fields<PizzaShorter>>) {
    this.sauce = changes?.sauce ?? src.sauce;
    this.cheese = changes?.cheese ?? src.cheese;
    this.radius = changes?.radius ?? src.radius;
  }

However I'm not sure whether this is really worth the trouble due to JavaScript engine optimisation (this has to answer someone with more V8 insight knowledge).

Upvotes: 0

jonrsharpe
jonrsharpe

Reputation: 122152

The closest thing to what you're looking for is to get a tuple of constructor arguments using the relevant utility type. You can then create an array of parameter arguments and spread them (type-safely) to the constructor:

class Pizza {
  constructor(
    private sauce: string,
    private cheese: string,
    public radius: number
  ) {}
}

type PizzaSpec = ConstructorParameters<typeof Pizza>;

const spec: PizzaSpec = ["marinara", "Venezuelan Beaver", 8];

const pizza = new Pizza(...spec);

Playground

Although you can't use an object with property keys, the type you get is a labeled tuple, equivalent to writing:

const spec: [sauce: string, cheese: string, radius: number] = ...;

so you do get a little more documentation than just [string, string, number].

Copying is much more limited, though; dealing with array slices in a type-safe way is pretty complicated.

Upvotes: 1

Related Questions