Skip to content

Instantly share code, notes, and snippets.

@ZER0
Created February 12, 2025 14:42
Show Gist options
  • Save ZER0/c2aeffe06c6ef5eccdadcc63f2b1465a to your computer and use it in GitHub Desktop.
Save ZER0/c2aeffe06c6ef5eccdadcc63f2b1465a to your computer and use it in GitHub Desktop.

Class Design Pattern

This document provides an opinionated list of code conventions and patterns for writing classes in modern JavaScript.
The aim is to establish a contract between the Consumer, the Provider, and developers, focusing on intention. Some property types have a stronger contract than others.

Properties

There are six categories of property types that a class can have: public, private, read-only, protected, scoped, and immutable.

Note

Technically, there can be more, such as write-only but that would be bad design and go against the API contract. If a "write-only" semantic is needed, a method should be used instead.

Public properties

These are considered the default type of property in JavaScript and have the weakest contract:

class Person {
  name = "John Doe";
}

They can be read, written, and deleted by both the consumer and provider, offering no contractual restrictions between the parties.

When to use it

  1. When no internal logic depends on the value of this property or even on its existence.
  2. When the whole class represents a model, especially if it’s an immutable model (see the appendix).

Private properties

Private properties have a stronger contract than public properties. They’re often used for encapsulation or to store values for read-only properties.

class Person {
  #name = "John Doe";
}

They can be read and written only from inside the class that defines them, and they cannot be deleted.

When to use it

  1. For total encapsulation. When a class’s internal logic strongly depends on their correctness, and they should never be exposed to the consumer, avoiding manipulation.

  2. To implement read-only properties.

Read-Only properties

Read-only properties have a stronger contract than public properties and possibly a weaker one than private properties, depending on implementation.

class Person {
  get name() {
    return "John Doe";
  }
}

They cannot be written by the consumer, only read, and they cannot be deleted.

They’re often implemented using private properties to hold values. A more common pattern is:

class Person {
  #name = "John Doe";

  get name() {
    return this.#name;
  }
}

This ensures that the consumer can only read them, but the provider retains the ability to write them.

Of course, read-only properties can also return values from different sources, using, for example, protected properties.

When to use it

  1. For partial encapsulation, allowing the consumer to access them without the ability to modify their values.

  2. For lazy initialization, when a property’s value depends on a heavy or asynchronous computation that may not occur in all use cases and is unlikely to change during the instance’s lifecycle:

class Person {
  #bmi;

  get bmi() {
    if (!this.#bmi) {
      this.#bmi = calculateBMI(this);
    }
    return this.#bmi;
  }
}

Risks

It’s very important how read-only properties are returned. Poorly written getters can break encapsulation and violate the contract.

If the getter returns primitives, there are no issues. However, if they return objects, the consumer can still modify them.

In such cases:

  1. If it’s an array of primitives or a simple, non-nested object, use the spread syntax.
  2. If it’s a complex object, a deep copy is necessary via structuredClone(), unless the object is already an immutable model

Protected properties

These properties are similar to private properties from the consumer’s viewpoint but can be manipulated by logic outside the defining class as long as they are in the same module:

const secret = Symbol("person::secret");

class Person {
  [secret] = "I like Cammy";
}

class Confidant {
  yourSecret(person) {
    console.log(person[secret]);
  }
}

They can be read, written, and deleted only by the provider, even across different logic pieces, but not by the consumer.

Note

The same approach can be used for trait-like methods (see the appendix).

When to use it

When the provider needs to share data across different logic pieces in the same module but does not want to expose that data to the consumer, unless via a getter (see read-only properties).

Risks

The Symbol should never be exposed across modules; otherwise, the contract is broken.

An exception to this rule is scoped properties.

Important

The consumer can still obtain the Symbol via reflection and manipulate its content; however, this behavior is clearly intentional. In this case, it is the consumer who is interfering with the “internal API,” which is why the contract is not as strong as that of purely private properties.

Scoped properties

These properties are similar to protected properties, with the only difference being that the Symbol is exported, allowing anyone who imports it to access the properties:

// person.js
import { secret } from "./secret.js";

class Person {
  [secret] = "I like Cammy";
}

// confidant.js
import { secret } from "./secret.js";

class Confidant {
  yourSecret(person) {
    console.log(person[secret]);
  }
}

When to use it

They should be used only when building a library package (e.g., npm, jsr), ensuring that the library’s entry point does not expose the Symbol directly to the main entry point.

Immutable properties

These properties are essentially read-only for both the consumer and the provider, making them the strongest contract type. They can be set only once, and their value will never change throughout the instance's lifecycle.

They usually appear only in the constructor, signaling to developers that the property will never change within the class logic (something that private properties cannot guarantee):

class Network {
  constructor(url) {
    Object.defineProperty(this, "url", {
      value: url,
      // enumerable: true
    });
  }
}

They can be read, but not written or deleted by anyone. If having them enumerable is important for the consumer, it’s possible to set enumerable: true in the descriptor.

When to use it

When a property is set only once at creation time and will never change for that instance. This reduces possible side effects (inherent in class-oriented programming) and immediately establishes a clear intention and contract for developers reading or maintaining the code, ensuring that such a property will retain its value throughout the instance’s lifecycle.

If all properties of the class share this behavior, then an immutable model should be used instead.

Constructor

Default Constructor

The default constructor should be lightweight. It should avoid heavy computation, setting only the basic attributes of an object to make it easy to share, clone, etc.

A constructor should almost never have a return statement or return a value different from the class it belongs to. If this is necessary (for example, for asynchronous computation), a custom constructor, such as a From constructor, should be used.

The default constructor should primarily be used for setting properties, calling super(), and specifying immutable properties. In the case of an immutable model, it should also invoke Object.freeze().

Custom constructor

A custom constructor is a static class method that returns a class instance, or eventually returns one.

It internally creates an instance of the class it belongs to and performs additional operations, including asynchronous computations when necessary:

class Network {
  constructor(url) {
    Object.defineProperty(this, "url", {
      value: url,
    });
  }

  static async connect(url) {
    const network = new Network(url);

    await network.connect();

    return network;
  }

  async connect() {
    // perform the connection over `this.url`
  }
}

The most common custom constructor, also frequently used in built-in JavaScript objects, is the from constructor.

Appendix

Immutable model

These classes are mostly used to represent model data. Unlike literal objects, they might have one or more constructors, but once instantiated, the whole instance cannot be changed by either the consumer or the provider. For this reason, the properties are often declared as public for simplicity, as they cannot be altered due to Object.freeze() usage:

class Color {
  r;
  g;
  b;

  constructor(r, g, b) {
    this.r = r;
    this.g = g;
    this.b = b;

    return Object.freeze(this);
  }

  static from(hex) {
    const r = (hex >> 16) & 0xff;
    const g = (hex >> 8) & 0xff;
    const b = hex & 0xff;

    return new Color(r, g, b);
  }
}

When to use it

When there is logic behind data creation that should be associated with the model, and a shared model is needed across different parts of the codebase.

Risk

The Object.freeze() method only works on the first layer. If the model contains nested objects, it must be applied to all of them unless they are already part of another immutable model:

class Pixel {
  visible = false;
  color;
  position = {
    x: 0,
    y: 0,
  };

  constructor({ x, y }, hexColor, visibility) {
    this.position.x = x;
    this.position.y = y;

    this.visible = visibility;

    this.color = Color.from(hexColor);

    Object.freeze(this.position);

    return Object.freeze(this);
  }
}

Trait-like methods

Trait-like methods are similar to scoped properties in principle but are used for methods. They use Symbol to define methods, which can then be exported and used across the codebase.

However, unlike scoped properties, they can be freely exported to the consumer. This provides a way to avoid inheritance when it is not strictly necessary while still sharing common traits. This is how most well-known symbols in JavaScript work.

Using Symbol, it is also possible to modify built-in objects in a future-proof way, ensuring symbols will never clash:

// conversion.js
export const intoBytes = Symbol("conversion::into-bytes");

// built-in.js
import { intoBytes } from "./conversion.js";

String.prototype[intoBytes] = function () {
  return new TextEncoder().encode(this);
};

// deps.js
import { intoBytes } from "./conversion.js";

// Assuming the previous Color class is from a third-party dependency
import { Color } from "jsr:package";

Color.prototype[intoBytes] = function () {
  return Uint8Array.from([this.r, this.g, this.b]);
};

// my-types.js
import { intoBytes } from "./conversion.js";

class ComplexType {
  [intoBytes]() {
    // Perform some serialization and return a Uint8Array
  }
}

// send.js
import { intoBytes } from "./conversion.js";

function sendAsBytes(obj) {
  if (!(intoBytes in obj)) {
    throw new Error("Can't send the given argument as bytes.");
  }

  const bytes = obj[intoBytes]();

  // Optionally, check here if the return type is as expected
  // before sending `bytes`.
}

Important

Only if fully exported outside the library (because the intent is to provide the consumer with the capability to implement such a Symbol on their types) should methods such as Object.freeze() or Object.defineProperty() be used if it is mandatory that the Symbol added inside the library’s types and built-ins cannot be overridden or deleted by the consumer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment