TypeScript constructor overloads

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:

  1. Multiple type signatures.
  2. Separate interface for arguments.
  3. Static factory methods.
  4. Proxy classes with different constructors.
  5. Single constructor.
  6. Combination of several approaches.
  7. 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]);

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 }
});

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]);

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]);

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]);

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]);

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]);

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?

Share this

Did you like this article? Share it!

Keep up to date

Our monthly newsletter. See previous editions.

Related articles