Can Typescript Enforce Class Types?

Viewed 144

In the example below, I expected that by declaring variable c1 as type Coordinate that it would be either initialized as instance of Coordinate or I would get a compilation error.

Typescript:

class Coordinate {
    x: number;
    y: number;

    constructor(x: number, y: number) {
        this.x = x;
        this.y = y;
    }

    toString(): string {
        return `(${this.x},${this.y})`;
    }
};

const c1 : Coordinate = {x:1, y:2};             // <=== problem
const c2 : Coordinate = new Coordinate(3, 4);

console.log('c1', c1 instanceof Coordinate ? 'Coordinate' : 'not Coordinate');
console.log('c2', c2 instanceof Coordinate ? 'Coordinate' : 'not Coordinate');

console.log(`c1: ${c1}`);
console.log(`c2: ${c2}`);

Compilation

The official node typescript package transpiles this typescript code without errors or warnings:

npx tsc --version
Version 4.0.3
npx tsc

Actual Output:

c1 not Coordinate
c2 Coordinate
c1: [object Object]
c2: (3,4)

Expected Output:

Either:

  1. Line const c1 : Coordinate = {x:1, y:2}; causes a compilation error, or

  2. c1 is coerced into an instance of Coordinate:

     c1 Coordinate
     c2 Coordinate
     c1: (1,2)
     c2: (3,4)
    

Is there a way of coding or configuring typescript to behave either of these ways? I'd prefer a compilator error. I'd like to have cpp-like type enforcement, not duck-typing.

1 Answers

As mentioned in the comments already, Typescript is structurally typed, so in general you want to work with the idea that anything which has an x or a y is a valid Coordinate. However, we can get around that. From that same link,

When an instance of a class is checked for compatibility, if the target type contains a private member, then the source type must also contain a private member that originated from the same class.

So if we give the class a private member (even if we never use it), like so

class Coordinate {
    private _ignore: any;
    x: number;
    y: number;

    constructor(x: number, y: number) {
        this.x = x;
        this.y = y;
    }

    toString(): string {
        return `(${this.x},${this.y})`;
    }
};

Then we can never construct it outside of using Coordinate directly

const c1 : Coordinate = {x:1, y:2}; // Error
const d1 : Coordinate = {_ignore: 0, x:1, y:2}; // Also an error

Even d1 fails, because although it has all of the right field names, the private ones didn't come from the correct class. This allows us to get the nominal behavior of languages like Java or Scala when we want it.

Note:

If you're worried about adding a field to a struct, you can also do the same trick with a private member function.

class Coordinate {
    x: number;
    y: number;

    constructor(x: number, y: number) {
        this.x = x;
        this.y = y;
    }

    private _ignore(): void {}

    toString(): string {
        return `(${this.x},${this.y})`;
    }
};

This will have the same effect.

Related