Public, Private, and Protected Class Visibility Patterns in JavaScript

EisenbergEffect
7 min readJul 10, 2023

While working on my Web Component Engineering course the other day, I needed to implement a few patterns for class private constructors and protected members. It occurred to me that some of these patterns may not be very well-known in the community and that this might be a good opportunity for a blog post on the subject of class member visibility.

Public Class Members

The first version of JavaScript classes shipped as part of ES2015. It included support for public fields (without field syntax), public getters/setters, and public methods, as well as public static getters/setters, and public static methods. Here’s an example showing each of those:

class Person {
constructor(firstName, lastName) {
this.firstName = firstName; // public field
this.lastName = lastName; // public field
}

// public getter
get fullName() {
return `${this.firstName} ${this.lastName}`;
}

// public setter
set fullName(value) {
const parts = value.split(" ");
this.firstName = parts[0];
this.lastName = parts[1];
}

// public method
introduceYourselfTo(other) {
const name = other.firstName ?? other;
console.log(`Hello ${name}! My name is ${this.fullName}.`);
}

// public static getter
static get typeName() {
return "Person";
}

// public static method
static fromJSON(json) {
return new Person(json.firstName, json.lastName);
}
}

const john = new Person("John", "Doe");
const jane = Person.fromJSON({ firstName: "Jane", lastName: "Doe" });

john.introduceYourselfTo(jane);

This version of classes is what most JS developers are familiar with today. It was a solid first set of class features for the language, but there were a few things missing. Among the missing features was a syntax for fields. In a subsequent version of the language this was addressed by enabling public field and public static field syntax. Here’s an example showing that:

class Person {
age = 0; // public field syntax
static typeName = "Person"; // public static field syntax

shareYourAge() {
console.log(`I am ${this.age} years old.`);
}

// elided...
}

const john = new Person("John", "Doe");
john.age = 21;
john.shareYourAge();

Improvements to classes continue even now. While Decorators were being designed, a companion feature for auto accessors was also developed. Using the new accessor keyword, soon you will be able to do this:

class Person {
accessor age = 0; // public getter/setter with auto backing private field

// elided...
}

const john = new Person("John", "Doe");
john.age = 21;
john.shareYourAge();

Private Class Members

In July 2021, the last of the major browsers shipped private class members, bringing platform-protected private capabilities to the language. Private members are designated by prefixing them with # (pound) and can only be statically invoked directly against the host class. Here’s a version of the Person class that uses a couple of private fields:

class Person {
#firstName; // private field
#lastName; // private field

constructor(firstName, lastName) {
this.#firstName = firstName;
this.#lastName = lastName;
}

get firstName() { return this.#firstName; }
get lastName() { return this.#lastName; }
get fullName() {
return `${this.firstName} ${this.lastName}`;
}

introduceYourselfTo(other) {
const name = other.firstName ?? other;
console.log(`Hello ${name}! My name is ${this.fullName}.`);
}

static fromJSON(json) {
return new Person(json.firstName, json.lastName);
}
}

const john = new Person("John", "Doe");
const jane = Person.fromJSON({ firstName: "Jane", lastName: "Doe" });

john.introduceYourselfTo(jane);

By combining private fields with public getters, the language also now supports platform-protected readonly properties, as shown above.

NOTE: Beware using private members with proxies. See my article “The Prickly Case of JavaScript Proxies” for more details.

NOTE: Technically speaking, you could emulate private fields and readonly properties in the past by using a WeakMap per field. This was quite tedious and lacked the syntactic expressiveness that is now available.

ASIDE: Ever wonder why JavaScript uses the # symbol for private members instead of a keyword such as public or pub as in other languages? There was sizeable debate over this when the feature was being designed. In my understanding, the primary reason relates to JavaScript’s interpreted nature. In order to enable the feature to perform well at runtime, the “privateness” of a member had to be determinable syntactically at the call site, not just at the definition site. For this reason, a symbolic prefix was chosen, which could be consistently applied both at call and definition sites, without breaking any existing JavaScript code.

Private Constructors

Fields, getters, setters, and methods, both instance and static, can all be made private, with one exception: constructors cannot be made private. However, there is a pattern you can use to prevent unauthorized calls to a constructor. Let’s take a look at the implementation:

class SomeSingletonService {
static #key = {};
static instance = new SomeSingletonService(this.#key);

constructor(key) {
if (key !== SomeSingletonService.#key) {
throw new TypeError("SomeSingletonService is not constructable.");
}
}
}

The important detail in this pattern is that we hold a private static key, which is required as the first parameter to the constructor. If that key is not provided to unlock the constructor, an exception is thrown. (I use a TypeError to match the exception type that the runtime throws when attempting to access other private members.) Now our class can create instances however it likes, using its private key, but any other party attempting to instantiate through the constructor is blocked.

NOTE: The above example is only intended to show the private constructor technique. This is not the best way to handle singletons in JavaScript. You are better off using a frozen object literal or an Inversion of Control/Dependency Injection Container (if your app is complex enough) in this specific situation.

Protected Class Members

Now we come to the most interesting case of all. How do we handle protected or friend scenarios when the JavaScript language provides no feature for this? The key (😆) to this lies in a similar approach to how we handled private constructors. Here’s how it works:

question.js

// superclass
function Question(key) {
return class {
#answer = 42;

answer(shareKey) {
if (shareKey === key) {
return this.#answer;
}

throw new TypeError("Access Denied");
}
}
}

deep-thought.js

import { Question } from "./question.js";

const key = {};

// subclass
class DeepThought extends Question(key) {
get #answer() {
return this.answer(key);
}

tellMeTheAnswer() {
console.log(this.#answer);
}
}

const dm = new DeepThought();
dm.tellMeTheAnswer();

We begin by creating a factory function for the superclas: Question. This function takes a key as its input, which it will use to check access to any shared value it provides: answer. Then we create a unique key and pass it to the superclass factory as part of our subclass definition. Within the subclass, we create a private convenience property for accessing the shared value, which internally calls the superclass shared method with the predefined key. To avoid turning a protected member into a public member, we make our subclass getter private as well.

Using Decorators

The above code works for protected scenarios as well as arbitrary sharing, simply by sharing the key. However, there’s a fair bit of boilerplate code that’s not particularly expressive and is even a little confusing. We can improve this by using Decorators. Let’s look at the end result, and then I’ll show you how to create the decorators.

question.js

function Question(share) {
return class {
@share accessor #answer = 42;
}
}

deep-thought.js

import { Friends } from "./friends.js";
import { Question } from "./question.js";

const { access, share } = Friends.create();

class DeepThought extends Question(share) {
@access accessor #answer;

tellMeTheAnswer() {
console.log(this.#answer);
}
}

const dm = new DeepThought();
dm.tellMeTheAnswer();

I like this version better since the decorators make it clearer what values the superclass is sharing and what values the subclass is accessing. It also eliminates some of the confusion caused by similar names in the previous version and scales better with multiple members.

The technique that accomplishes this is the use of a factory function for two decorators that share a common private accessor storage. Let’s look at the implementation:

friends.js

const Friends = Object.freeze({
create() {
const fields = new WeakMap();

function init(instance, name, accessors) {
let f = fields.get(instance);

if (!f) {
f = new Map();
fields.set(instance, f);
}

f.set(name, accessors);
}

function getValue(instance, name) {
return fields.get(instance).get(name).get.call(instance);
}

function setValue(instance, name, value) {
return fields.get(instance).get(name).set.call(instance, value);
}

const share = (target, context) => {
const { name, kind } = context;

switch (kind) {
case "accessor":
return {
get() {
return getValue(this, name);
},

set(value) {
setValue(this, name, value);
},

init(value) {
init(this, name, target);
return value;
}
};
default:
throw new Error(`Decorator kind ${kind} unsupported.`);
}
};

const access = (_, context) => {
const { name, kind } = context;

switch (kind) {
case "accessor":
return {
get() {
return getValue(this, name);
},
};
default:
throw new Error(`Decorator kind ${kind} unsupported.`);
}
};

access.getValue = getValue;
return { access, share };
}
});

The Friends.create() factory creates two decorators:

  • The share decorator applies to an accessor field and stores the field’s get/set in a shared nested WeakMap/Map keyed by instance and then by field name.
  • The access decorator applies to an accessor field and looks up the stored getter by the instance and field name.

Additionally, the getValue method is attached to the access decorator to facilitate imperative retrieval of shared data.

The share decorator acts as a secure data channel. The access decorator acts as the key to that channel. By using this technique, not only can protected visibility be achieved, but also any other visibility that you need. The important detail is that the access decorator must only be shared with consumers that you want to allow.

NOTE: The above decorator only works with accessors. With some additional work, it could also be made to function with other class member types. That exercise is left to the reader 😉

IMPORTANT: Accessors and decorators have not shipped in browsers yet. To use them today, you’ll need a transpiler.

Wrapping Up

JavaScript continues to grow as a language, first with classes, then with fields, private access, and decorators. By combining features, just about any access pattern you may need can be accomplished.

If you enjoyed this exploration of class member visibility patterns in JavaScript, you might want to check out my Web Component Engineering course. I’d also love it if you would subscribe to this blog, subscribe to my YouTube channel, or follow me on twitter. Your support greatly helps me continue writing and bringing this kind of content to the broader community. Thank you!

--

--

EisenbergEffect
EisenbergEffect

Written by EisenbergEffect

W3C, WICG & TC39 contributor focused on Web Standards, UI Architecture & Engineering Culture. Former Technical Fellow, Principal Architect & VP-level tech lead.