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.
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.
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 no internal logic depends on the value of this property or even on its existence.
- When the whole class represents a model, especially if it’s an immutable model (see the appendix).
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.
-
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.
-
To implement 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.
-
For partial encapsulation, allowing the consumer to access them without the ability to modify their values.
-
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;
}
}
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:
- If it’s an array of primitives or a simple, non-nested object, use the spread syntax.
- If it’s a complex object, a deep copy is necessary via
structuredClone()
, unless the object is already an immutable model
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 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).
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.
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]);
}
}
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.
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 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.
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()
.
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.
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 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.
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 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.