Accessors overriding properties is an error

March 31st 2023 TypeScript JavaScript

The TypeScript compiler prevents type-related errors in code, but sometimes it also prevents you from writing perfectly valid JavaScript code. At least, that's how it seems.

TypeScript 4.0 introduced a new compiler error for the following scenario:

  • You have a base class with a property (in this case, x and y):

    export class Position {
      constructor(public x: number, public y: number) {}
    
      move(dx: number, dy: number): void {
        this.x += dx;
        this.y += dy;
      }
    }
    
  • You try to override this property with an accessor (a setter and a getter) in a derived class:

    export class LoggingPositionWithAccessor extends Position {
      log: string[] = [];
      _x: number;
      _y: number;
    
      constructor(x: number, y: number) {
        super(x, y);
        this._x = x;
        this._y = y;
      }
    
      get x() {
        return this._x;
      }
    
      set x(value) {
        this.log?.push(`x changed to ${value}`);
        this._x = value;
      }
    
      get y() {
        return this._y;
      }
    
      set y(value) {
        this.log?.push(`y changed to ${value}`);
        this._y = value;
      }
    }
    

This code will not compile because the following error occurs:

TS2611: 'x' is defined as a property in class 'Position', but is overridden here in 'LoggingPositionWithAccessor' as an accessor.

However, if you write equivalent code in JavaScript, no (runtime) error occurs:

class LoggingPosition extends Position {
  log = [];
  _x;
  _y;

  constructor(x, y) {
    super(x, y);
    this._x = x;
    this._y = y;
  }

  get x() {
    return this._x;
  }

  set x(value) {
    this.log?.push(`x changed to ${value}`);
    this._x = value;
  }

  get y() {
    return this._y;
  }

  set y(value) {
    this.log?.push(`y changed to ${value}`);
    this._y = value;
  }
}

But a word of warning: such code does not always work as expected. In this particular case, the following set of (passing) tests shows that the setters are not called as the log array remains empty:

test("x setter doesn't write to log", () => {
  const position = new LoggingPosition(1, 2);
  position.x = 3;
  expect(position.log).toEqual([]);
});

test("z setter doesn't write to log", () => {
  const position = new LoggingPosition(1, 2);
  position.y = 3;
  expect(position.log).toEqual([]);
});

test("move doesn't write to log", () => {
  const position = new LoggingPosition(1, 2);
  position.move(3, 4);
  expect(position.log).toEqual([]);
});

This proves that the TypeScript compiler error can actually prevent you from writing non-working code.

But then how can you write working code for this particular scenario: add code that executes when a property is changed. There are several ways to do this:

  1. If the base class is under your control, simply change the base class to use accessors instead of properties. If you override these accessors in a derived class, this will always work as expected. If the base class is not under your control (i.e. comes from a library), you cannot do this and must use one of the other approaches.

  2. Add accessors with a different name:

    export class LoggingPositionWithRenamedAccessor extends Position {
      readonly log: string[] = [];
    
      constructor(x: number, y: number) {
        super(x, y);
      }
    
      get X(): number {
        return this.x;
      }
    
      set X(value: number) {
        this.log.push(`x changed to ${value}`);
        super.x = value;
      }
    
      get Y(): number {
        return this.y;
      }
    
      set Y(value: number) {
        this.log.push(`y changed to ${value}`);
        super.y = value;
      }
    }
    

    This works quite well, as long as you only have to run additional code when these new accessors are used instead of the original property:

    test("X setter writes to log", () => {
      const position = new LoggingPositionWithRenamedAccessor(1, 2);
      position.X = 3;
      expect(position.log).toEqual(["x changed to 3"]);
    });
    
    test("Y setter writes to log", () => {
      const position = new LoggingPositionWithRenamedAccessor(1, 2);
      position.Y = 3;
      expect(position.log).toEqual(["y changed to 3"]);
    });
    

    Of course, if the original property is used (by a method you have no control over), the code in the accessor will not be executed:

    test("move doesn't write to log", () => {
      const position = new LoggingPositionWithRenamedAccessor(1, 2);
      position.move(3, 4);
      expect(position.log).toEqual([]);
    });
    
  3. Use a Proxy object:

    export class LoggingPositionProxy extends Position {
      readonly log: string[] = [];
    
      private constructor(x: number, y: number) {
        super(x, y);
      }
    
      static create(x: number, y: number): LoggingPositionProxy {
        const loggingPositionProxy = new LoggingPositionProxy(x, y);
    
        const proxyHandler: ProxyHandler<LoggingPositionProxy> = {
          set(
            target: Position,
            property: string | symbol,
            newValue: any
          ): boolean {
            if (property === "x" || property === "y") {
              loggingPositionProxy.log.push(
                `${property} changed to ${newValue}`
              );
            }
    
            if (property in target) {
              target[property as keyof typeof target] = newValue;
            }
    
            return true;
          },
        };
    
        return new Proxy(loggingPositionProxy, proxyHandler);
      }
    }
    

    With this approach, you can add code to execute when a value is set to one of the existing properties in the base class. This means that it also affects code over which you have no direct control:

    test("setting x writes to log", () => {
      const position = LoggingPositionProxy.create(1, 2);
      position.x = 3;
      expect(position.log).toEqual(["x changed to 3"]);
    });
    
    test("setting y writes to log", () => {
      const position = LoggingPositionProxy.create(1, 2);
      position.y = 3;
      expect(position.log).toEqual(["y changed to 3"]);
    });
    
    test("move writes to log", () => {
      const position = LoggingPositionProxy.create(1, 2);
      position.move(3, 4);
      expect(position.log).toEqual(["x changed to 4", "y changed to 6"]);
    });
    

The full source code for the above examples is available in my GitHub repository, so you can try it out and experiment with it yourself.

In this blog post, I described how overriding a property with an accessor in JavaScript might not work as expected, which is why it is considered an error by the TypeScript compiler. I also suggested a few alternative approaches to achieve the same end result that work as expected and do not cause TypeScript compiler errors.

Get notified when a new blog post is published (usually every Friday):

If you're looking for online one-on-one mentorship on a related topic, you can find me on Codementor.
If you need a team of experienced software engineers to help you with a project, contact us at Razum.
Copyright
Creative Commons License