AccountLedger {
#balance: number;
#transactionHistory: string[];
#status: 'active' | 'frozen' | 'closed';
constructor(initialBalance: number) {
if (initialBalance < 0) {
throw new Error('Initial balance cannot be negative');
}
this.#balance = initialBalance;
this.#transactionHistory = [];
this.#status = 'active';
}
}
**Rationale:** Using `#` private fields guarantees that no external code can bypass the boundary, even via type casting or reflection. Constructor validation ensures atomic initialization, preventing partially constructed invalid states.
### Step 2: Expose Controlled Read Interfaces
Provide getters that return either primitive values or immutable snapshots. Never return mutable references to internal collections or objects. If consumers need to iterate or inspect state, return a defensive copy or a read-only view.
```typescript
class AccountLedger {
// ... previous code ...
get currentBalance(): number {
return this.#balance;
}
get transactionLog(): ReadonlyArray<string> {
return Object.freeze([...this.#transactionHistory]);
}
get isOperational(): boolean {
return this.#status === 'active';
}
}
Rationale: ReadonlyArray and Object.freeze prevent consumers from accidentally mutating internal history. The isOperational getter abstracts status logic, allowing internal state representation to change without breaking callers.
Step 3: Implement Validated Mutation Paths
Setters should never blindly assign values. Every mutation point must validate constraints, enforce business rules, and maintain consistency. If multiple fields must change together, avoid individual setters entirely. Use command methods or builder patterns instead.
class AccountLedger {
// ... previous code ...
deposit(amount: number): void {
if (amount <= 0) {
throw new Error('Deposit amount must be positive');
}
if (this.#status !== 'active') {
throw new Error('Cannot deposit into non-active account');
}
this.#balance += amount;
this.#transactionHistory.push(`+${amount}`);
}
withdraw(amount: number): void {
if (amount <= 0) {
throw new Error('Withdrawal amount must be positive');
}
if (this.#balance < amount) {
throw new Error('Insufficient funds');
}
if (this.#status !== 'active') {
throw new Error('Cannot withdraw from non-active account');
}
this.#balance -= amount;
this.#transactionHistory.push(`-${amount}`);
}
}
Rationale: Domain-specific methods (deposit, withdraw) replace generic setters. This enforces business invariants at the boundary, keeps validation centralized, and makes the API self-documenting. Consumers cannot bypass rules by calling arbitrary setters.
Step 4: Design for Internal Evolution
Encapsulation enables internal refactoring without contract breaks. If you later switch from a numeric balance to a decimal library, or replace the transaction array with a streaming log, external code remains untouched. The access layer absorbs the change.
// Internal change example (consumers unaffected)
class AccountLedger {
#balance: Decimal; // Changed from number
#auditStream: AuditLog; // Changed from string[]
get currentBalance(): Decimal {
return this.#balance;
}
deposit(amount: Decimal): void {
this.#balance = this.#balance.add(amount);
this.#auditStream.record('deposit', amount);
}
}
Rationale: The public contract (deposit, currentBalance) stays stable. Internal representation evolves independently. This is the core operational benefit of encapsulation: decoupling implementation from interface.
Pitfall Guide
1. The Dumb Accessor Trap
Explanation: Developers generate getters and setters that directly forward to private fields without adding validation, logging, or transformation. This creates the illusion of encapsulation while providing none of the architectural benefits.
Fix: Treat accessors as contract enforcement points. Add input validation, state checks, or lazy initialization. If an accessor does nothing but return a field, reconsider whether the field should be private or if the class needs behavioral methods instead.
2. Mutable Reference Leakage
Explanation: Returning internal arrays, objects, or maps directly allows consumers to modify state outside the class boundary. TypeScript's structural typing often masks this issue until runtime corruption occurs.
Fix: Always return defensive copies or immutable views. Use Object.freeze(), ReadonlyArray, or spread operators ([...arr]) to break reference sharing. Document return types clearly to signal immutability expectations.
3. Setter Overload and Inconsistent State
Explanation: Exposing multiple independent setters for related fields allows consumers to create partially updated or logically invalid states. For example, setting startDate without endDate breaks temporal invariants.
Fix: Replace granular setters with atomic update methods or builder patterns. Group related mutations into single operations that validate the complete state before applying changes. Use constructor injection for required fields.
4. Ignoring Read-Only Contracts
Explanation: Classes that should be immutable expose setters, enabling accidental mutation. This is common in DTOs, configuration objects, and domain events where state should be fixed after creation.
Fix: Omit setters entirely. Use readonly modifiers, constructor-only initialization, or factory functions. If external code needs modified versions, return new instances rather than mutating existing ones.
5. Bypassing Boundaries via Type Casting or Reflection
Explanation: Developers use any, type assertions, or runtime hacks to access private fields, defeating encapsulation. This creates hidden dependencies and breaks static analysis.
Fix: Enforce boundaries at build time. Configure strict TypeScript settings (noImplicitAny, strictPropertyInitialization). Use linters to detect private field access. Document contracts explicitly and treat boundary violations as architectural defects.
6. Validation Misplacement
Explanation: Placing complex business rules inside accessors couples structural validation with domain logic. This makes accessors heavy, hard to test, and difficult to reuse across different contexts.
Fix: Keep accessors focused on structural integrity and type safety. Delegate complex business rules to domain services, command handlers, or validation layers. Use accessors to enforce invariants, not to orchestrate workflows.
7. Over-Engineering Simple Data Shapes
Explanation: Applying full encapsulation to pure data transfer objects (DTOs) or configuration payloads introduces unnecessary boilerplate. Not every class needs behavioral boundaries.
Fix: Reserve encapsulation for domain entities, aggregates, and stateful components. Use plain interfaces or type aliases for serialization contracts, API payloads, and configuration objects. Match the pattern to the responsibility.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Domain Entity with Business Rules | Full encapsulation with command methods | Enforces invariants, centralizes validation, supports audit trails | Low (initial setup), High ROI (maintenance) |
| API Response / DTO | Plain interfaces or type aliases | Serialization frameworks expect direct property access, no behavior needed | Minimal |
| Configuration Object | Constructor injection + readonly fields | Immutable after creation, prevents runtime mutation, clear initialization | Low |
| Performance-Critical Loop | Direct fields with compiler optimizations | Accessor overhead is negligible in modern engines, but tight loops may benefit from inlining | Medium (micro-optimization) |
| Legacy Codebase Migration | Gradual boundary introduction with facade layer | Avoids breaking changes, allows incremental refactoring, maintains compatibility | High initially, decreases over time |
Configuration Template
// strict-access-boundaries.ts
// TypeScript configuration for enforcing state boundaries
export interface AccessBoundaryConfig {
// Enforce private field syntax
useHashPrivateFields: boolean;
// Prevent mutable reference returns
enforceImmutableCollections: boolean;
// Require constructor validation
requireAtomicInitialization: boolean;
// Disable generic setters for domain classes
forbidDumbAccessors: boolean;
}
export const defaultAccessBoundaryConfig: AccessBoundaryConfig = {
useHashPrivateFields: true,
enforceImmutableCollections: true,
requireAtomicInitialization: true,
forbidDumbAccessors: true,
};
// Usage in domain class
export class FinancialTransaction {
#id: string;
#amount: number;
#timestamp: Date;
#metadata: Record<string, unknown>;
constructor(id: string, amount: number, metadata: Record<string, unknown>) {
if (!id.trim()) throw new Error('Transaction ID required');
if (amount <= 0) throw new Error('Amount must be positive');
this.#id = id;
this.#amount = amount;
this.#timestamp = new Date();
this.#metadata = Object.freeze({ ...metadata });
}
get transactionId(): string { return this.#id; }
get value(): number { return this.#amount; }
get recordedAt(): Date { return new Date(this.#timestamp.getTime()); }
get tags(): Readonly<Record<string, unknown>> { return this.#metadata; }
// No setters. State is immutable after construction.
}
Quick Start Guide
- Identify Stateful Classes: Scan your codebase for classes with public fields or uncontrolled mutation. Prioritize domain entities and aggregates.
- Apply Private Boundaries: Convert public fields to
# private syntax. Add constructor validation to enforce required invariants.
- Replace Setters with Commands: Remove generic setters. Create domain-specific methods (
deposit, updateStatus, attachDocument) that validate and mutate state atomically.
- Secure Returns: Audit all getters returning objects or arrays. Wrap them in
Object.freeze(), ReadonlyArray, or defensive copies.
- Verify Contracts: Write tests that attempt to bypass boundaries. Confirm that invalid states are rejected at construction or mutation points. Run static analysis to ensure no external code accesses private fields.
Encapsulation is not a language feature to be toggled. It is an architectural discipline that separates internal representation from external contract. When applied consistently, it transforms fragile data containers into resilient state machines capable of evolving without breaking the systems that depend on them.