Siddhant Deval
Siddhant Deval
system design18 min read

Class Internals, Memory Layout & Access Encapsulation

TypeScript classes are dual-natured: compile-time type declarations and runtime prototype functions. This article dissects memory layout, access boundary semantics, and initialization order — the foundational knowledge that separates engineers who write correct encapsulation from those who accidentally expose internal state to reflection and external mutation.

Series·Part 1 of 13

TypeScript Low-Level Design & Object-Oriented Architecture

Class Internals, Memory Layout & Access Encapsulation

In TypeScript 5+, OOP is an architectural contract, not an inheritance tree: enforce domain invariants at compile time, encapsulate mutation strictly within aggregates, and invert dependencies so high-level business policy never couples to execution details. That principle starts here — at the level of a single class declaration.

Most engineers treat a TypeScript class as a fancy object literal. It is not. A class is a dual-natured entity: at compile time it describes a structural type, and at runtime it emits a prototype-chained constructor function into the JavaScript heap. Understanding both dimensions is the prerequisite for everything that follows in this series — from aggregate roots to dependency injection to concurrency guards. Get the class wrong and every abstraction built on top of it is built on sand.


1. The Anti-Pattern Graveyard: The Hollow Data-Bag Class

Here is the pattern every 3-to-5 year engineer has written in a Node.js service at least once:

TYPESCRIPT
// ❌ Anti-Pattern: Hollow data-bag — public mutation with no invariant protection
class BankAccount {
  public id: string;
  public balance: number;
  public currency: string;
  public ownerId: string;

  constructor(id: string, balance: number, currency: string, ownerId: string) {
    this.id = id;
    this.balance = balance;
    this.currency = currency;
    this.ownerId = ownerId;
  }
}

// Caller code — no barriers whatsoever
const account = new BankAccount('acc_001', 1000, 'USD', 'usr_042');
account.balance = -99999;        // Silent corruption — bypasses all business rules
account.currency = 'MONOPOLY';   // Nonsense value accepted without complaint
console.log(Object.keys(account)); // ['id', 'balance', 'currency', 'ownerId'] — fully exposed

This class does nothing. It stores data and exposes everything. Any caller anywhere in the codebase can set balance to a negative number, swap the currency, or reflect over the instance to extract every field. There are no invariants, no boundaries, no architecture — only a structured bag of mutable public properties.

Every concept in this article is a systematic response to one or more of these three failures: no runtime encapsulation, no initialization guarantees, and no memory awareness.


2. Classes as Dual-Natured Entities

2.1 What the TypeScript Compiler Actually Emits

Write this TypeScript class:

TYPESCRIPT
class LedgerEntry {
  readonly entryId: string;
  private readonly amount: number;

  constructor(entryId: string, amount: number) {
    this.entryId = entryId;
    this.amount = amount;
  }

  getAmount(): number {
    return this.amount;
  }
}

The TypeScript compiler erases all type annotations and emits this JavaScript:

JAVASCRIPT
// ✅ What V8 actually executes — no types, no private, no readonly
"use strict";
class LedgerEntry {
  constructor(entryId, amount) {
    this.entryId = entryId;
    this.amount = amount;   // ← "private" is completely gone
  }
  getAmount() {
    return this.amount;
  }
}

The private keyword, readonly, and all TypeScript access modifiers do not exist at runtime. They are compile-time assertions that the TypeScript checker enforces while you write code, then discards before handing the file to V8.

Crucial Requirement

TypeScript's access modifiers are a linter for your team — not a security boundary. Once compiled, every field labeled private is a plain property accessible via any standard JavaScript runtime API.

2.2 The Compile-Time Type vs. The Runtime Constructor

A TypeScript class declaration simultaneously produces:

Dimension What It Creates Lives Where
Compile-time A structural type describing the instance shape TypeScript type checker (erased at emit)
Runtime A constructor function on the prototype chain V8 JavaScript heap

This duality explains an otherwise confusing behavior: you can use a class name as both a type annotation (const entry: LedgerEntry) and a new expression (new LedgerEntry()). Most nominal OOP languages keep these completely separate concepts.


3. Compile-Time private vs. ECMAScript #private

This is the most consequential distinction in TypeScript class design. Entire audit systems have been built on the false assumption that TypeScript's private provides genuine runtime isolation.

3.1 TypeScript private Is Fully Transparent at Runtime

TYPESCRIPT
class AccountBalance {
  private balance: number = 0;

  deposit(amount: number): void {
    this.balance += amount;
  }
}

const acct = new AccountBalance();
acct.deposit(500);

// TypeScript compiler rejects this line:
// acct.balance  ← TS Error: Property 'balance' is private

// But at runtime, every JavaScript technique works:
console.log((acct as any).balance);           // 500 — type assertion bypasses TS
console.log(Object.keys(acct));                // ['balance'] — fully enumerable
console.log(JSON.stringify(acct));             // {"balance":500} — serialized in plain JSON
console.log(Reflect.ownKeys(acct));            // ['balance'] — visible to reflection

Serializing an AccountBalance instance to JSON includes the balance field. Any middleware, logger, or serialization layer that touches the instance will expose it. In a fintech audit context, that is a data leakage vulnerability.

3.2 ECMAScript #private Provides Genuine V8 Heap Isolation

The ECMAScript specification introduced native private class fields via the # prefix. These are not syntax sugar — they create genuine PrivateName slots in the V8 heap that are inaccessible to all standard JavaScript APIs.

TYPESCRIPT
class AccountBalance {
  #balance: number = 0;   // ECMAScript native private field

  deposit(amount: number): void {
    this.#balance += amount;
  }

  getBalance(): number {
    return this.#balance;
  }
}

const acct = new AccountBalance();
acct.deposit(500);

// TypeScript rejects access attempts:
// acct.#balance  ← SyntaxError at parse time

// At runtime, all bypass techniques fail hard:
console.log((acct as any)['#balance']);        // undefined — PrivateName is not a string key
console.log(Object.keys(acct));                // [] — not enumerable
console.log(JSON.stringify(acct));             // {} — not serialized
console.log(Reflect.ownKeys(acct));            // [] — not reflected

The difference is structural: TypeScript private is a naming convention enforced by the compiler. ECMAScript #private is a capability enforced by the V8 runtime object model.

Two-column diagram with dark background. LEFT column labeled 'TypeScript private (Compile-Time Only)' in red: shows a class box with 'private balance: number' in dim text, then three arrows pointing out labeled 'Object.keys() → [balance]', 'JSON.stringify() → {"balance":500}', 'Reflect.ownKeys() → [balance]'. All three arrows are red with the label 'EXPOSED'. RIGHT column labeled 'ECMAScript #private (V8 Heap Isolation)' in cyan: shows a class box with '#balance: number' in cyan text, then three arrows pointing out to blocked walls labeled 'Object.keys() → []', 'JSON.stringify() → {}', 'Reflect.ownKeys() → []'. All three are green with the label 'BLOCKED'. A dividing line in amber separates the columns. Title at top: 'TypeScript private vs ECMAScript #private — Runtime Behavior'.
Two-column diagram with dark background. LEFT column labeled 'TypeScript private (Compile-Time Only)' in red: shows a class box with 'private balance: number…
Performance / Safety Warning

Do not rely on TypeScript private for sensitive domain data such as balances, credentials, or encryption keys. Use ECMAScript #private fields whenever the field must be invisible to serializers, loggers, and reflective middleware.

3.3 The #private Trade-Off Table

Characteristic TypeScript private ECMAScript #private
Runtime isolation None — plain property Full — V8 PrivateName slot
Object.keys() Visible Not visible
JSON.stringify() Serialized Omitted
Parameter properties constructor(private x: T) ✅ Not supported
Proxy traps Interceptable Not interceptable
instanceof checks Not affected Not affected
Interview sandbox Works everywhere Node 12+ / V8 7.2+

For the fintech domain in this series, the decision rule is: if a field represents a monetary value, an account identifier, or internal invariant state, use #private. Use TypeScript private only for fields where compiler-level protection is sufficient and interoperability with proxies or parameter properties is needed.


4. Parameter Properties & Initialization Order

4.1 Parameter Properties Are Ergonomic Shorthand

TypeScript's parameter property syntax eliminates the boilerplate of declaring a field and then assigning it in the constructor body:

TYPESCRIPT
// ❌ Verbose — three lines of repetition per field
class OrderLine {
  private readonly productId: string;
  private quantity: number;
  private unitPrice: number;

  constructor(productId: string, quantity: number, unitPrice: number) {
    this.productId = productId;
    this.quantity = quantity;
    this.unitPrice = unitPrice;
  }
}

// ✅ Parameter properties — declares and assigns in one expression
class OrderLine {
  constructor(
    private readonly productId: string,
    private quantity: number,
    private unitPrice: number,
  ) {}

  lineTotal(): number {
    return this.quantity * this.unitPrice;
  }
}

Both versions emit identical JavaScript. The parameter property version is a compile-time transformation only. Note, however, that parameter properties are incompatible with ECMAScript #private fields — if you need genuine runtime isolation you must declare and assign #fields explicitly.

4.2 Initialization Order in Inheritance Chains

The JavaScript specification defines a strict initialization sequence for class hierarchies. Getting this wrong causes undefined field values mid-constructor — a bug that only appears at runtime under specific inheritance patterns.

TYPESCRIPT
class Account {
  protected readonly accountId: string;
  protected balance: number;

  constructor(accountId: string, initialBalance: number) {
    this.accountId = accountId;
    this.balance = initialBalance;
    console.log(`[Account] constructor — balance: ${this.balance}`);
  }
}

class SavingsAccount extends Account {
  // 1. Field initializer — runs AFTER super() returns, BEFORE constructor body
  private readonly interestRate: number = 0.045;

  constructor(accountId: string, initialBalance: number) {
    super(accountId, initialBalance); // ← 2. Base class constructor runs here
    // 3. Derived constructor body — interestRate is already set to 0.045
    console.log(`[Savings] constructor — rate: ${this.interestRate}`);
  }

  applyInterest(): void {
    this.balance += this.balance * this.interestRate;
  }
}

const savings = new SavingsAccount('acc_007', 10_000);
// Output:
// [Account] constructor — balance: 10000
// [Savings] constructor — rate: 0.045

The precise initialization order for derived classes is:

  1. Base class field initializers (from the base class body)
  2. Base class constructor body (super(...) call)
  3. Derived class field initializers (from the derived class body — this is the critical step)
  4. Derived class constructor body
Performance / Safety Warning

If a base class constructor calls a virtual method that the derived class overrides, and that method accesses a derived class field, the field will be undefined at the time of the call — because step 3 has not yet run. This is the canonical "calling overridden virtual method in constructor" footgun.

TYPESCRIPT
class Base {
  constructor() {
    this.validate(); // ← Called during super(), before derived field initializers run
  }
  validate(): void {}
}

class Derived extends Base {
  // Field initializer runs AFTER super() completes
  private readonly limit: number = 1000;

  override validate(): void {
    // ❌ this.limit is undefined here — super() hasn't finished yet
    if (this.limit < 0) throw new Error('Invalid limit');
  }
}

new Derived(); // No error thrown — limit is undefined, not < 0

5. Prototype Methods vs. Arrow Function Class Fields

This distinction has a direct, measurable impact on memory consumption in high-throughput services that instantiate large numbers of objects.

5.1 Prototype Methods — Shared Heap Reference

When you declare a method in the class body normally, it lives on the class prototype. Every instance of the class holds a pointer to the same prototype object — there is exactly one function in memory regardless of how many instances exist.

TYPESCRIPT
class TransactionProcessor {
  private readonly processorId: string;

  constructor(processorId: string) {
    this.processorId = processorId;
  }

  // ✅ Prototype method — allocated once on the prototype, shared by all instances
  process(amount: number): string {
    return `[${this.processorId}] processed ${amount}`;
  }
}

const p1 = new TransactionProcessor('proc_001');
const p2 = new TransactionProcessor('proc_002');

// Both instances share the exact same function object
console.log(p1.process === p2.process); // true — same V8 heap reference

5.2 Arrow Function Class Fields — Per-Instance Closure

When you declare a method as an arrow function assigned to a class field, the function is allocated anew for every instance. This lexically binds this — which is the common reason engineers choose this pattern — but at the cost of duplicating the function object on the heap for each instance.

TYPESCRIPT
class TransactionProcessor {
  private readonly processorId: string;

  constructor(processorId: string) {
    this.processorId = processorId;
  }

  // ❌ Arrow function field — new closure allocated per instance
  process = (amount: number): string => {
    return `[${this.processorId}] processed ${amount}`;
  };
}

const p1 = new TransactionProcessor('proc_001');
const p2 = new TransactionProcessor('proc_002');

// Each instance has its own separate function closure
console.log(p1.process === p2.process); // false — different heap objects

5.3 Measuring the Memory Impact

In a payment engine that creates 100,000 TransactionProcessor instances per minute, the difference is not academic:

TYPESCRIPT
// Prototype method version: memory per instance
// = fixed object overhead (~56 bytes on V8 x64)
// + string for processorId
// + prototype pointer (shared — not duplicated)

// Arrow function field version: memory per instance
// = fixed object overhead (~56 bytes)
// + string for processorId
// + NEW function closure object (~128 bytes) ← duplicated 100,000 times

// Approximate extra heap cost for 100,000 instances:
// 100,000 × 128 bytes ≈ 12.8 MB of additional closure allocations
Two-panel memory diagram with dark background. LEFT panel labeled 'Prototype Method Pattern' in cyan: shows a stack of 5 instance boxes (inst_1 through inst_5) each containing only 'processorId: string'. A single arrow from each instance points right to a shared 'Prototype' box containing 'process: Function (×1)'. The word 'SHARED' appears in green. RIGHT panel labeled 'Arrow Function Field Pattern' in red: shows a stack of 5 instance boxes (inst_1 through inst_5) each containing 'processorId: string' AND 'process: Function (×1)' inline. Five separate function boxes appear, each labeled '~128 bytes'. The word 'DUPLICATED' appears in red. Annotation at the bottom: '100,000 instances → +12.8 MB heap allocation'.
Two-panel memory diagram with dark background. LEFT panel labeled 'Prototype Method Pattern' in cyan: shows a stack of 5 instance boxes (inst_1 through inst_…
Pro Tip & Optimization

Use prototype methods by default. Reserve arrow function class fields exclusively for cases where the method is passed as a callback to a third-party API and this binding cannot be guaranteed — for example, element.addEventListener('click', this.handleClick). Even then, prefer .bind(this) in the constructor for prototype methods, or extract the callback to a module-level function.

5.4 Cross-Language Rosetta: Prototype vs. vtable

Concept TypeScript / JavaScript Go Python
Shared method allocation Prototype chain (one function per class) Method in struct definition (compiled) Class __dict__ method (one per class)
Per-instance method Arrow function field (closure per instance) No direct equivalent self.method = lambda: ... in __init__
this binding Lexical this via arrow field or .bind() First param receiver func (t *T) First param self

6. Getters, Setters & Synchronous Invariant Enforcement

Property accessors provide a controlled interception point between field reads and writes. Used correctly, they are a clean mechanism for synchronous invariant assertions. Used carelessly, they introduce invisible side effects that make debugging nightmarish.

6.1 Synchronous Invariant Assertion

TYPESCRIPT
class MonetaryBalance {
  #cents: number;

  constructor(initialCents: number) {
    // Invariant enforced at construction time
    if (initialCents < 0) throw new RangeError('Balance cannot be negative');
    this.#cents = initialCents;
  }

  get cents(): number {
    return this.#cents;
  }

  set cents(value: number) {
    // ✅ Invariant enforced at every write — no caller can bypass this
    if (value < 0) throw new RangeError(`Cannot set balance to ${value} cents`);
    if (!Number.isInteger(value)) throw new TypeError('Balance must be whole cents');
    this.#cents = value;
  }

  get dollars(): number {
    // Derived property — computed from source of truth, never stored separately
    return this.#cents / 100;
  }
}

const balance = new MonetaryBalance(50000); // $500.00
balance.cents = -1;   // ❌ Throws RangeError: Cannot set balance to -1 cents
balance.cents = 50.5; // ❌ Throws TypeError: Balance must be whole cents
balance.cents = 75000; // ✅ Sets to $750.00
Crucial Requirement

Getters and setters must be synchronous and side-effect free. They should perform pure validation or compute derived values — never trigger network calls, dispatch events, or mutate other objects. Violations make the class unpredictable: any line reading account.balance could silently kick off an HTTP request.

6.2 The Async Accessor Anti-Pattern

TYPESCRIPT
// ❌ NEVER do this — async getter is a footgun
class AccountView {
  private accountId: string;

  constructor(accountId: string) {
    this.accountId = accountId;
  }

  // This getter returns a Promise, not a number — callers must know to await it
  // but the syntax `account.balance` looks synchronous, so they often don't
  get balance(): Promise<number> {
    return fetch(`/api/accounts/${this.accountId}`)
      .then(r => r.json())
      .then(data => data.balance);
  }
}

// Caller is fooled by the synchronous-looking syntax
const account = new AccountView('acc_001');
console.log(account.balance); // Logs: Promise { <pending> } — not a number

The invariant for the entire fintech ledger domain in this series: getters compute, they never fetch. Use explicit async methods for any operation that requires I/O.


7. Static Members & Static Initialization Blocks

7.1 Class-Level State and Initialization

Static members belong to the class constructor itself, not to any instance. They are initialized once when the module is first loaded and shared across all instances for the lifetime of the process.

TYPESCRIPT
class LedgerConfig {
  // Static field — class-level constant, not per-instance
  static readonly MAX_TRANSACTION_AMOUNT_CENTS = 10_000_000; // $100,000.00

  // Static block (TS 4.9+ / ES2022) — executes once on first module load
  static {
    const envMax = parseInt(process.env.MAX_TX_CENTS ?? '');
    if (!isNaN(envMax) && envMax > 0) {
      // Mutating a readonly static inside a static block is the only allowed escape hatch
      (LedgerConfig as any).MAX_TRANSACTION_AMOUNT_CENTS = envMax;
    }
    console.log(`[LedgerConfig] initialized — cap: ${LedgerConfig.MAX_TRANSACTION_AMOUNT_CENTS} cents`);
  }
}
Architectural Note

Static initialization blocks (static { ... }) execute in module-load order — once and only once per process lifetime. They are the correct location for environment-driven configuration that must be fixed at startup, such as monetary caps, connection pool sizes, or feature flags that must not change mid-request.

7.2 Static Factories as a Registry Pattern

A common pattern in the fintech domain is using static factory methods to centralize object construction and enforce invariants that cannot be checked at the type level:

TYPESCRIPT
class AccountId {
  readonly #value: string;

  // Private constructor — only this class can call new AccountId(...)
  private constructor(value: string) {
    this.#value = value;
  }

  // ✅ Static factory — enforces format invariant before constructing
  static fromString(raw: string): AccountId {
    if (!/^acc_[a-z0-9]{8,}$/.test(raw)) {
      throw new Error(`Invalid AccountId format: "${raw}"`);
    }
    return new AccountId(raw);
  }

  toString(): string {
    return this.#value;
  }
}

const id = AccountId.fromString('acc_a1b2c3d4'); // ✅
const bad = AccountId.fromString('12345');         // ❌ Throws at factory

This pattern also enables a future extension: the factory can be swapped to return a cached instance (Flyweight) or an instrumented subclass (Factory Method) without changing any call sites.


8. Emulating final Classes

TypeScript has no final keyword. Derived classes can extend any class by default. For value objects and aggregate roots where inheritance would be semantically incorrect — an AccountBalance should never be extended — the idiom is a private constructor combined with a static factory.

TYPESCRIPT
// ✅ Pattern: Private constructor + static factory = "final" class
class TransactionId {
  readonly #id: string;

  private constructor(id: string) {
    this.#id = id;
  }

  static generate(): TransactionId {
    // Could use crypto.randomUUID(), nanoid, or any ID strategy
    const id = `txn_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
    return new TransactionId(id);
  }

  static fromString(raw: string): TransactionId {
    if (!raw.startsWith('txn_')) throw new Error('Invalid TransactionId');
    return new TransactionId(raw);
  }

  toString(): string {
    return this.#id;
  }
}

// This line will fail at compile time — constructor is private
// class ExtendedId extends TransactionId {} // ← TS Error: Cannot extend a class with a private constructor

const txnId = TransactionId.generate();
console.log(txnId.toString()); // txn_lf0abc_x4mn2
Architectural Note

Cross-Language Rosetta — final semantics:

  • Go: Achieved naturally — struct methods are not virtual; you cannot override them via embedding.
  • Python: No built-in final, but @final from typing (Python 3.8+) communicates intent to type checkers like mypy.
  • TypeScript 5+: Private constructor + static factory is the de-facto production pattern.

9. Interview Sandbox: #private Fallback for Legacy Runners

CoderPad and HackerRank environments do not always ship with the latest Node.js runtime. When ECMAScript #private fields are unavailable, there is a portable fallback using a WeakMap:

TYPESCRIPT
// Interview Sandbox Fallback — when ECMAScript #private is unavailable
// Works in any ES6-capable runtime including Node 10+

const _balance = new WeakMap<AccountBalance, number>();

class AccountBalance {
  constructor(initialCents: number) {
    _balance.set(this, initialCents);
  }

  get cents(): number {
    return _balance.get(this)!;
  }

  deposit(amountCents: number): void {
    if (amountCents <= 0) throw new RangeError('Deposit must be positive');
    _balance.set(this, _balance.get(this)! + amountCents);
  }
}

const acct = new AccountBalance(10000);
acct.deposit(5000);
console.log(acct.cents); // 15000
// Object.keys(acct) → [] — balance not enumerable
// JSON.stringify(acct) → {} — balance not serialized

The WeakMap key is the instance object itself, so when the instance is garbage collected, the entry is automatically removed — no memory leak. The balance is not accessible via standard property access because it lives in a module-scoped WeakMap, not on the instance.


Summary

Concept Rule
private keyword Compile-time only — erased at emit; use #private for genuine runtime encapsulation
#private fields V8 PrivateName slots — invisible to Object.keys, JSON.stringify, and Reflect
Arrow function fields Per-instance closure — avoids this binding bugs but multiplies heap allocations
Prototype methods Shared single reference — default choice for all regular class methods
Initialization order Base field initializers → super() → derived field initializers → derived constructor body
Getters / setters Synchronous and side-effect free only — never trigger I/O inside an accessor
Static blocks Module-load-time initialization — correct location for environment-driven config
final emulation Private constructor + static factory — prevents inheritance at the type level

What's Next

In Part 2, we confront TypeScript's structural type system directly. Two classes with identical property shapes are silently assignable to each other — a foundational footgun for domain models where CustomerId and VendorId happen to both be strings. Part 2: Structural Typing, Nominal Modeling & Branded Primitives covers the Brand pattern, discriminated unions, and polymorphic this typing to close this gap at zero runtime cost.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#TypeScript#OOP#Classes#Encapsulation#Memory Layout#LLD Interview
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.