Siddhant Deval
Siddhant Deval
frontend5 min read

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.

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:

TYPESCRIPT
// TypeScript source
class Shape {
  private x: number;
  private y: number;

  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }

  move(dx: number, dy: number): void {
    this.x += dx;
    this.y += dy;
  }
}

const shape = new Shape(0, 0);
// ❌ TypeScript error: Property 'x' is private
// shape.x = 999;

This looks safe in TypeScript. After compilation:

JAVASCRIPT
// Compiled JavaScript — no 'private' anywhere
class Shape {
  constructor(x, y) {
    this.x = x; // ← Plain property — fully accessible at runtime
    this.y = y; // ← Plain property — fully accessible at runtime
  }
  move(dx, dy) {
    this.x += dx;
    this.y += dy;
  }
}

const shape = new Shape(0, 0);
shape.x = 999; // ✅ Works at runtime — no error, no warning

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":

TYPESCRIPT
class RectShape {
  private width: number;
  private height: number;

  constructor(w: number, h: number) {
    if (w <= 0 || h <= 0) throw new DomainError('Dimensions must be positive');
    this.width = w;
    this.height = h;
  }

  resize(dw: number, dh: number): void {
    const newW = this.width + dw;
    const newH = this.height + dh;
    if (newW <= 0 || newH <= 0) throw new DomainError('Resize would produce non-positive dimensions');
    this.width = newW;
    this.height = newH;
  }
}

The invariant is enforced in the constructor and in resize(). But at runtime:

JAVASCRIPT
// In the browser console, or in any JavaScript file that imports the compiled output:
const shape = new RectShape(100, 50);
shape.width = -500;  // Works perfectly — invariant bypassed at runtime
shape.height = 0;    // Works perfectly — invariant bypassed at runtime
// getBounds() now returns a negative-area rect — silent corruption

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:

TYPESCRIPT
class RectShape {
  #width: number;   // ← ECMAScript private field
  #height: number;

  constructor(w: number, h: number) {
    if (w <= 0 || h <= 0) throw new DomainError('Dimensions must be positive');
    this.#width = w;
    this.#height = h;
  }

  resize(dw: number, dh: number): void {
    const newW = this.#width + dw;
    const newH = this.#height + dh;
    if (newW <= 0 || newH <= 0) throw new DomainError('Resize would produce non-positive dimensions');
    this.#width = newW;
    this.#height = newH;
  }

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

After compilation (targeting ES2022 or later with "target": "ES2022" in tsconfig.json):

JAVASCRIPT
class RectShape {
  #width;   // ← Preserved in compiled output — runtime enforcement
  #height;

  constructor(w, h) {
    if (w <= 0 || h <= 0) throw new DomainError('Dimensions must be positive');
    this.#width = w;
    this.#height = h;
  }
}

const shape = new RectShape(100, 50);
shape.#width = -500; // SyntaxError — cannot be written outside the class body
// Even with full access to the object, #width cannot be read or written

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:

TYPESCRIPT
class RectShape {
  #brand = true; // Private brand field

  static isRectShape(obj: unknown): obj is RectShape {
    return #brand in Object(obj); // Only true for genuine RectShape instances
  }

  #width: number;
  #height: number;
  // ...
}

// Usage — runtime type narrowing without instanceof limitations
function processShape(obj: unknown): void {
  if (RectShape.isRectShape(obj)) {
    // TypeScript narrows to RectShape here
    // AND we have runtime certainty this is a genuine RectShape
    console.log(obj.width);
  }
}

// Edge cases where instanceof breaks (cross-realm, multiple copies of the module):
const fakeRect = { width: 100, height: 50 }; // Plain object
RectShape.isRectShape(fakeRect); // false — no #brand field

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:

TYPESCRIPT
class Shape {
  private x: number = 0;
}

const shape = new Shape();
(shape as any).x = 999; // ✅ Compiles, runs fine — 'as any' bypasses TypeScript private

ECMAScript #private has no such escape hatch — as any does not help:

TYPESCRIPT
class Shape {
  #x: number = 0;
}

const shape = new Shape();
(shape as any).#x = 999; // ❌ SyntaxError — #x is not valid syntax outside the class body
// Cannot be worked around at all

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:

TYPESCRIPT
// ✅ readonly for Value Objects — immutable after construction, no behavior
export interface Point {
  readonly x: number;
  readonly y: number;
}

// TypeScript enforces immutability at compile time
const p: Point = { x: 10, y: 20 };
p.x = 30; // ❌ TypeScript error: Cannot assign to 'x' because it is a read-only property

For Entities and Aggregates (domain objects with invariants and behavior), #private is appropriate:

TYPESCRIPT
// ✅ #private for Entities — runtime-enforced invariants
class RectShape {
  #x: number;
  #y: number;
  #width: number;
  #height: number;

  // Invariant: position and dimensions are always finite numbers
  constructor(x: number, y: number, w: number, h: number) {
    this.#validateDimensions(w, h);
    this.#x = x; this.#y = y; this.#width = w; this.#height = h;
  }

  #validateDimensions(w: number, h: number): void {
    if (!Number.isFinite(w) || w <= 0) throw new DomainError(`Width must be a positive finite number, got: ${w}`);
    if (!Number.isFinite(h) || h <= 0) throw new DomainError(`Height must be a positive finite number, got: ${h}`);
  }

  move(dx: number, dy: number): void {
    if (!Number.isFinite(dx) || !Number.isFinite(dy))
      throw new DomainError(`Move delta must be finite numbers, got: (${dx}, ${dy})`);
    this.#x += dx;
    this.#y += dy;
  }

  resize(newW: number, newH: number): void {
    this.#validateDimensions(newW, newH);
    this.#width = newW;
    this.#height = newH;
  }

  // Public read-only accessors — expose state without mutation access
  get x(): number { return this.#x; }
  get y(): number { return this.#y; }
  get width(): number { return this.#width; }
  get height(): number { return this.#height; }

  // Snapshot for React consumption — plain serializable object
  toData(): RectShapeData {
    return { type: 'rect', id: this.id, x: this.#x, y: this.#y, width: this.#width, height: this.#height };
  }
}

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:

TYPESCRIPT
class RectShape {
  #x: number;
  #y: number;
  #width: number;
  #height: number;
  #rotation: number;

  constructor(x: number, y: number, w: number, h: number, rotation = 0) {
    this.#validateDimensions(w, h);
    this.#validateRotation(rotation);
    this.#x = x; this.#y = y; this.#width = w; this.#height = h; this.#rotation = rotation;
  }

  // Private validation methods — inaccessible to subclasses or external code
  #validateDimensions(w: number, h: number): void {
    if (w <= 0) throw new DomainError(`Width must be positive: ${w}`);
    if (h <= 0) throw new DomainError(`Height must be positive: ${h}`);
  }

  #validateRotation(r: number): void {
    if (!Number.isFinite(r)) throw new DomainError(`Rotation must be a finite number: ${r}`);
  }

  // Private computation — the rotated bounding box algorithm is an implementation detail
  #computeRotatedBounds(): Rect {
    const cx = this.#x + this.#width / 2;
    const cy = this.#y + this.#height / 2;
    const cos = Math.abs(Math.cos(this.#rotation));
    const sin = Math.abs(Math.sin(this.#rotation));
    const w = this.#width * cos + this.#height * sin;
    const h = this.#width * sin + this.#height * cos;
    return rect(cx - w / 2, cy - h / 2, w, h);
  }

  getBounds(): Rect {
    return this.#rotation === 0
      ? rect(this.#x, this.#y, this.#width, this.#height) // Fast path — no rotation
      : this.#computeRotatedBounds();                      // Full computation
  }
}

#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:

TYPESCRIPT
// ✅ Testing #private state through public interface
describe('RectShape', () => {
  it('should maintain positive dimensions after resize', () => {
    const shape = new RectShape(0, 0, 100, 50);
    shape.resize(80, 40);
    // Test via public getters — not by accessing shape.#width
    expect(shape.width).toBe(80);
    expect(shape.height).toBe(40);
  });

  it('should throw when resize would produce zero or negative dimensions', () => {
    const shape = new RectShape(0, 0, 100, 50);
    expect(() => shape.resize(0, 50)).toThrow(DomainError);
    expect(() => shape.resize(-10, 50)).toThrow(DomainError);
    // shape.#width and shape.#height unchanged — invariant preserved
    expect(shape.width).toBe(100); // Still the original value
    expect(shape.height).toBe(50);
  });

  it('should produce correct bounds after rotation', () => {
    const shape = new RectShape(0, 0, 100, 50, Math.PI / 4); // 45°
    const bounds = shape.getBounds();
    // Test the observable output — not the private #computeRotatedBounds logic
    expect(bounds.width).toBeCloseTo(106.07, 1); // (100 + 50) / √2 ≈ 106.07
    expect(bounds.height).toBeCloseTo(106.07, 1);
  });
});

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 a LayerId where a ShapeId is expected, and show how this catches real canvas studio bugs before they reach the browser.

Research & Synthesis Note

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

#TypeScript#JavaScript#OOP#Encapsulation#Private Fields#Domain Invariants#V8
Siddhant Deval

Written by Siddhant Deval

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