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.
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:
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:
The TypeScript compiler erases all type annotations and emits this JavaScript:
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.
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
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.
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'.](https://pub-74778554195b4df89d82f0d61988355a.r2.dev/assets/blog/system-design/typescript-classes-memory-encapsulation-internals/fig-01.png)
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:
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.
The precise initialization order for derived classes is:
- Base class field initializers (from the base class body)
- Base class constructor body (
super(...)call) - Derived class field initializers (from the derived class body — this is the critical step)
- Derived class constructor body
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.
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.
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.
5.3 Measuring the Memory Impact
In a payment engine that creates 100,000 TransactionProcessor instances per minute, the difference is not academic:

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
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
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.
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:
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.
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@finalfromtyping(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:
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
CustomerIdandVendorIdhappen to both be strings. Part 2: Structural Typing, Nominal Modeling & Branded Primitives covers the Brand pattern, discriminated unions, and polymorphicthistyping to close this gap at zero runtime cost.
This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.