TypeScriptAccess Modifiers (public, private, protected)

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.

TS
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'
Tip
Some teams prefer to always write 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.

TS
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 private
Warning
TypeScript private 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.

TS
// 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.

TS
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 protected
protected in Multi-Level Inheritance

TS
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.

TS
// 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 private
Success
Parameter properties are one of TypeScript's most loved features. They keep constructor-heavy value-object classes very concise.
Common Patterns

Service / Repository pattern — private internal state, public API surface:

TS
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 private

Template method pattern — protected hook methods overridden by subclasses:

TS
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)