Logo

Home

About

Blog

Contact

Buy Merch

Portfolio

Privacy

TOS

Click to navigate

  1. Home
  2. Joshua R. Lehman's Blog
  3. Access Modifiers: public, private, protected

Table of contents

  • Share on X
  • Discuss on X

Related Articles

Classes in TypeScript: The Basics
TypeScript
7m
Feb 8, 2026

Classes in TypeScript: The Basics

A comprehensive introduction to TypeScript classes covering properties, methods, constructors, and object instantiation with type safety.

#TypeScript Classes#OOP+5
Object Property Modifiers
TypeScript
6m
Jan 22, 2026

Object Property Modifiers

TypeScript provides powerful property modifiers to control how object properties behave. This guide covers optional properties, readonly properties, and required properties to help you create precise and safe type definitions.

#TypeScript#Object Properties+3
Mastering TypeScript Utility Types
TypeScript
9m
Feb 1, 2026

Mastering TypeScript Utility Types

TypeScript provides powerful built-in utility types that help you transform existing types without rewriting them. This guide covers the most essential utility types and how to use them effectively in your projects.

#TypeScript#Utility Types+4
Ask me anything! 💬

© Joshua R. Lehman

Full Stack Developer

Crafted with passion • Built with modern web technologies

2026 • All rights reserved

Contents

  • Understanding Access Control
  • The Three Access Modifiers
  • Why Encapsulation Matters
  • Practical Patterns
  • Protected vs Private: When to Use Each
  • Access Modifiers in Constructors
  • Runtime vs Compile-Time
  • Best Practices
TypeScript

Access Modifiers: public, private, protected

February 15, 2026•15 min read
Joshua R. Lehman
Joshua R. Lehman
Author
Diagram showing public, private, and protected access levels in TypeScript classes
Access Modifiers: public, private, protected

Understanding Access Control

Access modifiers are keywords that control how and where class members (properties and methods) can be accessed. They're fundamental to encapsulation—one of the core principles of object-oriented programming that lets you hide implementation details and expose only what's necessary.

Think of access modifiers as security clearance levels. Some information is public (anyone can access it), some is private (only you can access it), and some is protected (you and your trusted associates can access it). TypeScript gives you three access modifiers to implement this control: public, private, and protected.

Before TypeScript, JavaScript had no formal access control—everything was public. TypeScript's access modifiers give you compile-time enforcement of access rules, preventing accidental misuse of class internals. While these restrictions are removed in compiled JavaScript, the TypeScript compiler catches violations during development, which is when you want to find bugs.

The Three Access Modifiers

public: The Default

Members marked public can be accessed from anywhere—inside the class, outside the class, in subclasses, everywhere. In TypeScript, if you don't specify an access modifier, members are public by default:

class User {
  public name: string; // Explicitly public
  email: string; // Implicitly public (no modifier = public)
 
  public constructor(name: string, email: string) {
    this.name = name;
    this.email = email;
  }
 
  public greet(): string {
    return `Hello, I'm ${this.name}`;
  }
}
 
const user = new User("Alice", "[email protected]");
console.log(user.name); // ✅ Accessible
console.log(user.email); // ✅ Accessible
console.log(user.greet()); // ✅ Accessible

Most developers omit the public keyword since it's the default, but you can include it explicitly for clarity if you prefer.

private: Class-Only Access

Members marked private can only be accessed from within the class that defines them. They're completely hidden from outside code, including subclasses:

class BankAccount {
  private balance: number;
  public accountNumber: string;
 
  constructor(accountNumber: string, initialBalance: number) {
    this.accountNumber = accountNumber;
    this.balance = initialBalance;
  }
 
  public deposit(amount: number): void {
    if (amount > 0) {
      this.balance += amount; // ✅ Can access private member inside class
    }
  }
 
  public getBalance(): number {
    return this.balance; // ✅ Can access private member inside class
  }
}
 
const account = new BankAccount("ACC001", 1000);
account.deposit(500);
 
console.log(account.accountNumber); // ✅ Public property accessible
console.log(account.getBalance()); // ✅ Public method accessible
console.log(account.balance); // ❌ Error: Property 'balance' is private

The error message is clear: TypeScript prevents you from accessing balance directly because it's marked private. This forces you to use the public methods (deposit, getBalance) which can enforce business rules and validation.

protected: Class and Subclass Access

Members marked protected can be accessed from within the defining class and any class that extends it (subclasses), but not from outside code:

class Employee {
  protected salary: number;
  public name: string;
 
  constructor(name: string, salary: number) {
    this.name = name;
    this.salary = salary;
  }
 
  protected calculateBonus(): number {
    return this.salary * 0.1; // 10% bonus
  }
}
 
class Manager extends Employee {
  private teamSize: number;
 
  constructor(name: string, salary: number, teamSize: number) {
    super(name, salary);
    this.teamSize = teamSize;
  }
 
  public getTotalCompensation(): number {
    // ✅ Can access protected members from parent class
    return this.salary + this.calculateBonus();
  }
}
 
const manager = new Manager("Bob", 80000, 5);
console.log(manager.name); // ✅ Public property accessible
console.log(manager.getTotalCompensation()); // ✅ Public method accessible
console.log(manager.salary); // ❌ Error: Property 'salary' is protected
console.log(manager.calculateBonus()); // ❌ Error: Method 'calculateBonus' is protected

The protected modifier is particularly useful for inheritance hierarchies where you want to share implementation details with subclasses but hide them from external code.

Access Modifier Visibility

Which contexts can access members with each modifier

Access Modifier
Same Class
Inside the class that defines the member
Subclass
In a class that extends the defining class
External Code
Outside the class and its subclasses
public
protected
private
Key Takeaways
  • ✓public: Accessible everywhere (default)
  • ✓protected: Class + subclasses only
  • ✓private: Class only (most restrictive)

Quick Reference

Use public for your API (what users interact with), private for implementation details (nobody else needs to see), and protected for inheritance support (subclasses need access but external code doesn't).

Why Encapsulation Matters

Encapsulation is about hiding complexity and controlling access to an object's internal state. Access modifiers are the primary tool for achieving encapsulation in TypeScript.

Problem: Unrestricted Access

Without access modifiers, anyone can modify any property, leading to invalid states:

class Rectangle {
  width: number;
  height: number;
 
  constructor(width: number, height: number) {
    this.width = width;
    this.height = height;
  }
 
  getArea(): number {
    return this.width * this.height;
  }
}
 
const rect = new Rectangle(10, 5);
console.log(rect.getArea()); // 50
 
// Nothing stops this nonsense:
rect.width = -20; // Negative width?!
rect.height = 0; // Zero height?!
console.log(rect.getArea()); // 0 (mathematically correct but logically wrong)

The problem: external code can put the object into an invalid state. A rectangle with negative or zero dimensions doesn't make sense, but nothing prevents it.

Solution: Controlled Access

Use private properties and public methods to enforce invariants:

class Rectangle {
  private width: number;
  private height: number;
 
  constructor(width: number, height: number) {
    if (width <= 0 || height <= 0) {
      throw new Error("Width and height must be positive");
    }
    this.width = width;
    this.height = height;
  }
 
  public setWidth(width: number): void {
    if (width <= 0) {
      throw new Error("Width must be positive");
    }
    this.width = width;
  }
 
  public setHeight(height: number): void {
    if (height <= 0) {
      throw new Error("Height must be positive");
    }
    this.height = height;
  }
 
  public getWidth(): number {
    return this.width;
  }
 
  public getHeight(): number {
    return this.height;
  }
 
  public getArea(): number {
    return this.width * this.height;
  }
}
 
const rect = new Rectangle(10, 5);
console.log(rect.getArea()); // 50
 
rect.setWidth(20); // ✅ Valid
console.log(rect.getArea()); // 100
 
rect.setWidth(-10); // ❌ Error: Width must be positive
rect.width = -10; // ❌ Error: Property 'width' is private

Now the class maintains its invariants. The rectangle will always have positive dimensions because the public methods enforce validation.

Common Mistake

Making all properties public "just in case" defeats the purpose of encapsulation. Start with private by default and only expose what's truly needed through public methods. You can always make something more accessible later, but removing public access is a breaking change.

Practical Patterns

Pattern 1: Private Implementation Details

Keep internal mechanics private, expose only the public interface:

class Cache {
  private data: Map<string, any> = new Map();
  private maxSize: number;
 
  constructor(maxSize: number = 100) {
    this.maxSize = maxSize;
  }
 
  public get(key: string): any | undefined {
    return this.data.get(key);
  }
 
  public set(key: string, value: any): void {
    // Private method handles eviction logic
    this.evictIfNeeded();
    this.data.set(key, value);
  }
 
  public has(key: string): boolean {
    return this.data.has(key);
  }
 
  public clear(): void {
    this.data.clear();
  }
 
  // Private helper method - implementation detail
  private evictIfNeeded(): void {
    if (this.data.size >= this.maxSize) {
      // Simple eviction: remove oldest entry
      const firstKey = this.data.keys().next().value;
      this.data.delete(firstKey);
    }
  }
}
 
const cache = new Cache(3);
cache.set("a", 1);
cache.set("b", 2);
cache.set("c", 3);
cache.set("d", 4); // Triggers eviction internally
 
console.log(cache.has("a")); // false (evicted)
console.log(cache.has("d")); // true
 
// cache.evictIfNeeded(); // ❌ Error: Method 'evictIfNeeded' is private
// cache.data.clear(); // ❌ Error: Property 'data' is private

External code doesn't need to know about eviction logic. They just use get, set, and has. The implementation can change without breaking client code.

Pattern 2: Protected Template Methods

Base classes define protected methods that subclasses can override or extend:

abstract class DataProcessor {
  protected data: any[];
 
  constructor(data: any[]) {
    this.data = data;
  }
 
  // Template method - public
  public process(): any[] {
    this.validate();
    this.transform();
    this.cleanup();
    return this.data;
  }
 
  // Protected methods - subclasses can override
  protected validate(): void {
    if (!Array.isArray(this.data)) {
      throw new Error("Data must be an array");
    }
  }
 
  protected abstract transform(): void; // Must be implemented by subclass
 
  protected cleanup(): void {
    // Default cleanup (can be overridden)
    this.data = this.data.filter((item) => item != null);
  }
}
 
class NumberProcessor extends DataProcessor {
  protected transform(): void {
    // ✅ Can access protected method from parent
    this.data = this.data.map((num) => {
      if (typeof num !== "number") {
        throw new Error("All items must be numbers");
      }
      return num * 2;
    });
  }
 
  protected cleanup(): void {
    // ✅ Override parent's cleanup
    super.cleanup(); // Call parent's cleanup first
    this.data = this.data.filter((num) => num > 0); // Additional filtering
  }
}
 
const processor = new NumberProcessor([1, 2, null, 3, -4]);
const result = processor.process();
console.log(result); // [2, 4, 6] (doubled, nulls removed, negatives removed)
 
// processor.validate(); // ❌ Error: Method 'validate' is protected
// processor.transform(); // ❌ Error: Method 'transform' is protected

The template method pattern uses protected to let subclasses customize behavior while keeping the overall algorithm structure private.

Pattern 3: Readonly Public, Private Setter

Sometimes you want a value readable from outside but only settable internally:

class Counter {
  private _count: number = 0;
 
  public get count(): number {
    return this._count;
  }
 
  public increment(): void {
    this._count++;
  }
 
  public decrement(): void {
    if (this._count > 0) {
      this._count--;
    }
  }
 
  public reset(): void {
    this._count = 0;
  }
}
 
const counter = new Counter();
console.log(counter.count); // 0 (can read)
 
counter.increment();
counter.increment();
console.log(counter.count); // 2
 
// counter.count = 100; // ❌ Error: Cannot assign to 'count' because it is a read-only property
counter._count = 100; // ❌ Error: Property '_count' is private

External code can read count but can't set it directly. All modifications go through controlled methods.

Pattern 4: Private Constructor for Singletons

Use a private constructor to prevent direct instantiation:

class DatabaseConnection {
  private static instance: DatabaseConnection;
  private connectionString: string;
  private isConnected: boolean = false;
 
  private constructor(connectionString: string) {
    this.connectionString = connectionString;
  }
 
  public static getInstance(connectionString?: string): DatabaseConnection {
    if (!DatabaseConnection.instance) {
      if (!connectionString) {
        throw new Error("Connection string required for first initialization");
      }
      DatabaseConnection.instance = new DatabaseConnection(connectionString);
    }
    return DatabaseConnection.instance;
  }
 
  public connect(): void {
    if (!this.isConnected) {
      console.log(`Connecting to ${this.connectionString}...`);
      this.isConnected = true;
    }
  }
 
  public disconnect(): void {
    if (this.isConnected) {
      console.log("Disconnecting...");
      this.isConnected = false;
    }
  }
}
 
// const db = new DatabaseConnection("..."); // ❌ Error: Constructor is private
 
const db1 = DatabaseConnection.getInstance("postgres://localhost:5432");
const db2 = DatabaseConnection.getInstance(); // Returns same instance
 
console.log(db1 === db2); // true (same instance)

The private constructor ensures only one instance exists—the singleton pattern.

Protected vs Private: When to Use Each

Use private When:

Implementation details that should never be exposed:

class PasswordManager {
  private salt: string;
  private hashAlgorithm: string = "sha256";
 
  constructor() {
    this.salt = this.generateSalt();
  }
 
  private generateSalt(): string {
    return Math.random().toString(36).substring(2);
  }
 
  private hash(password: string): string {
    // Complex hashing logic
    return password + this.salt; // Simplified for example
  }
 
  public setPassword(password: string): string {
    return this.hash(password);
  }
}

Nobody outside (including subclasses) needs to know about salt, hashAlgorithm, generateSalt, or hash. These are pure implementation details.

Use protected When:

Subclasses need access but external code doesn't:

class Vehicle {
  protected engineOn: boolean = false;
  public brand: string;
 
  constructor(brand: string) {
    this.brand = brand;
  }
 
  protected startEngine(): void {
    this.engineOn = true;
    console.log("Engine started");
  }
 
  protected stopEngine(): void {
    this.engineOn = false;
    console.log("Engine stopped");
  }
}
 
class Car extends Vehicle {
  public drive(): void {
    if (!this.engineOn) {
      this.startEngine(); // ✅ Subclass can access protected method
    }
    console.log("Driving...");
  }
 
  public park(): void {
    console.log("Parking...");
    this.stopEngine(); // ✅ Subclass can access protected method
  }
}
 
const car = new Car("Toyota");
car.drive(); // Engine started, Driving...
car.park(); // Parking..., Engine stopped
 
// car.startEngine(); // ❌ Error: Method 'startEngine' is protected

Subclasses need startEngine and stopEngine, but external code shouldn't call them directly—they should use drive and park.

Use public When:

The member is part of the class's public API:

class ShoppingCart {
  public items: string[] = [];
  private total: number = 0;
 
  public addItem(item: string, price: number): void {
    this.items.push(item);
    this.total += price;
  }
 
  public getTotal(): number {
    return this.total;
  }
 
  public clear(): void {
    this.items = [];
    this.total = 0;
  }
}

items, addItem, getTotal, and clear are meant to be used by external code—they're the cart's public interface. total is private because it's managed internally.

Access Modifiers in Constructors

You can apply access modifiers to constructor parameters to automatically create and initialize properties:

class Product {
  // Traditional way
  private id: number;
  private name: string;
  public price: number;
 
  constructor(id: number, name: string, price: number) {
    this.id = id;
    this.name = name;
    this.price = price;
  }
}
 
// Shorthand with parameter properties
class ProductShorthand {
  constructor(
    private id: number,
    private name: string,
    public price: number
  ) {
    // Properties automatically created and assigned
  }
}
 
const product = new ProductShorthand(1, "Laptop", 1200);
console.log(product.price); // ✅ 1200 (public)
// console.log(product.id); // ❌ Error: Property 'id' is private
// console.log(product.name); // ❌ Error: Property 'name' is private

This shorthand is incredibly common in TypeScript—it reduces boilerplate while maintaining access control.

Runtime vs Compile-Time

Critical understanding: TypeScript access modifiers are compile-time only. They're erased when compiled to JavaScript:

class Secret {
  private apiKey: string;
 
  constructor(apiKey: string) {
    this.apiKey = apiKey;
  }
}
 
const secret = new Secret("super-secret-key");
// secret.apiKey; // ❌ TypeScript error: Property 'apiKey' is private

But in the compiled JavaScript:

class Secret {
  constructor(apiKey) {
    this.apiKey = apiKey;
  }
}
 
const secret = new Secret("super-secret-key");
console.log(secret.apiKey); // ✅ Works in JavaScript! No privacy at runtime

Security Notice

TypeScript access modifiers are compile-time only and provide no runtime security. They're erased in the compiled JavaScript. For true runtime privacy, use JavaScript's #privateField syntax or keep sensitive data server-side.

Implications:

  1. Access modifiers protect you during development (TypeScript compilation)
  2. They don't protect against runtime access (someone can bypass them in JavaScript)
  3. For true runtime privacy, use JavaScript private fields (#propertyName)
  4. TypeScript access modifiers are about code organization and compile-time safety, not security

True Runtime Privacy with # (ECMAScript Private Fields)

TypeScript supports ECMAScript private fields for actual runtime privacy:

class SecureVault {
  #secretCode: string; // True private field
 
  constructor(secretCode: string) {
    this.#secretCode = secretCode;
  }
 
  public verify(code: string): boolean {
    return code === this.#secretCode;
  }
}
 
const vault = new SecureVault("1234");
console.log(vault.verify("1234")); // true
// console.log(vault.#secretCode); // ❌ Syntax error even in JavaScript

The # syntax creates truly private fields that can't be accessed outside the class, even at runtime. However, private keyword is more common and integrates better with TypeScript's type system.

Best Practices

1. Start Private, Make Public When Needed

Default to private for new members. Make them protected or public only when you have a specific reason:

class UserManager {
  private users: Map<string, User> = new Map();
  private nextId: number = 1;
 
  public addUser(name: string, email: string): User {
    const id = this.nextId++;
    const user = new User(id, name, email);
    this.users.set(id.toString(), user);
    return user;
  }
 
  public getUser(id: string): User | undefined {
    return this.users.get(id);
  }
 
  // Started private, only expose if actually needed
  // private getAllUsers(): User[] { ... }
  // private deleteUser(id: string): boolean { ... }
}

This principle maintains encapsulation and gives you flexibility to change implementation details later.

Refactoring Tip

If you're unsure whether a member should be public or private, start with private. It's easy to make something more accessible later, but making something less accessible is a breaking change that affects all existing code using your class.

2. Use Protected for Extensibility

If you're building a class library that others will extend, use protected for members that subclasses might need:

abstract class HttpClient {
  protected baseURL: string;
  protected timeout: number = 30000;
 
  constructor(baseURL: string) {
    this.baseURL = baseURL;
  }
 
  protected buildURL(endpoint: string): string {
    return `${this.baseURL}${endpoint}`;
  }
 
  public abstract request(endpoint: string): Promise<any>;
}
 
class RestClient extends HttpClient {
  public async request(endpoint: string): Promise<any> {
    const url = this.buildURL(endpoint); // ✅ Can use protected method
    // Fetch logic...
    return fetch(url, { timeout: this.timeout }); // ✅ Can use protected property
  }
}

3. Document Why Members Are Private/Protected

Comments help future maintainers understand access decisions:

class FileProcessor {
  // Private: Internal buffer not meant for external access
  // Use addData() instead to ensure proper validation
  private buffer: string[] = [];
 
  // Protected: Subclasses may override validation logic
  protected validate(data: string): boolean {
    return data.length > 0 && data.length < 1000;
  }
 
  // Public: Primary method for adding data
  public addData(data: string): boolean {
    if (!this.validate(data)) {
      return false;
    }
    this.buffer.push(data);
    return true;
  }
}

4. Validate in Public Methods

Public methods are the entry points—validate inputs there:

class Temperature {
  private celsius: number;
 
  constructor(celsius: number) {
    this.setCelsius(celsius); // Use validation method
  }
 
  public setCelsius(value: number): void {
    if (value < -273.15) {
      throw new Error("Temperature below absolute zero");
    }
    this.celsius = value;
  }
 
  public getCelsius(): number {
    return this.celsius;
  }
 
  public setFahrenheit(value: number): void {
    const celsius = ((value - 32) * 5) / 9;
    this.setCelsius(celsius); // Reuse validation
  }
}

Common Mistakes

Mistake 1: Making Everything Public

// ❌ Bad: Everything public
class Database {
  public connection: any;
  public queries: string[];
  public cache: Map<string, any>;
 
  public connect() {
    /* ... */
  }
  public disconnect() {
    /* ... */
  }
  public executeQuery(sql: string) {
    /* ... */
  }
  public clearCache() {
    /* ... */
  }
}
 
// ✅ Better: Hide implementation details
class DatabaseGood {
  private connection: any;
  private queries: string[] = [];
  private cache: Map<string, any> = new Map();
 
  public connect() {
    /* ... */
  }
  public disconnect() {
    /* ... */
  }
  public executeQuery(sql: string) {
    /* ... */
  }
 
  // Expose only what's needed
  public getQueryHistory(): string[] {
    return [...this.queries]; // Return copy
  }
}

Mistake 2: Exposing Mutable State

// ❌ Bad: Returning reference to private array
class TodoList {
  private items: string[] = [];
 
  public getItems(): string[] {
    return this.items; // Returns reference!
  }
}
 
const list = new TodoList();
const items = list.getItems();
items.push("Hack the system"); // Modifies private array!
 
// ✅ Better: Return a copy
class TodoListGood {
  private items: string[] = [];
 
  public getItems(): string[] {
    return [...this.items]; // Return copy
  }
 
  public getItemCount(): number {
    return this.items.length; // Or provide specific methods
  }
}

Mistake 3: Overly Restrictive Access

// ❌ Too restrictive: Subclasses can't access
class BaseComponent {
  private state: any = {};
 
  private setState(newState: any): void {
    this.state = { ...this.state, ...newState };
  }
}
 
class Button extends BaseComponent {
  public click(): void {
    // this.setState({ clicked: true }); // ❌ Error: 'setState' is private
  }
}
 
// ✅ Better: Use protected for extensibility
class BaseComponentGood {
  protected state: any = {};
 
  protected setState(newState: any): void {
    this.state = { ...this.state, ...newState };
  }
}
 
class ButtonGood extends BaseComponentGood {
  public click(): void {
    this.setState({ clicked: true }); // ✅ Works
  }
}

What's Next

You now understand how to control access to class members with public, private, and protected. In the next posts, you'll learn:

  • Getters and setters for controlled property access
  • Static members for class-level properties and methods
  • Abstract classes that enforce structure in inheritance hierarchies
  • Class inheritance and the super keyword

Key Takeaways

Access modifiers give you:

✅ Encapsulation through controlled access to class internals ✅ Data integrity by preventing invalid state modifications ✅ Maintainability by hiding implementation details ✅ Flexibility to change internals without breaking external code ✅ Clear interfaces that show what's meant to be used publicly ✅ Inheritance support with protected members for subclasses

Use private by default, protected when subclasses need access, and public for your class's API. This discipline leads to more robust, maintainable code that's easier to refactor and extend over time.