Access Modifiers (public, private, protected)

In object-oriented programming, encapsulation is one of the key principles for building robust and maintainable systems. While languages like JavaScript and TypeScript do not enforce private members through strict syntax sugar as Java or C++ does, they provide flexible exposure strategies via the concept of "access modifiers." These concepts—public (public), private (private), and protected (protected)—not only define the internal structural boundaries of a class but also serve as foundational pillars for understanding TypeScript's type system, code maintainability, and future evolution directions.
First and foremost, it is crucial to clarify that in native JavaScript, access modifiers are not keywords; instead, they rely on naming conventions: properties starting with a single underscore _ are typically considered private (internal), while those prefixed with double underscores __ follow language specifications for renaming internal fields. However, TypeScript introduces a stricter and more explicit semantic model.

In TypeScript, class members default to being public. This means that if no modifier is specified when declaring a method or property, it is fully visible and accessible to external code. For example:

class User {
    name: string; // Defaults to public
    
    greet() { }   // Defaults to public
}

To exert finer control over the scope of exposure, TypeScript provides three keywords: private, protected, and public (which can be abbreviated as +). Their default behaviors are as follows:

  • public (+): Accessible from within the class and by all external code. This is the most permissive strategy.
  • private (-): Accessible only to instance methods of the current class; subclasses cannot inherit or modify these members, achieving strict encapsulation.
  • protected: (Note: In TypeScript 3.4+, it is recommended to use # for private fields syntax, but in traditional modifier contexts, this refers to protected access) Accessible within the class and by all subclasses (including derived classes extended via interfaces), but invisible to external code.

Application Strategies and Examples Across Different Scenarios

The choice of an appropriate access level depends on your design goals. If you wish to hide implementation details to prevent misuse or destructive modifications, use private; if rewriting and extending functionality within the inheritance hierarchy is allowed without direct calls by third-party libraries, protected is suitable.

1. Application Scenarios for Private Members

When helper methods exist within a class or temporary state needs management, setting them as private is best practice. This ensures that external logic cannot bypass core rules to manipulate this intermediate data directly.

class BankAccount {
    // Defaults to public: anyone can modify the balance leading to negative numbers
    balance = 0; 
    
    constructor(private amount: number) {}
    
    deposit(amount: number): void {
        this.balance += amount;
        
        // private helper method, accessible only by this class
        _validateAmount(amount); 
    }

    // Recommended TypeScript 3.4+ private field syntax (compiles to strong encapsulation in practice)
    #privateData: string = "secret"; 
    
    getBalance(): number {
        return this.balance;
    }
}

// External code cannot directly access private members or call _validateAmount
const acc = new BankAccount(100); 
acc._validateAmount(50); // TypeScript compilation error: Property '_validateAmount' does not exist on type 'BankAccount'

2. Inheritance Design Patterns for Protected Members

protected members are commonly used to implement polymorphic behavior. Subclasses need the ability to override parent logic, but external code should not directly intervene in this process. For instance, in a graphical drawing system, different shape classes may require sharing certain calculation steps.

class Shape {
    protected color: string; // Accessible and overridable by subclasses
    
    draw(): void {
        console.log(`Drawing a ${this.color} shape`);
    }
    
    setColor(color: string) { this.color = color; }
}

// External code cannot directly modify the Shape's color, but Rectangle can inherit and extend it
class Rectangle extends Shape {
    width: number; height: number;
    
    constructor(w: number, h: number, c: string) {
        super(); // Call parent class constructor if exists
        this.width = w; 
        this.height = h; 
        this.color = c; // Directly set protected property
    }

    draw(): void {
        console.log(`Drawing a ${this.color} rectangle`);
        super.draw(); // Invoke parent implementation
    }
    
    setDimensions(w: number, h: number): void { 
        this.width = w; 
        this.height = h; 
    }
}

const rect = new Rectangle(10, 20, "blue"); 
rect.setColor("red"); // ✅ Allowed: Subclasses can modify protected properties
// rect.color = "green"; ❌ TypeScript compilation error: Property 'color' does not exist on type 'Rectangle' (unless explicitly declared)

Modern TypeScript Private Fields Syntax (#private fields)

It is worth noting that TypeScript version 3.4 introduced the #-prefixed private field syntax (Private Fields), which offers stronger semantic guarantees compared to traditional - modifiers. Members defined using #fieldName cannot be accessed via inheritance, nor can they even be read or modified directly by parent classes unless initialized within a constructor.

class Config {
    #version: string = "1.0"; // True private field
    
    updateVersion(newVer: string): void { 
        this.#version = newVer; // ✅ Accessible internally
    }
    
    getVersion(): string { return this.#version; }
}

class LegacyConfig extends Config {
    #legacyData: number; 

    constructor() { super(); this.#legacyData = 0; }
    
    updateVersion(newVer: string): void { 
        // ❌ TypeScript compilation error: Property '#version' does not exist on type 'LegacyConfig'
        // this.#version = newVer; 
        
        // Must access via public getter/setter or internal method
        super.updateVersion(newVer); 
    }
}

const cfg = new LegacyConfig(); 
cfg.getVersion(); // ✅ OK: Returns "1.0" (if unmodified)
// cfg.#version = "2.0"; ❌ Compilation error: Cannot directly access #private fields

Summary and Best Practice Recommendations

Mastering the distinctions between public, private, and protected helps developers design class structures with clear boundaries and well-defined responsibilities. In actual development, adhere to the following principles: default to public; set helper logic as private; use protected appropriately when polymorphic collaboration is needed; for TypeScript 3.4+ projects, prioritize using # private field syntax for stronger encapsulation guarantees. This clear access control strategy not only reduces runtime errors but also significantly enhances code readability and maintainability.