16.1 Class Fields: Public, Private, Protected, and Readonly
Right, let’s talk about access modifiers. This is where TypeScript starts to feel less like JavaScript’s quirky cousin and more like a proper, grown-up language with a sense of decorum. It’s the language saying, “Okay, fine, we’ll let you have your dynamic chaos, but at least here, inside this class, we’re going to have some rules.” And thank goodness for that.
In vanilla JavaScript, everything is public. Every property you slap onto this is fair game for anyone, anywhere, to poke, prod, and mutate. It’s the equivalent of leaving your diary on a park bench with a sign that says “Please be nice.” TypeScript gives you the tools to lock that diary in a safe, give a key to your best friend, and tell everyone else to get lost.
We have three main keywords for this: public, private, and protected. And then there’s the bouncer at the door, readonly, who doesn’t care who you are, you’re not changing a thing.
Public: The Default (That You Should Rarely Use Explicitly)
Let’s get this one out of the way. public is the default. If you don’t write any keyword before a field, it’s public. Explicitly writing public is like putting a “KICK ME” sign on your own back; it’s technically accurate but entirely redundant. I only use it when I’m feeling particularly pedantic or need to align other modifiers on the same line for readability.
class Radio {
public volume: number = 5; // Why did you type 'public'? Who are you showing off for?
station: string = "90.1"; // This is also public. See? So much cleaner.
crankIt() {
this.volume = 11; // Of course we can do this. It's public.
}
}
const myRadio = new Radio();
myRadio.volume = 100; // Anyone can just blast my eardrums. Thanks, JavaScript.
myRadio.station = "Static"; // Yep, this too.
Private: “For My Eyes Only”
This is the workhorse. You should default to making fields private. It’s the core of encapsulation—the principle that an object’s internal state shouldn’t be directly accessible from the outside world. You interact with it through public methods, which act as controlled gatekeepers. This prevents other parts of your code from putting the object into an invalid state.
Now, here’s the first absurdity: TypeScript has two ways to declare private fields. The classic way, using the private keyword, and the new ECMAScript standard way, using a # prefix.
class ClassicVault {
private secret: string = "The crown jewels are paste."; // TypeScript-private
}
class ModernVault {
#secret: string = "Seriously, don't tell anyone."; // JavaScript-private (hard private)
}
const classic = new ClassicVault();
// @ts-expect-error - TypeScript will yell at you, but it's still there in JavaScript!
console.log(classic.secret);
const modern = new ModernVault();
// This will be a runtime error too. It's truly, actually private.
// console.log(modern.#secret);
The private keyword is a compile-time check. It’s TypeScript’s type system politely asking you not to access that field. But if you ignore it and force your way through with // @ts-ignore or by using plain JavaScript, the property is still there and accessible. It’s a suggestion.
The #secret syntax is hard privacy. It’s enforced by the JavaScript engine itself. It will throw an error at runtime if you try to access it. It’s a rule.
Which should you use? For new code, prefer #. It’s more robust and actually part of the language. The private keyword is mostly for legacy at this point. The designers made a questionable choice by introducing their own soft private first, but they’ve since course-corrected to align with the standard.
Protected: “For Me and My Children”
protected sits between public and private. A protected field is inaccessible from the outside world, just like a private one, but it is accessible by subclasses. Use this when you’re building a base class that you expect other classes to extend, and those subclasses need direct access to the internal state.
class Vehicle {
protected honkSound: string = "Beep!";
public honk() {
console.log(this.honkSound);
}
}
class Truck extends Vehicle {
constructor() {
super();
// I can change the protected field from my parent.
this.honkSound = "HONK!";
}
public superHonk() {
// I can use it in my own methods.
console.log(this.honkSound.toUpperCase());
}
}
const myTruck = new Truck();
myTruck.honk(); // "HONK!"
// @ts-expect-error - Can't access it from the outside.
// myTruck.honkSound = "Squeak";
Be careful with protected. It creates a tight coupling between the base class and its subclasses. Changing a protected field is a breaking change for all your subclasses, so use it judiciously.
Readonly: The Immutability Bouncer
The readonly modifier is beautifully straightforward. It means you can assign a value to the field only once—either in its declaration or inside the class constructor. After that, it’s set in stone. It’s perfect for values that should never change after initialization, like an ID, a configuration value, or a reference to a foundational dependency.
It works with any of the access modifiers. A readonly public field is one anyone can read but no one can change. A readonly private field is one that’s set during construction and then never altered, even internally.
class Monument {
public readonly name: string;
private readonly height: number;
constructor(name: string, height: number) {
this.name = name; // This is fine.
this.height = height; // Also fine.
}
attemptRenovation() {
// @ts-expect-error - Nope. Can't change it after the constructor.
// this.height = this.height + 10;
console.log("Plan rejected by the historical society.");
}
}
const washington = new Monument("Washington Monument", 555);
console.log(washington.name); // Allowed, it's public.
// @ts-expect-error - Not allowed, it's readonly.
// washington.name = "Jefferson Monument";
Crucial Pitfall: readonly is, like the classic private, a TypeScript compile-time check. It doesn’t make the value immutable itself. If it’s a reference to an object or an array, its properties or elements can still be changed. It just means you can’t reassign the monument.name field to a whole new string. For true deep immutability, you’d need to use Object.freeze() or similar techniques. It’s a good start, but don’t mistake it for a full immutability solution.