Encapsulation & Domain Invariants: TypeScript private vs ECMAScript #private
True encapsulation requires enforcing business invariants at runtime — TypeScript's private keyword is erased at compile time, whereas ECMAScript #private fields guarantee genuine V8 heap-level boundary protection. This article dissects the difference and teaches you to build entities that can never be placed in an invalid state.
Frontend Object-Oriented Architecture
Encapsulation & Domain Invariants: TypeScript private vs ECMAScript #private
Domain objects are autonomous state machines with enforced invariants — they are not passive bags of data passed between controller functions. Encapsulation is the mechanism that makes this autonomy structural rather than aspirational. But in TypeScript, there are two entirely different encapsulation mechanisms that look similar and behave radically differently: the private keyword (a compile-time annotation) and the # syntax (ECMAScript private fields — runtime enforcement). Choosing the wrong one means your "encapsulated" domain object is actually fully accessible at runtime, and any code that runs after TypeScript is stripped can violate every invariant you wrote.
This article dissects both mechanisms through the lens of the canvas studio's Shape domain objects — showing exactly what each protects, what each costs, and which to use for invariant-critical domain properties.
1. The False Security of TypeScript private
TypeScript private is a compile-time-only access modifier. It prevents access to marked members from outside the class in TypeScript source code. It generates no special runtime code. After tsc compiles, the private modifier is erased — the property becomes a plain JavaScript property, accessible to any JavaScript code:
This looks safe in TypeScript. After compilation:
Every private member is a public property at runtime. Any code that consumes your library's compiled output — third-party scripts, browser console, Chrome extensions, Webpack plugins processing the output — can read and write it freely.
1.1 The Consequences for Domain Invariants
Consider the Shape domain object with an invariant: "width and height must always be positive":
The invariant is enforced in the constructor and in resize(). But at runtime:
TypeScript private provides protection only in TypeScript source files. In a purely TypeScript codebase where all access goes through .ts files and the compiler checks all of them, this is reasonable protection. In any other context — testing with JavaScript, consuming compiled output, browser devtools — it is no protection at all.
2. ECMAScript #private Fields: Runtime Enforcement
ECMAScript private fields (#field) are a language-level feature — not a TypeScript extension. They use a fundamentally different mechanism: a WeakMap-like internal slot per class that only the class definition can access. There is no cast, no as any, no runtime workaround that provides access from outside the class:
After compilation (targeting ES2022 or later with "target": "ES2022" in tsconfig.json):
The # field is enforced by the JavaScript engine itself. There is no workaround. Object.getOwnPropertyNames(shape) does not list #width. Object.assign({}, shape) does not copy #width. JSON serialization ignores it. The field is literally invisible to everything outside the class definition.
2.1 The in Operator and Brand Checking
#private fields enable a pattern unavailable with private: brand checking — a runtime type guard that verifies an object is a genuine instance of a class, not a look-alike duck-typed object:
This brand-checking pattern is particularly useful in the canvas studio for distinguishing RectShape from a shape data transfer object (RectShapeData) which has the same fields but is not a domain object.
3. The TypeScript private Escape Hatch
TypeScript private has an explicit escape hatch using type assertion. Any TypeScript developer who knows the escape hatch can bypass your encapsulation:
ECMAScript #private has no such escape hatch — as any does not help:
4. readonly vs #private for Immutable Value Objects
For Value Objects (Part 3 backend series; geometric types like Point and Rect in the canvas studio), the goal is immutability rather than behavioral encapsulation. readonly is the appropriate mechanism:
For Entities and Aggregates (domain objects with invariants and behavior), #private is appropriate:
Every mutation goes through move() or resize(). Both validate their inputs. The shape cannot be in an invalid state. The validation is guaranteed by the runtime — not by TypeScript, not by convention, by the JavaScript engine.
5. #private Methods: Encapsulating Internal Logic
Private methods using # keep complex validation or transformation logic invisible to subclasses and external consumers:
#computeRotatedBounds() and #validateDimensions() are internal implementation details. Subclasses cannot call them, override them, or rely on them. If the bounding box algorithm changes, only RectShape needs updating — no subclass contracts are broken.
6. Testing #private Fields Without Exposing Them
A common objection to #private is: "How do I test the private state?" The answer: test through the public interface, not through private fields. If you need to observe the result of a private state change, the public API exposes it — either through a getter, a toData() method, or observable behavior:
If you find yourself wanting to read a private field in a test, it almost always means either: (a) the field should have a public getter, or (b) the behavior you want to test is better expressed through a higher-level observable outcome.
7. The Decision Matrix
| Scenario | Use |
|---|---|
| Value Object (immutable data container, no behavior) | readonly interface or Object.freeze() |
| Entity internal state (invariants must hold) | #private fields |
| TypeScript-only codebase, compiled output not consumed externally | private is acceptable; #private still preferred |
| Library or SDK where compiled output is consumed by JavaScript | #private mandatory |
| Method that is implementation detail of the class | #private method |
| Method intended for subclass override | protected (TypeScript compile-time only) |
| Computed/derived value (no mutable state) | Public get accessor over #private backing field |
Summary
| Feature | TypeScript private |
ECMAScript #private |
|---|---|---|
| Enforcement | Compile-time only (TypeScript) | Runtime (JavaScript engine) |
| Compiled output | Erased — plain public property | Preserved — #field syntax in output |
| Escape hatch | (obj as any).field |
None — SyntaxError outside class body |
| Subclass access | Blocked in TypeScript | Blocked in JavaScript |
| Serialization | Visible to Object.keys(), JSON.stringify() |
Invisible to all serialization |
| Brand checking | Not possible | #brand in obj pattern |
| Performance | Same as public property | ~1ns overhead (V8 ≥ Chrome 94) — negligible |
What's Next
Part 5 takes branded primitives — introduced briefly in Part 3 — and applies them to the canvas studio's full ID system:
ShapeId,LayerId,CommandId. We build the compile-time type safety that prevents passing aLayerIdwhere aShapeIdis expected, and show how this catches real canvas studio bugs before they reach the browser.
This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.