Skip to main content

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 public by default, so access modifiers mostly restrict access, not grant it
  • private locks a member to the defining class only; protected opens it to subclasses too; readonly lets 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 to protected only when inheritance demands it, and mark anything that should not change as readonly

Quick example

typescript
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: readonly

public 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 to public)

Comparison table

ModifierInside classSubclassOutsideReassign after init
publicyesyesyesyes
protectedyesyesnoyes
privateyesnonoyes
readonlyset onceread onlyread onlyno

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

typescript
class Safe { private pin = 1234; } const s = new Safe(); (s as any).pin = 0000; // Works. No error. console.log((s as any).pin); // 0

private erases at compile time. For real runtime hiding, use JavaScript's # syntax (ES2022):

typescript
class Safe { #pin = 1234; // V8 physically blocks access even with casting }

Mistake 2: using protected when you mean "almost private"

typescript
class Parent { protected secret = "internal"; } class Child extends Parent { expose() { return this.secret; } } const c = new Child(); c.expose(); // "internal" - it got out

If no subclass needs direct access, use private. protected is for inheritance hooks, not for "slightly less private."

Mistake 3: assuming readonly blocks deep mutation

typescript
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 allowed

readonly 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

typescript
// 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: readonly on prop types to catch mutation attempts inside components
  • Express/Passport.js: private on token handlers and internal middleware state
  • NestJS: protected methods in base controllers that child controllers extend
  • Redux Toolkit: readonly on state slices to catch accidental mutations at the type level
  • Prisma: readonly on 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

typescript
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: readonly

balance is hidden but reachable through getBalance(). This is the standard encapsulation pattern for class-based code.

Inheritance with protected

typescript
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 interface

protected makes sense here because Car needs the Vehicle internals, but consumers of Car should not see them.

TypeScript private vs JavaScript #private

typescript
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 this

The # 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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?