Access Modifiers
Access modifiers control which code is allowed to read or write a class member. TypeScript offers three keywords — public, private, and protected — that enforce encapsulation at compile time. Alongside them, modern JavaScript has its own #private fields that are enforced at runtime. Understanding the difference between the two systems is essential: TypeScript modifiers are erased during compilation, while #private fields are real, hard-private even after the types are gone. This page covers all three modifiers and how they relate to ECMAScript private fields.
public (the default)
A public member is accessible from anywhere. Members are public unless you say otherwise, so the keyword is usually optional and written only for emphasis.
class Counter {
public count = 0; // explicit
step = 1; // implicitly public
increment(): void {
this.count += this.step;
}
}
const c = new Counter();
c.count; // ✅ readable everywhere
Because public is the default, most teams omit the keyword and reserve explicit modifiers for private and protected.
private
A private member is accessible only from within the declaring class. Subclasses and external code cannot touch it. The compiler rejects any outside access.
class BankAccount {
private balance = 0;
deposit(amount: number): void {
this.balance += amount;
}
getBalance(): number {
return this.balance;
}
}
const acct = new BankAccount();
acct.deposit(100);
acct.getBalance(); // ✅ 100
acct.balance; // ❌ Error: Property 'balance' is private
This enforces encapsulation: callers interact through deposit and getBalance, never the raw field. Note that TypeScript private is checked only at compile time — at runtime the property still exists as a plain object key.
protected
A protected member is accessible within the declaring class and its subclasses, but not from outside. It is the right choice for state and helpers that derived classes need but consumers should not see.
class Shape {
protected sides: number;
constructor(sides: number) {
this.sides = sides;
}
}
class Triangle extends Shape {
constructor() {
super(3);
}
describe(): string {
return `A shape with ${this.sides} sides`; // ✅ subclass access
}
}
new Triangle().sides; // ❌ Error: Property 'sides' is protected
Triangle reads this.sides freely because it extends Shape, but external code still cannot.
TypeScript modifiers vs ECMAScript #private
JavaScript itself now supports hard-private fields prefixed with #. These are fundamentally different from TypeScript’s private. A # field is enforced by the language at runtime, is not enumerable, and is genuinely inaccessible outside the class — even via bracket access or casting to any.
class Vault {
#secret = "hunter2"; // runtime-private (ECMAScript)
private legacySecret = "abc"; // compile-time-private (TypeScript)
reveal(): string {
return this.#secret;
}
}
const v = new Vault();
(v as any).legacySecret; // ✅ "abc" — TS private is erased, accessible at runtime
(v as any)["#secret"]; // ❌ undefined — # fields are truly hidden
v.#secret; // ❌ SyntaxError outside the class
The casting trick exposes the TypeScript private field because that modifier disappears after compilation. The #secret field survives because it is a real language feature.
| Aspect | TS private / protected | ECMAScript #field |
|---|---|---|
| Enforced | Compile time only | Runtime (and compile time) |
Survives as any | No — accessible | Yes — still hidden |
| Subclass access | protected allows it | Never (always class-only) |
| Visible in JS output | Yes (plain property) | Yes (true private slot) |
| Name collisions | Shares normal namespace | Separate # namespace |
Reach for
#privatefields when you need real runtime encapsulation — for example in security-sensitive code or published libraries. Use TypeScriptprivate/protectedwhen compile-time checking is enough and you wantprotectedsemantics, which#cannot express.
Combining with other modifiers
Access modifiers compose with readonly, static, and parameter properties. Order matters: the access modifier comes first.
class Service {
private static instance: Service;
public readonly id = crypto.randomUUID();
private constructor() {}
static getInstance(): Service {
return (this.instance ??= new Service());
}
}
A private constructor blocks new Service() from outside, forcing construction through the static factory — a common singleton technique.
Best Practices
- Default to the most restrictive modifier that works; start
privateand widen only when needed. - Omit the redundant
publickeyword except on parameter properties where it is required. - Use
protectedfor members subclasses legitimately need, not as a looserprivate. - Prefer
#privatefields when you need true runtime privacy or are shipping a library. - Remember TS
privateis erased — never rely on it to hide secrets at runtime. - Combine
private staticwith a private constructor to implement singletons. - Expose state through methods or accessors rather than public mutable fields.