Skip to main content

TS Ep 17: Access Modifiers & Hard Private Fields (#)

Rachmat Hidayat
Author
Rachmat Hidayat
Learn & sharing insights on TypeScript, Go, Kubernetes, DevOps, DevSecOps, SRE, Platform Engineering, AI/ML Engineering, and MLOps.
typescript - This article is part of a series.
Part 17: This Article
Encapsulation is a core pillar of Object-Oriented Programming. It hides internal implementation details and protects internal state from unauthorized modification. TypeScript provides compile-time access modifiers (public, private, protected), while modern JavaScript offers native, runtime-enforced private fields (#).

1. The Access Modifier Visibility Matrix
#

TypeScript provides three access keywords that control the visibility of class fields, methods, getters, setters, and constructors:

Access ModifierInside Defining ClassInside Subclasses (extends)Outside Instances (new)
public (Default)✅ Allowed✅ Allowed✅ Allowed
protected✅ Allowed✅ Allowed❌ Blocked by Compiler
private✅ Allowed❌ Blocked by Compiler❌ Blocked by Compiler

2. In Action: public, protected, and private
#

Let’s model a banking domain to observe how each modifier operates across base classes, subclasses, and external callers:

class BankAccount {
  // 1. PUBLIC: Accessible everywhere (Default visibility if omitted)
  public readonly accountNumber: string;

  // 2. PROTECTED: Accessible inside BankAccount AND any subclasses (like SavingsAccount)
  protected accountStatus: "active" | "frozen" | "closed";

  // 3. PRIVATE: Accessible ONLY inside BankAccount methods
  private balance: number;

  constructor(accountNumber: string, initialBalance: number) {
    this.accountNumber = accountNumber;
    this.balance = initialBalance;
    this.accountStatus = "active";
  }

  public deposit(amount: number): number {
    if (this.accountStatus === "frozen") {
      throw new Error("Cannot deposit to a frozen account.");
    }
    this.balance += amount;
    return this.balance;
  }

  private logTransaction(type: string, amount: number) {
    console.log(`[AUDIT]: ${type} of $${amount} on account ${this.accountNumber}`);
  }
}

// Subclass extending BankAccount
class SavingsAccount extends BankAccount {
  private interestRate: number;

  constructor(accountNumber: string, initialBalance: number, interestRate: number) {
    super(accountNumber, initialBalance);
    this.interestRate = interestRate;
  }

  public applyInterest() {
    // 🟢 WORKED: Can access 'protected' member 'accountStatus' from parent class!
    if (this.accountStatus !== "active") {
      throw new Error("Account is not active.");
    }

    // ❌ Compiler Error: Property 'balance' is private and only accessible within class 'BankAccount'.
    // this.balance += this.balance * this.interestRate;
  }
}

External Caller Checks
#

const account = new BankAccount("ACC_1001", 500);

// 1. PUBLIC: Accessible externally
console.log(account.accountNumber); // "ACC_1001"
account.deposit(250);               // 750

// 2. PROTECTED: Blocked externally!
// ❌ Compiler Error: Property 'accountStatus' is protected and only accessible within class 'BankAccount' and its subclasses.
// account.accountStatus = "frozen";

// 3. PRIVATE: Blocked externally!
// ❌ Compiler Error: Property 'balance' is private and only accessible within class 'BankAccount'.
// console.log(account.balance);

3. Compile-Time Soft Privacy vs Runtime Hard Privacy
#

A common misconception among developers transitioning to TypeScript is assuming private makes a property truly secret at runtime.

Important

Soft Privacy Rule: TypeScript’s private and protected keywords are Soft Privacy assertions. They exist only at compile time and undergo Type Erasure during compilation.

The JavaScript Emission Reality
#

When tsc compiles BankAccount to JavaScript, the access keywords are completely erased:

// Compiled JavaScript Output (dist/BankAccount.js)
class BankAccount {
    constructor(accountNumber, initialBalance) {
        this.accountNumber = accountNumber;
        this.balance = initialBalance; // Plain JavaScript property!
        this.accountStatus = "active";
    }
    deposit(amount) { ... }
}

If a plain JavaScript consumer imports your library, or if an engineer uses dynamic bracket notation (account["balance"]), they can read and modify private properties at runtime:

// Bypassing TypeScript compile-time check using bracket notation:
const secretBalance = (account as any)["balance"]; // Returns 750 at runtime!

4. Hard Private Fields (#privateField)
#

To solve the limitations of soft privacy, ECMAScript (ES2022) introduced Native Private Fields, designated by a # prefix.

Unlike TypeScript’s private keyword, # fields provide Hard Privacy enforced natively by JavaScript runtime engines (V8, SpiderMonkey, JavaScriptCore).

class SecureVault {
  // Hard private field (Native ES2022 feature)
  #masterEncryptionKey: string;

  constructor(key: string) {
    this.#masterEncryptionKey = key;
  }

  public getMaskedKey(): string {
    return `***${this.#masterEncryptionKey.slice(-4)}`;
  }
}

const vault = new SecureVault("sk_live_998811223344");

console.log(vault.getMaskedKey()); // "***3344"

// ❌ SyntaxError: Property '#masterEncryptionKey' is not accessible outside class 'SecureVault'.
// Even if you try bracket notation or 'as any', JavaScript's V8 engine CRASHES at runtime:
// (vault as any)["#masterEncryptionKey"]; // Returns undefined!
// (vault as any).#masterEncryptionKey;   // Hard Syntax Error!

5. Comparison: private vs # Private Fields
#

Featureprivate field (TypeScript)#field (ES2022 Native)
Enforcement LayerCompile-Time (TypeScript)Runtime (Browser/Node Engine)
Subclass AccessibilityInaccessible in subclassesInaccessible in subclasses
Bracket Bypass (obj["prop"])Bypassed at runtimeImpossible (Returns undefined or syntax error)
Compiled OutputStandard property this.propPreserves #prop (or uses WeakMap polyfills)
Performance OverheadZero (Plain property)Microscopic (WeakMap lookup overhead in older targets)
Declaration Syntaxprivate balance: number#balance: number

Summary & Next Steps
#

In this episode:

  • We analyzed the Visibility Matrix for public, protected, and private members.
  • We demonstrated inheritance rules (protected is accessible in subclasses, private is not).
  • We uncovered Soft Privacy: TypeScript access modifiers undergo type erasure and do not prevent runtime access.
  • We implemented Hard Privacy using native ES2022 #privateField syntax.

In Episode 18: Parameter Properties, we will learn TypeScript’s shorthand syntax for declaring and initializing constructor fields in a single line!

typescript - This article is part of a series.
Part 17: This Article