We explore several different ways of implementing multiple constructors in TypeScript.
While technically TypeScript only allows one constructor implementation, we can provide multiple paths for object initialization.
Consider the following interface:
interface Point {
coordinates(): Iterable<number>;
}
We will provide a concrete implementation while exploring these different alternatives:
- Multiple type signatures.
- Separate interface for arguments.
- Static factory methods.
- Proxy classes with different constructors.
- Single constructor.
- Combination of several approaches.
- Refactor code.
1. Multiple type signatures
class NDPoint implements Point {
private values: number[];
constructor()
constructor(point: Point)
constructor(x: number)
constructor(x: number, y: number)
constructor(x: number, y: number, z: number)
constructor(...coordinates: number[])
constructor(coordinates: number[])
constructor(xOrPoint?: Point | number | number[], y?: number, z?: number) {
if (typeof xOrPoint === 'undefined' || xOrPoint === null) {
this.values = [];
} else if (xOrPoint instanceof Array) {
this.values = xOrPoint;
} else if (typeof xOrPoint === 'number') {
if (typeof y !== 'undefined') {
if (typeof z !== 'undefined') {
this.values = [xOrPoint, y, z];
} else {
this.values = [xOrPoint, y];
}
} else {
this.values = [xOrPoint];
}
} else {
this.values = [...xOrPoint.coordinates()];
}
}
coordinates(): Iterable<number> {
return this.values;
}
}
I know the example is a bit convoluted and the different type signatures could be simplified, but bear with me for the sake of argument.
The first constructor declarations are just for our benefit. They are only for design time and they cannot have any implementation. Only the last version will actually be compiled.
To create new instances:
new NDPoint();
new NDPoint(new NDPoint());
new NDPoint(10);
new NDPoint(10, 10);
new NDPoint(10, 10, 10);
new NDPoint(10, 10, 10, 10);
new NDPoint([10, 10, 10]);
Pros
- Easy for the caller to see different constructor overloads.
Cons
- Too much logic in constructor.
- Type checking.
2. Separate interface for arguments
Consider instead if we move the constructor parameters into its own type:
interface PointArguments {
point?: Point;
coordinates?: number[];
x?: number;
xy?: { x: number, y: number };
xyz?: { x: number, y: number, z: number };
}
Our implementation becomes like this:
class NDPoint implements Point {
private values: number[];
constructor(args?: PointArguments) {
if (!args) {
this.values = [];
} else if (args.point) {
this.values = [...args.point.coordinates()];
} else if (args.coordinates) {
this.values = args.coordinates;
} else if (typeof args.x !== 'undefined') {
this.values = [args.x];
} else if (args.xy) {
this.values = [args.xy.x, args.xy.y];
} else if (args.xyz) {
this.values = [args.xyz.x, args.xyz.y, args.xyz.z];
} else {
this.values = [];
}
}
coordinates(): Iterable<number> {
return this.values;
}
}
To create new instances:
new NDPoint();
new NDPoint({ point: new NDPoint() });
new NDPoint({ coordinates: [10, 10, 10] });
new NDPoint({ x: 10 });
new NDPoint({ xy: { x: 10, y: 10 } });
new NDPoint({ xyz: { x: 10, y: 10, z: 10 } });
// new NDPoint(10, 10, 10); // error, not possible
But what happens if we do this?
// unclear what should happen here
new NDPoint({
coordinates: [10, 10, 10],
x: 20,
xyz: { x: 30, y: 30, z: 30 }
});
Pros
- Constructor logic got simpler (only need to check if member is present).
Cons
- Ambiguous arguments interface (harder for the caller to work with the class).
- Still contains logic in constructor.
3. Static factory methods
class NDPoint implements Point {
private values: number[];
static fromVoid() {
return new NDPoint([]);
}
static fromPoint(point: Point) {
return new NDPoint([...point.coordinates()]);
}
static fromX(x: number) {
return new NDPoint([x]);
}
static fromXY(x: number, y: number) {
return new NDPoint([x, y]);
}
static fromXYZ(x: number, y: number, z: number) {
return new NDPoint([x, y, z]);
}
static fromSpread(...coordinates: number[]) {
return new NDPoint(coordinates);
}
constructor(coordinates: number[]) {
this.values = coordinates;
}
coordinates(): Iterable<number> {
return this.values;
}
}
To create our instances:
NDPoint.fromVoid();
NDPoint.fromPoint(new NDPoint([]));
NDPoint.fromX(10);
NDPoint.fromXY(10, 10);
NDPoint.fromXYZ(10, 10, 10);
NDPoint.fromSpread(10, 10, 10, 10);
new NDPoint([10, 10, 10]);
Pros
- No logic in constructor.
Cons
- Use of static methods.
- Caller has to search static methods for initialization.
4. Proxy classes with different constructors
What if instead of just one implementation we had several, each with a different constructor implementation?
class NDPoint implements Point {
private values: number[];
constructor(coordinates: number[]) {
this.values = coordinates;
}
coordinates(): Iterable<number> {
return this.values;
}
}
class OneDPoint implements Point {
private point: Point;
constructor(x: number) {
this.point = new NDPoint([x]);
}
coordinates(): Iterable<number> {
return this.point.coordinates();
}
}
class TwoDPoint implements Point {
private point: Point;
constructor(x: number, y: number) {
this.point = new NDPoint([x, y]);
}
coordinates(): Iterable<number> {
return this.point.coordinates();
}
}
class ThreeDPoint implements Point {
private point: Point;
constructor(x: number, y: number, z: number) {
this.point = new NDPoint([x, y, z]);
}
coordinates(): Iterable<number> {
return this.point.coordinates();
}
}
class PointFromSpread implements Point {
private point: Point;
constructor(...coordinates: number[]) {
this.point = new NDPoint(coordinates);
}
coordinates(): Iterable<number> {
return this.point.coordinates();
}
}
class EmptyPoint implements Point {
constructor() {
}
coordinates(): Iterable<number> {
return [];
}
}
class ClonedPoint implements Point {
private point: Point;
constructor(point: Point) {
this.point = new NDPoint([...point.coordinates()]);
}
coordinates(): Iterable<number> {
return this.point.coordinates();
}
}
To create new instances:
new EmptyPoint();
new ClonedPoint(new EmptyPoint());
new OneDPoint(10);
new TwoDPoint(10, 10);
new ThreeDPoint(10, 10, 10);
new PointFromSpread(10, 10, 10, 10);
new NDPoint([10, 10, 10]);
Pros
- No logic in constructor.
- No static methods.
Cons
- Very verbose (many proxy classes).
- Hard to discover variations.
5. Single constructor
class NDPoint implements Point {
private values: number[];
constructor(coordinates: number[]) {
this.values = coordinates;
}
coordinates(): Iterable<number> {
return this.values;
}
}
To create new instances:
new NDPoint([]);
new NDPoint([...new NDPoint([]).coordinates()]);
new NDPoint([10]);
new NDPoint([10, 10]);
new NDPoint([10, 10, 10]);
new NDPoint([10, 10, 10, 10]);
Pros
- No logic in constructor.
- No static methods.
- Smallest implementation.
Cons
- Burden on the caller to transform constructor input.
- Duplicated code for argument transformation (e.g. repeat same code each time you want to clone a point).
6. Combination of several approaches
class NDPoint implements Point {
private values: number[];
static from(coordinates: Iterable<number>) {
return new NDPoint(...coordinates);
}
constructor(...coordinates: number[]) {
this.values = coordinates;
}
coordinates(): Iterable<number> {
return this.values;
}
}
To create new instances:
new NDPoint();
NDPoint.from(new NDPoint().coordinates());
new NDPoint(10);
new NDPoint(10, 10);
new NDPoint(10, 10, 10);
new NDPoint(10, 10, 10, 10);
NDPoint.from([10, 10, 10]);
Pros
- No logic in constructor.
Cons
- Use of static methods.
- Small burden on caller to discover static methods and to transform input.
7. Refactor code
Another approach is to refactor the code so the main class as a single point of initialization and we provide proxy classes for edge cases and utility classes for argument transformation.
class NDPoint implements Point {
private values: Iterable<number>;
constructor(coordinates: Iterable<number>) {
this.values = coordinates;
}
coordinates(): Iterable<number> {
return this.values;
}
}
class EmptyPoint implements Point {
coordinates(): Iterable<number> {
return [];
}
}
class IterableOf<T> implements Iterable<T> {
private items: T[];
constructor(...items: T[]) {
this.items = items;
}
[Symbol.iterator](): Iterator<T> {
return this.items.values();
}
}
To create new instances:
new EmptyPoint();
new NDPoint(new NDPoint([10, 10]).coordinates());
new NDPoint(new IterableOf(10));
new NDPoint(new IterableOf(10, 10));
new NDPoint(new IterableOf(10, 10, 10));
new NDPoint(new IterableOf(10, 10, 10, 10));
new NDPoint([10, 10, 10]);
Pros
- No logic in constructor.
- No static methods.
- Most reusable code.
Cons
- Burden on caller to find ways for instantiation.
Summary
I find constructor overloading to be a pain point in TypeScript. You either have a very verbose constructor or you need to resort to static methods or additional classes to provide different initialization options.
I don't know what the best approach is. I don't like to use logic in constructors or static methods, but the alternatives are not very good either. In this example I would probably choose the last approach.
The solution you choose will always be a compromise between flexibility and clean code.
What do you think? What's your preferred approach?