Where Classes in TypeScript Fall Short
🔬

Where Classes in TypeScript Fall Short

26th January 2023 · 15 minute read

Classes were introduced to the ECMAScript language specification in 2015 with the release of ES6. This implementation does not extend the language to offer additional functionality, but rather provide syntactical sugar over the prototypical inheritance model on which the language was built from. In this post I will address my observations on the implementation of classes in TypeScript.

Context

Most of my earliest professional experience is working with OOP Languages such as C# and Java, although I have been in the tech industry long enough to have used many popular languages in earnest in the last 10 years.

More recently I have been working a lot with TypeScript running on Node.js. Coming from an OOP background, I was initially drawn to using classes. However, as I grew more comfortable with the language, I grew more and more skeptical towards TypeScript’s implementation of classes.

My observations are that classes in TypeScript are most commonly applied as a bridge for developers who are familiar with OOP languages. However, classes in TypeScript differ from expectations set by languages like C# and Java in a number of significant and potentially hazardous ways.

Expectations of a class

Classes are described in the official Java documentation as follows;

In the real world, you'll often find many individual objects all of the same kind. There may be thousands of other bicycles in existence, all of the same make and model. Each bicycle was built from the same set of blueprints and therefore contains the same components. In object-oriented terms, we say that your bicycle is an instance of the class of objects known as bicycles. A class is the blueprint from which individual objects are created.

Classes are composed of instance fields, methods and constructors. Instance fields hold local state for the instantiated class. Methods are functions that can be called from the instantiated class and constructors are special methods that are used to instantiate an instance of the class.

Each of these elements can include modifiers for controlling encapsulation, which means to control whether the given element can be used from outside the instance of the class or not. Using the public modifier on instance fields, methods and constructors allows access from outside the instantiated class, whereas the private modifier limits access to inside the instance only. There are a number of other modifiers available in C# and Java but I won’t through all of them and how they work at this moment.

One use for encapsulation is to hide implementation details that are not relevant to the code consuming the instantiated class. Encapsulation is also an important component for managing mutability, which means controlling and preventing the change of state within class instances.

Typescript

Let’s consider a simple example written for of a class implemented in TypeScript;

class Bicycle {
  constructor(
    private readonly cadence = 0,
    private readonly speed = 0,
    private readonly gear = 1,
  ) {}

  printStates = (): void => {
    console.log(
      `cadence: ${this.cadence}, speed: ${this.speed}, gear: ${this.gear}`,
    );
  };
}

TypeScript borrows the private keyword from C# and Java. This sets an expectation that the property1 and property2 fields on instances of this class are not accessible from outside of the instance.

This example also features the readonly keyword which is borrowed from C#, with the Java equivalent being the final keyword. This modifier prevents the value contained in this field from being mutated.

Below is an example for the equivalent class written in Java;

class Bicycle {
  private final int cadence;
  private final int speed;
  private final int gear;

  public Bicycle(
      final int cadence,
      final int speed,
      final int gear) {
    this.cadence = cadence;
    this.speed = speed;
    this.gear = gear;
  }

  void printStates() {
    String.format(
        "cadence: %d, speed: %d, gear: %d",
        this.cadence,
        this.speed,
        this.gear);
  }
}

Combining these keywords produces an expectation whereby properties on instances of this class can only be accessed internally and cannot be mutated. Unfortunately with TypeScript this is not actually the case.

Mutability

Consider the example below, which uses the readonly keyword to prevent mutability of the instance fields on a class;

interface HasIdentity {
  id: number;
}

class Person implements HasIdentity {
  constructor(
    readonly id: number,
    readonly firstName: string,
    readonly lastName: string,
  ) {}
}

const illegallyMutatePerson = (person: HasIdentity): void => {
  person.id = 234;
};

const person = new Person(123, `Alex`, `Smith`);
illegallyMutatePerson(person);
console.log(person);

// Person { id: 234, firstName: 'Alex', lastName: 'Smith'}

The immutability offered by TypeScript’s readonly keyword is only maintained as long as you’re referring to the concrete implementation of a class. This is a potentially hazardous violation of the assumptions set by borrowing the readonly keyword from C#, which is meant to share a similar purpose.

A Java Example

Consider the example below of the equivalent code expressed in Java;

public class Mutability {

  static void illegallyMutatePerson(HasIdentity person) {
    person.id = 234;
  }

  public static void main(String[] args) {
    var person = new Person(123, "Alex", "Smith");
    illegallyMutatePerson(person);
    System.out.println(person);
  }
}

public interface HasIdentity {
  int id = 0;
}

public class Person implements HasIdentity {
  final int id;
  final String firstName;
  final String lastName;

  public Person(final int id, final String firstName, final String lastName) {
    this.id = id;
    this.firstName = firstName;
    this.lastName = lastName;
  }
}

// Mutability.java:22: error: cannot assign a value to final variable id
//    person.id = 234;
//          ^
// 1 error
// error: compilation failed

In Java, interface attributes are public static final by default, meaning that attempting to access and mutate the instance field in this way results in a compilation error.

Luckily there are a few options available to implement immutability in TypeScript classes.

Getters

JavaScript natively supports a feature called Getters, which enable control over the mutability of instance fields in classes. In the example below, we can internalise the instance fields and allow readonly access to them by implementing Getters;

interface HasIdentity {
  id: number;
}

class Person implements HasIdentity {
  constructor(
    private readonly internalId: number,
    private readonly internalFirstName: string,
    private readonly internalLastName: string,
  ) {}

  get id(): number {
    return this.internalId;
  }

  get firstName(): string {
    return this.internalFirstName;
  }

  get lastName(): string {
    return this.internalLastName;
  }
}

const illegallyMutatePerson = (person: HasIdentity): void => {
  person.id = 234;
};

const person = new Person(123, `Alex`, `Smith`);
illegallyMutatePerson(person);
console.log(person);

// TypeError: Cannot set property id of #<Person> which has only a getter

Object.freeze()

Similarly, the Object.freeze() method prevents extensions and makes existing properties on any object in JavaScript non-writeable and non-configurable. A frozen object can no longer be changed: new properties cannot be added, existing properties cannot be removed, their enumerability, configurability, writability, or value cannot be changed, and the object's prototype cannot be re-assigned. Consider the example below which produces a similar error to the one in the Getters example;

interface HasIdentity {
  id: number;
}

class Person implements HasIdentity {
  id: number;
  firstName: string;
  lastName: string;

  constructor(id: number, firstName: string, lastName: string) {
    this.id = id;
    this.firstName = firstName;
    this.lastName = lastName;
    Object.freeze(this);
  }
}

const illegallyMutatePerson = (person: HasIdentity): void => {
  person.id = 234;
};

const person = new Person(123, `Alex`, `Smith`);
illegallyMutatePerson(person);
console.log(person);

// TypeError: Cannot assign to read only property 'id' of object '#<Person>'

Encapsulation

The example below uses the private keyword on both the author and title instance fields to prevent them from being able to be accessed from outside the class. Unfortunately it appears the console.log() statements have no concerns about violating encapsulation and leaking the internals of this instance;

class Book {
  constructor(
    private readonly author: string,
    private readonly title: string,
  ) {}
}

const book = new Book('2001: A Space Odyssey', 'Author C. Clarke');

console.log(book);

// Book { author: '2001: A Space Odyssey', title: 'Author C. Clarke' }

TypeScript’s private keyword is only relevant at compile-time and is subverted just about as easily as the readonly keyword. In another example below, we see that libraries like Jest default to determining whether two classes are equal by comparing each of the instance fields, again ignoring the private modifier;

class Book {
  constructor(
    private readonly author: string,
    private readonly title: string,
  ) {}
}

describe(`given one instance of Book`, () => {
  const book1 = new Book(`Macklin Hartley`, `Classes in TypeScript`);

  describe(`compare to empty`, () => {
    it(`toEqual`, () => expect(book1).toEqual({}));
    it(`toStrictEqual`, () => expect(book1).toStrictEqual({}));
  });

  describe(`compare to self`, () => {
    it(`toEqual`, () => expect(book1).toEqual(book1));
    it(`toStrictEqual`, () => expect(book1).toStrictEqual(book1));
  });

  describe(`compare to instance of Book with same author and title`, () => {
    const book2 = new Book(`Macklin Hartley`, `Classes in TypeScript`);
    it(`not.toEqual`, () => expect(book1).not.toEqual(book2));
    it(`not.toStrictEqual`, () => expect(book1).not.toStrictEqual(book2));
  });

  describe(`compare to inline`, () => {
    const inline = {
      author: `Macklin Hartley`,
      title: `Classes in TypeScript`,
    };
    it(`not.toEqual`, () => expect(book1).not.toEqual(inline));
    it(`not.toStrictEqual`, () => expect(book1).not.toStrictEqual(inline));
  });

  describe(`compare to instance of Book with different author and title`, () => {
    const book2 = new Book(`Arthur C. Clarke`, `2001: A Space Odyssey`);
    it(`not.toEqual`, () => expect(book1).not.toEqual(book2));
    it(`not.toStrictEqual`, () => expect(book1).not.toStrictEqual(book2));
  });
});

Jest’s .toEqual(value) method iterates through each of the properties on the expected and received inputs and ensure they are equal. The .toStrictEqual(value) method also checks inputs both have the same type. Running these tests in Jest produces the following test output where a number of the assertions fail;

image

Again, luckily there are options available to ensure our classes are properly encapsulated.

JavaScript Private Fields

JavaScript natively implements private functionality to any field prefixed with a hash #. See in the example below where the author and title properties are successfully hidden.

class Book {
  #author: string;
  #title: string;

  constructor(author: string, title: string) {
    this.#author = author;
    this.#title = title;
  }
}

const book = new Book('2001: A Space Odyssey', 'Author C. Clarke');

console.log(book);

// Book {}

This change alone negatively impacts the test output, noting in particular the output of the first test where the class is compared to an empty object;

image

Because the instance fields are now hidden, this results in more of the tests comparing the expected results being compared to empty object equivalents {}.

In order to fix this, we need to implement a method for comparing our class to something else. In the example below, I’ve implemented the .equals(obj) method which explicitly compares each instance field to the input as well as confirms a matching type.

class Book {
  #author: string;
  #title: string;

  constructor(author: string, title: string) {
    this.#author = author;
    this.#title = title;
  }

  get author(): string {
    return this.#author;
  }

  get title(): string {
    return this.#title;
  }

  equals = (obj: unknown): boolean =>
    obj instanceof Book &&
    this.#author === obj.author &&
    this.#title === obj.title;
}
ℹ️
This trick is lifted from the Java Object class, which specifies implementation of an .equals(Object obj) method for comparing objects to one another.

Then the tests are updated to use the .equals() method, replacing any usage of Jest’s .toEqual(value) or .toStrictEquals(value) matchers.

describe(`given one instance of Book`, () => {
  const book1 = new Book(`Macklin Hartley`, `Classes in TypeScript`);

  describe(`compare to empty`, () => {
    it(`equals() > false`, () => expect(book1.equals({})).toBeFalsy());
  });

  describe(`compare to self`, () => {
    it(`equals() > true`, () => expect(book1.equals(book1)).toBeTruthy());
  });

  describe(`compare to instance of Book with same author and title`, () => {
    const book2 = new Book(`Macklin Hartley`, `Classes in TypeScript`);
    it(`equals() > true`, () => expect(book1.equals(book2)).toBeTruthy());
  });

  describe(`compare to inline`, () => {
    const inline = {
      author: `Macklin Hartley`,
      title: `Classes in TypeScript`,
    };
    it(`equals() > false`, () => expect(book1.equals(inline)).toBeFalsy());
  });

  describe(`compare to instance of Book with different author and title`, () => {
    const book2 = new Book(`Arthur C. Clarke`, `2001: A Space Odyssey`);
    it(`equals() > false`, () => expect(book1.equals(book2)).toBeFalsy());
  });
});

Resulting in the following output where all the tests pass;

image

Understand your tools

Based on the above, you may assume my recommendation is not to use TypeScript classes.

Moving from strongly-typed OOP languages like C# or Java to TypeScript requires a paradigm shift. The presence of familiar APIs borrowed from popular OOP languages such as private and readonly is troubling because it does little to address this paradigm shift. More than likely it allows newcomers to ignore it long enough to run into serious problems.

I recommend avoiding TypeScript’s private and readonly APIs if you want to ensure your classes are immutable and encapsulated. Lean on Getters for immutability, and JavaScript’s native private class modifiers or ES modules for encapsulation. Additionally, opt for explicit equality comparison rather than Jest’s .toEqual(value) or .toStrictEquals(value) matchers which compare structure.

I personally prefer not to use classes altogether as a forcing function for maintaining this paradigm shift. When I am writing in TypeScript, having it feel different to when I use C# or Java helps me avoid falling into traps.

The only recommendation I will make is to take the time to learn and understand the tools you are working with. TypeScript has many more quirks than I have highlighted, and no language is perfect!

References