Access Modifiers
Access modifiers control where class members can be read or written. TypeScript provides
public, private, and protected — all enforced at compile time. JavaScript's native
# syntax provides true runtime privacy. Choosing the right modifier is a key part of
writing well-encapsulated object-oriented code.
public — The Default
Every member is public by default. You can write it explicitly for clarity, but it is
not required.
class Car {
public make: string; // explicit public
model: string; // implicit public (same thing)
public year: number;
constructor(make: string, model: string, year: number) {
this.make = make;
this.model = model;
this.year = year;
}
public describe(): string {
return `${this.year} ${this.make} ${this.model}`;
}
}
const car = new Car('Toyota', 'Corolla', 2024);
console.log(car.make); // 'Toyota' — accessible anywhere
console.log(car.describe()); // '2024 Toyota Corolla'public explicitly so that every member has a visible access modifier, making code reviews easier.private — Compile-time Encapsulation
A private member is only accessible within the class body where it is declared.
Subclasses cannot access it either.
class BankAccount {
private balance: number;
private transactionLog: string[] = [];
constructor(initialBalance: number) {
this.balance = initialBalance;
}
deposit(amount: number): void {
if (amount <= 0) throw new Error('Amount must be positive');
this.balance += amount;
this.transactionLog.push(`+${amount}`);
}
withdraw(amount: number): void {
if (amount > this.balance) throw new Error('Insufficient funds');
this.balance -= amount;
this.transactionLog.push(`-${amount}`);
}
getBalance(): number {
return this.balance; // controlled read-only access
}
}
const account = new BankAccount(100);
account.deposit(50);
account.withdraw(30);
console.log(account.getBalance()); // 120
// account.balance = 1000000; // Error: Property 'balance' is privateprivate is erased at compile time. At runtime the field is just a regular JavaScript property — it can be accessed with bracket notation or by inspecting the object. Use JavaScript #private for true runtime privacy.TypeScript private vs JavaScript #private
TypeScript's private keyword and JavaScript's native # syntax both express privacy,
but they work very differently.
// TypeScript private — compile-time only
class TsSecret {
private secret = 'ts-value';
getSecret() { return this.secret; }
}
const ts = new TsSecret();
// ts.secret; // Error at compile time
(ts as any).secret; // 'ts-value' — accessible at runtime!
// JavaScript #private — true runtime privacy
class JsSecret {
#secret = 'js-value';
getSecret() { return this.#secret; }
}
const js = new JsSecret();
// js.#secret; // SyntaxError at compile AND runtime
// (js as any).secret; // undefined — truly inaccessible!
console.log(ts.getSecret()); // 'ts-value'
console.log(js.getSecret()); // 'js-value'Aspect | TypeScript private | JavaScript #private |
|---|---|---|
Enforcement | Compile-time only | Compile-time + Runtime |
Runtime access via (obj as any) | Possible | Impossible |
Works in .d.ts / cross-boundary | Yes | Yes (TS 4.3+) |
Inheritance | Not accessible in subclass | Not accessible in subclass |
Performance | No overhead | Slight overhead (WeakMap internally) |
Use when | Most cases — simpler syntax | Serialisation security, libraries |
protected — Accessible in Subclasses
A protected member is like private but it is also accessible inside derived classes.
Use it when a base class needs to share internal state or behaviour with its subclasses
without exposing it to the outside world.
abstract class Animal {
protected name: string;
protected sound: string;
constructor(name: string, sound: string) {
this.name = name;
this.sound = sound;
}
protected makeSound(): string {
return `${this.name} says ${this.sound}!`;
}
abstract describe(): string;
}
class Dog extends Animal {
private breed: string;
constructor(name: string, breed: string) {
super(name, 'Woof'); // can pass to super
this.breed = breed;
}
describe(): string {
// Can access protected members from base class
return `${this.name} is a ${this.breed}. ${this.makeSound()}`;
}
fetch(item: string): string {
return `${this.name} fetches the ${item}!`;
}
}
const rex = new Dog('Rex', 'German Shepherd');
console.log(rex.describe()); // Rex is a German Shepherd. Rex says Woof!
// rex.name; // Error: 'name' is protected
// rex.makeSound(); // Error: 'makeSound' is protectedprotected in Multi-Level Inheritance
class Vehicle {
protected engineSize: number;
protected fuelType: string;
constructor(engineSize: number, fuelType: string) {
this.engineSize = engineSize;
this.fuelType = fuelType;
}
protected startEngine(): string {
return `Engine (${this.engineSize}L ${this.fuelType}) started`;
}
}
class Car extends Vehicle {
protected doors: number;
constructor(engineSize: number, fuelType: string, doors: number) {
super(engineSize, fuelType);
this.doors = doors;
}
drive(): string {
return `${this.startEngine()} — driving a ${this.doors}-door car`;
}
}
class SportsCar extends Car {
private turbo: boolean;
constructor(engineSize: number, doors: number, turbo: boolean) {
super(engineSize, 'petrol', doors);
this.turbo = turbo;
}
race(): string {
const boost = this.turbo ? ' (turbo boost!)' : '';
// Can access protected members from both Car and Vehicle
return `${this.startEngine()} — racing with ${this.doors} doors${boost}`;
}
}
const ferrari = new SportsCar(3.9, 2, true);
console.log(ferrari.race());
// Engine (3.9L petrol) started — racing with 2 doors (turbo boost!)Constructor Parameter Shorthand
TypeScript lets you declare and assign class fields directly in the constructor parameter
list by prefixing them with an access modifier (or readonly). This eliminates the
repetitive boilerplate of declaring the field and then assigning it in the body.
// Verbose version — lots of repetition
class VerboseUser {
public id: number;
public name: string;
private email: string;
protected role: string;
constructor(id: number, name: string, email: string, role: string) {
this.id = id;
this.name = name;
this.email = email;
this.role = role;
}
}
// Concise version — parameter properties
class User {
constructor(
public id: number,
public name: string,
private email: string,
protected role: string,
) {}
// Fields are automatically declared and assigned — no body needed!
getContactInfo(): string {
return `${this.name} <${this.email}>`;
}
}
const user = new User(1, 'Alice', 'alice@example.com', 'admin');
console.log(user.name); // 'Alice'
console.log(user.getContactInfo()); // 'Alice <alice@example.com>'
// user.email; // Error: 'email' is privateCommon Patterns
Service / Repository pattern — private internal state, public API surface:
class UserService {
private users = new Map<number, { id: number; name: string }>();
private nextId = 1;
create(name: string): { id: number; name: string } {
const user = { id: this.nextId++, name };
this.users.set(user.id, user);
return user;
}
findById(id: number): { id: number; name: string } | undefined {
return this.users.get(id);
}
findAll(): { id: number; name: string }[] {
return Array.from(this.users.values());
}
}
const svc = new UserService();
svc.create('Alice');
svc.create('Bob');
console.log(svc.findAll().length); // 2
// svc.users; // Error: 'users' is privateTemplate method pattern — protected hook methods overridden by subclasses:
abstract class DataExporter {
// Public template method
export(data: unknown[]): string {
const header = this.buildHeader();
const rows = data.map(item => this.formatRow(item));
const footer = this.buildFooter(data.length);
return [header, ...rows, footer].join('\n');
}
protected abstract buildHeader(): string;
protected abstract formatRow(item: unknown): string;
// Hook with default implementation — override optionally
protected buildFooter(count: number): string {
return `Total: ${count} records`;
}
}
class CsvExporter extends DataExporter {
protected buildHeader(): string {
return 'id,name,value';
}
protected formatRow(item: unknown): string {
const row = item as { id: number; name: string; value: number };
return `${row.id},${row.name},${row.value}`;
}
}
const exporter = new CsvExporter();
const output = exporter.export([
{ id: 1, name: 'Alpha', value: 10 },
{ id: 2, name: 'Beta', value: 20 },
]);
console.log(output);id,name,value 1,Alpha,10 2,Beta,20 Total: 2 records
Anti-patterns to Avoid
Making everything public — defeats encapsulation, makes refactoring harder
Using protected where private suffices — exposes internals to subclasses unnecessarily
Relying on TypeScript private for security — use JavaScript # if you need true runtime privacy
Mixing constructor shorthand and manual field declaration for the same field
Using protected to share utility functions — extract them into module-level functions instead
Quick Reference
Modifier | Same class | Subclass | Outside class |
|---|---|---|---|
public | Yes | Yes | Yes |
protected | Yes | Yes | No |
private | Yes | No | No |
#private (JS) | Yes | No | No (runtime enforced) |