Access modifiers in TypeScript: public, private, protected, readonly
Access modifiers in TypeScript (public, private, protected, readonly) control which parts of a class are visible from outside, from subclasses, or only from within the class itself.
Theory
TL;DR
- All class members are
publicby default, so access modifiers mostly restrict access, not grant it privatelocks a member to the defining class only;protectedopens it to subclasses too;readonlylets anyone read but blocks reassignment after construction- These checks exist only at compile time. The emitted JavaScript has no trace of them
- Rule of thumb: start with
private, loosen toprotectedonly when inheritance demands it, and mark anything that should not change asreadonly
Quick example
class Animal {
public name: string; // Accessible anywhere
private secret: string = "hidden"; // This class only
protected species: string = "mammal"; // This class + subclasses
readonly id: number; // Read anywhere, set once
constructor(name: string, id: number) {
this.name = name;
this.id = id;
}
}
const cat = new Animal("Whiskers", 1);
console.log(cat.name); // "Whiskers" - public, works
console.log(cat.id); // 1 - readonly, works
// cat.secret // Error: private
// cat.id = 2; // Error: readonlypublic and readonly are reachable outside the class. private and protected are not.
Key difference
private, protected, and readonly all restrict something, but they restrict different things. private restricts who can read or write a member (the class itself, nothing else). protected relaxes that rule to include subclasses. readonly does not restrict who reads at all. It restricts when you can write, which is only during construction. These two ideas are independent: you can have a private readonly property that is locked to the class and frozen after init.
When to use
- Internal state that no consumer should touch:
private - A helper method or property a subclass needs to build on:
protected - An ID, a config URL, anything that must not change after object creation:
readonly - Anything that is part of the public API:
public(or just omit the modifier, since it defaults topublic)
Comparison table
| Modifier | Inside class | Subclass | Outside | Reassign after init |
|---|---|---|---|---|
public | yes | yes | yes | yes |
protected | yes | yes | no | yes |
private | yes | no | no | yes |
readonly | set once | read only | read only | no |
How the compiler handles this
TypeScript checks these restrictions during the tsc build. The compiler scans class declarations and flags any access that violates the declared modifier. After that, the emitted .js file contains none of it. V8 and Node.js see plain object properties. So private balance = 1000 becomes just balance = 1000 in the output. The TypeScript Language Server in VS Code applies the same rules in real time via .d.ts metadata, which is why your editor underlines violations before you run anything.
This differs from Java or C#, where access control is enforced at runtime by the VM. In TypeScript, the modifier system is a development tool. If someone casts to any, the check disappears. Most developers discover this the first time they log a supposedly private property and see it sitting right there in the console.
Common mistakes
Mistake 1: trusting private to protect sensitive data at runtime
class Safe {
private pin = 1234;
}
const s = new Safe();
(s as any).pin = 0000; // Works. No error.
console.log((s as any).pin); // 0private erases at compile time. For real runtime hiding, use JavaScript's # syntax (ES2022):
class Safe {
#pin = 1234; // V8 physically blocks access even with casting
}Mistake 2: using protected when you mean "almost private"
class Parent {
protected secret = "internal";
}
class Child extends Parent {
expose() { return this.secret; }
}
const c = new Child();
c.expose(); // "internal" - it got outIf no subclass needs direct access, use private. protected is for inheritance hooks, not for "slightly less private."
Mistake 3: assuming readonly blocks deep mutation
class Config {
readonly options = { debug: false };
}
const cfg = new Config();
cfg.options = { debug: true }; // Error - reference reassignment blocked
cfg.options.debug = true; // Fine - mutation of the object itself is allowedreadonly blocks reassignment of the reference, not mutation of the underlying object. For deep immutability, combine it with Object.freeze() or a DeepReadonly utility type.
Mistake 4: forgetting parameter property syntax
// These two classes behave identically:
class User {
private name: string;
constructor(name: string) { this.name = name; }
}
class User {
constructor(private name: string) {} // Declares and assigns in one step
}If you omit the modifier in the constructor signature, the property is never declared on the class.
Real-world usage
- React:
readonlyon prop types to catch mutation attempts inside components - Express/Passport.js:
privateon token handlers and internal middleware state - NestJS:
protectedmethods in base controllers that child controllers extend - Redux Toolkit:
readonlyon state slices to catch accidental mutations at the type level - Prisma:
readonlyon generated model IDs
Follow-up questions
Q: What does TypeScript private compile to in JavaScript?
A: A plain class field. class X { private y = 1 } becomes class X { y = 1 } in the output. Any access at runtime works fine.
Q: Can a subclass access a private member?
A: No. TypeScript gives a compile error. Use protected if a subclass needs that member.
Q: What is the difference between private and readonly?
A: They control different things. private controls who can access the member. readonly controls when you can write to it. A private readonly property combines both: class-only access and immutable after construction.
Q: Why use private at all if it has no runtime enforcement?
A: The benefit is during development. Your IDE flags violations before the code runs, tsc blocks invalid builds, and it signals intent to teammates reading the code.
Q: A library ships to JavaScript consumers. How do you enforce privacy at runtime?
A: Combine approaches. TypeScript private for IDE and type-level feedback. JavaScript # fields for actual runtime enforcement: V8 blocks access regardless of casting. For extra isolation, use factory functions that return a typed interface, so consumers never hold a direct reference to the class instance.
Examples
All four modifiers in one class
class BankAccount {
public owner: string; // Anyone can read
private balance: number; // Internal only
protected accountType: string; // Subclasses can read
readonly id: string; // Set once, immutable
constructor(owner: string, initial: number, id: string) {
this.owner = owner;
this.balance = initial;
this.accountType = "standard";
this.id = id;
}
public deposit(amount: number): void {
this.balance += amount; // OK inside the class
}
public getBalance(): number {
return this.balance;
}
}
const acc = new BankAccount("Alice", 500, "ACC-001");
acc.owner; // "Alice"
acc.getBalance(); // 500
// acc.balance; // Error: private
// acc.id = "X"; // Error: readonlybalance is hidden but reachable through getBalance(). This is the standard encapsulation pattern for class-based code.
Inheritance with protected
class Vehicle {
protected speed: number = 0;
protected accelerate(amount: number): void {
this.speed += amount;
}
}
class Car extends Vehicle {
public drive(): string {
this.accelerate(60); // OK - protected, subclass can call it
return `Speed: ${this.speed}km/h`;
}
}
const car = new Car();
car.drive(); // "Speed: 60km/h"
// car.speed; // Error: protected, not part of the public interfaceprotected makes sense here because Car needs the Vehicle internals, but consumers of Car should not see them.
TypeScript private vs JavaScript #private
class Secure {
private tsField = "typescript only";
#jsField = "runtime enforced"; // ES2022
reveal() {
console.log(this.tsField); // OK
console.log(this.#jsField); // OK
}
}
const s = new Secure();
s.reveal();
(s as any).tsField; // Works - TypeScript private is gone at runtime
// s.#jsField; // SyntaxError - V8 itself blocks thisThe # syntax is enforced by V8. TypeScript private offers no protection once the code is running. For anything actually sensitive, # is the right call.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.