Loading learning content...
Imagine you're working with a BankAccount class in a financial system. You call getBalance() and receive -$500,000. But wait—wasn't this account type supposed to be a "Non-Negative Balance Account"? How did this happen?
The answer lies in class invariants—properties that must always be true for an object, from the moment it's created until the moment it's destroyed. When these invariants are violated, objects enter impossible states, and systems fail in unpredictable ways.
An invariant isn't just documentation. It's a contract that every method must maintain.
The BankAccount that went negative had an invariant: balance >= 0. Somewhere, some method—perhaps in a subclass—violated this invariant. The result? Data corruption flowing through the system, affecting calculations, reports, and potentially real-world financial decisions.
This page explores the concept of class invariants: what they are, why they're critical to reliable software, and how they form the foundation of the Liskov Substitution Principle's behavioral contracts.
By the end of this page, you will understand what class invariants are, why they're essential for object integrity, how to identify invariants in your designs, and how invariants create implicit contracts that every method must honor. You'll learn to think about objects not just in terms of behavior, but in terms of the guarantees they must maintain.
A class invariant is a condition that is always true for every valid instance of a class, throughout its entire lifetime. From the moment an object is constructed until the moment it goes out of scope, invariants must hold.
Formal definition:
A class invariant is a logical condition involving the instance variables of a class that must be true before and after every method call. If the invariant ever becomes false, the object is in an invalid state.
Let's unpack this carefully:
Invariants are different from preconditions and postconditions:
| Concept | Scope | Who Ensures It | When It Must Hold |
|---|---|---|---|
| Precondition | Single method call | The caller | Before the method begins execution |
| Postcondition | Single method call | The method implementation | After the method completes execution |
| Invariant | Object lifetime | Every method in the class | Always, except during method execution |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
/** * A DateRange class with a clear invariant: * INVARIANT: startDate <= endDate (start never after end) * * This invariant must be true: * - After construction * - After every method call * - During the object's entire lifetime */class DateRange { private startDate: Date; private endDate: Date; // INVARIANT: startDate <= endDate constructor(start: Date, end: Date) { // Check invariant at construction time if (start > end) { throw new Error("Invalid DateRange: start cannot be after end"); } this.startDate = start; this.endDate = end; // Invariant established: startDate <= endDate ✓ } /** * Move the start date earlier. * This always maintains the invariant since we're moving start earlier. */ extendStart(newStart: Date): void { if (newStart > this.endDate) { throw new Error("Cannot set start after end date"); } this.startDate = newStart; // Invariant maintained: startDate <= endDate ✓ } /** * Move the end date later. * This always maintains the invariant since we're moving end later. */ extendEnd(newEnd: Date): void { if (newEnd < this.startDate) { throw new Error("Cannot set end before start date"); } this.endDate = newEnd; // Invariant maintained: startDate <= endDate ✓ } /** * Shift the entire range by a number of days. * Invariant is maintained because both dates move equally. */ shiftDays(days: number): void { const msPerDay = 24 * 60 * 60 * 1000; const shift = days * msPerDay; // Both dates shift together - invariant cannot be violated this.startDate = new Date(this.startDate.getTime() + shift); this.endDate = new Date(this.endDate.getTime() + shift); // Invariant maintained: startDate <= endDate ✓ } getDurationDays(): number { const ms = this.endDate.getTime() - this.startDate.getTime(); return Math.floor(ms / (24 * 60 * 60 * 1000)); // Note: This method relies on the invariant to return non-negative }}Notice how every method in DateRange is designed with the invariant in mind. Methods either verify that their changes won't violate the invariant (and throw if they would), or they make changes that structurally cannot violate the invariant (like shifting both dates together). This is the discipline invariant thinking requires.
Invariants come in various forms, from simple value constraints to complex relationships between multiple fields. Understanding these categories helps you identify and document invariants in your own classes.
1. Value Constraints (Bounds, Ranges, Allowed Values)
These invariants restrict individual fields to specific domains:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
class Percentage { private value: number; // INVARIANT: 0 <= value <= 100 constructor(value: number) { if (value < 0 || value > 100) { throw new Error("Percentage must be between 0 and 100"); } this.value = value; } add(other: Percentage): Percentage { // Result capped to maintain invariant const sum = Math.min(100, this.value + other.value); return new Percentage(sum); }} class EmailAddress { private readonly address: string; // INVARIANT: address is a valid email format (contains @ and domain) constructor(address: string) { if (!this.isValidEmail(address)) { throw new Error("Invalid email address format"); } this.address = address; } private isValidEmail(email: string): boolean { // Simplified validation const pattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return pattern.test(email); }} class AgeBracket { private minAge: number; private maxAge: number; // INVARIANT: 0 <= minAge <= maxAge <= 150 constructor(minAge: number, maxAge: number) { if (minAge < 0 || maxAge > 150 || minAge > maxAge) { throw new Error("Invalid age bracket"); } this.minAge = minAge; this.maxAge = maxAge; }}2. Relationship Invariants (Between Fields)
These invariants express that multiple fields must maintain a specific relationship:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
class Rectangle { private width: number; private height: number; private area: number; // Cached for performance // INVARIANT: area === width * height (always synchronized) constructor(width: number, height: number) { this.width = width; this.height = height; this.area = width * height; // Establish invariant } setWidth(newWidth: number): void { this.width = newWidth; this.area = this.width * this.height; // Maintain invariant } setHeight(newHeight: number): void { this.height = newHeight; this.area = this.width * this.height; // Maintain invariant } getArea(): number { // Relies on invariant - no recalculation needed return this.area; }} class SortedList<T> { private items: T[]; private comparator: (a: T, b: T) => number; // INVARIANT: For all i < j: comparator(items[i], items[j]) <= 0 // (Items are always in sorted order) constructor(comparator: (a: T, b: T) => number) { this.items = []; this.comparator = comparator; } add(item: T): void { // Binary search to find insertion point - maintains sort invariant let left = 0, right = this.items.length; while (left < right) { const mid = Math.floor((left + right) / 2); if (this.comparator(this.items[mid], item) <= 0) { left = mid + 1; } else { right = mid; } } this.items.splice(left, 0, item); // Invariant maintained: items remain sorted } getMin(): T | undefined { // Relies on invariant: smallest is always at index 0 return this.items[0]; } getMax(): T | undefined { // Relies on invariant: largest is always at last index return this.items[this.items.length - 1]; }}3. Structural Invariants (Data Structure Integrity)
These invariants ensure that internal data structures remain valid:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
class BinarySearchTree<T> { private root: TreeNode<T> | null = null; private comparator: (a: T, b: T) => number; // INVARIANT: For every node N: // - All values in N.left subtree < N.value // - All values in N.right subtree > N.value // (Binary Search Tree property) constructor(comparator: (a: T, b: T) => number) { this.comparator = comparator; } insert(value: T): void { // Insert maintains BST invariant by choosing correct position if (!this.root) { this.root = { value, left: null, right: null }; return; } this.insertRecursive(this.root, value); // Invariant maintained: BST property preserved } private insertRecursive(node: TreeNode<T>, value: T): void { const comparison = this.comparator(value, node.value); if (comparison < 0) { // Value is less - goes in left subtree (maintains invariant) if (node.left) { this.insertRecursive(node.left, value); } else { node.left = { value, left: null, right: null }; } } else { // Value is greater/equal - goes in right subtree (maintains invariant) if (node.right) { this.insertRecursive(node.right, value); } else { node.right = { value, left: null, right: null }; } } } contains(value: T): boolean { // Search relies on BST invariant for O(log n) lookup return this.searchRecursive(this.root, value); } private searchRecursive(node: TreeNode<T> | null, value: T): boolean { if (!node) return false; const comparison = this.comparator(value, node.value); if (comparison === 0) return true; // Invariant guarantees we only need to search one subtree if (comparison < 0) { return this.searchRecursive(node.left, value); } else { return this.searchRecursive(node.right, value); } }} interface TreeNode<T> { value: T; left: TreeNode<T> | null; right: TreeNode<T> | null;}4. Count/Size Invariants (Synchronized Counts)
These invariants ensure that size or count fields accurately reflect the actual data:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
class LinkedList<T> { private head: ListNode<T> | null = null; private tail: ListNode<T> | null = null; private count: number = 0; // INVARIANT 1: count === actual number of nodes in list // INVARIANT 2: if count === 0, then head === null && tail === null // INVARIANT 3: if count > 0, then head !== null && tail !== null // INVARIANT 4: tail.next === null (tail is always the last node) add(value: T): void { const newNode: ListNode<T> = { value, next: null }; if (this.tail) { this.tail.next = newNode; this.tail = newNode; } else { this.head = newNode; this.tail = newNode; } this.count++; // Maintain count invariant // All invariants maintained } removeFirst(): T | undefined { if (!this.head) return undefined; const value = this.head.value; this.head = this.head.next; if (!this.head) { this.tail = null; // Maintain invariant 2 } this.count--; // Maintain count invariant // All invariants maintained return value; } size(): number { // Relies on count invariant - O(1) instead of traversing return this.count; } isEmpty(): boolean { // Relies on invariant 2 return this.count === 0; }} interface ListNode<T> { value: T; next: ListNode<T> | null;}The most important step in working with invariants is making them explicit. Document invariants as comments in your class definition. Future maintainers (including future you) must understand what properties the class guarantees, so they don't accidentally break them when adding new methods.
Invariants might seem like additional overhead—extra checks, extra thinking, extra documentation. But they provide profound benefits that compound as systems grow in size and complexity.
1. Trust and Simplification
When invariants are guaranteed, code can make assumptions without defensive checks:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
// WITHOUT invariant guarantees - defensive everywhereclass UntrustedRectangle { width: number | null = null; height: number | null = null; getArea(): number | null { // Must check everything because nothing is guaranteed if (this.width === null || this.height === null) { return null; } if (this.width < 0 || this.height < 0) { return null; // Or throw? Caller doesn't know what to expect } return this.width * this.height; } getPerimeter(): number | null { // Same defensive checks repeated everywhere if (this.width === null || this.height === null) { return null; } if (this.width < 0 || this.height < 0) { return null; } return 2 * (this.width + this.height); }} // WITH invariant guarantees - clean and focusedclass TrustedRectangle { // INVARIANT: width > 0, height > 0 (always positive, never null) private constructor( private readonly width: number, private readonly height: number ) {} static create(width: number, height: number): TrustedRectangle { // Invariant enforced at construction if (width <= 0 || height <= 0) { throw new Error("Dimensions must be positive"); } return new TrustedRectangle(width, height); } getArea(): number { // No checks needed - invariant guarantees valid dimensions return this.width * this.height; } getPerimeter(): number { // Clean implementation relying on invariant return 2 * (this.width + this.height); } scale(factor: number): TrustedRectangle { if (factor <= 0) { throw new Error("Scale factor must be positive"); } // Result automatically satisfies invariant return new TrustedRectangle(this.width * factor, this.height * factor); }}2. Reasoning About Correctness
Invariants make it possible to reason about program correctness systematically:
3. Performance Optimization
Invariants enable optimizations that would otherwise be unsafe:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
class SortedArray<T> { private items: T[]; private comparator: (a: T, b: T) => number; // INVARIANT: items is always sorted according to comparator constructor(comparator: (a: T, b: T) => number) { this.items = []; this.comparator = comparator; } /** * Because of the invariant, we can use binary search. * O(log n) instead of O(n) for unsorted arrays. */ contains(item: T): boolean { return this.binarySearch(item) !== -1; } /** * Find minimum in O(1) because invariant guarantees smallest is first. */ min(): T | undefined { return this.items[0]; // Invariant: first is always smallest } /** * Find maximum in O(1) because invariant guarantees largest is last. */ max(): T | undefined { return this.items[this.items.length - 1]; // Invariant: last is always largest } /** * Merge two sorted arrays in O(n + m) because both maintain sort invariant. * Without the invariant, this would require O((n+m) log(n+m)) to sort. */ merge(other: SortedArray<T>): SortedArray<T> { const result = new SortedArray<T>(this.comparator); let i = 0, j = 0; // Merge in sorted order - only possible because both are sorted while (i < this.items.length && j < other.items.length) { if (this.comparator(this.items[i], other.items[j]) <= 0) { result.items.push(this.items[i++]); } else { result.items.push(other.items[j++]); } } // Append remaining items (already sorted) while (i < this.items.length) result.items.push(this.items[i++]); while (j < other.items.length) result.items.push(other.items[j++]); return result; // Result also maintains invariant } private binarySearch(item: T): number { let left = 0, right = this.items.length - 1; while (left <= right) { const mid = Math.floor((left + right) / 2); const cmp = this.comparator(this.items[mid], item); if (cmp === 0) return mid; if (cmp < 0) left = mid + 1; else right = mid - 1; } return -1; }}If any code path allows the SortedArray to become unsorted, every optimization based on the sort invariant fails. Binary search returns wrong results. Min/max become incorrect. Merge produces unsorted output. This is why invariant violations are so dangerous—they silently corrupt behavior across the entire class.
Here's a critical insight: Class invariants create implicit contracts between the class and all code that uses it.
When you define a class with invariants, you're making promises to callers:
Callers rely on these promises. They write code that assumes the invariants are true. If you violate the invariants, you break that code—often in subtle, hard-to-detect ways.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
class NonNegativeBalance { // INVARIANT: balance >= 0 private balance: number; constructor(initialBalance: number) { if (initialBalance < 0) { throw new Error("Initial balance cannot be negative"); } this.balance = initialBalance; } deposit(amount: number): void { if (amount < 0) { throw new Error("Cannot deposit negative amount"); } this.balance += amount; // Invariant maintained: adding positive to non-negative stays non-negative } withdraw(amount: number): void { if (amount < 0) { throw new Error("Cannot withdraw negative amount"); } if (amount > this.balance) { throw new Error("Insufficient funds"); } this.balance -= amount; // Invariant maintained: we checked amount <= balance } getBalance(): number { return this.balance; // Caller can trust this is >= 0 }} // Caller code relies on the invariantfunction calculateInterest(account: NonNegativeBalance): number { const balance = account.getBalance(); // This code TRUSTS that balance >= 0 // No check needed because the invariant guarantees it const rate = 0.05; return balance * rate; // Always non-negative} function displayBalance(account: NonNegativeBalance): string { const balance = account.getBalance(); // Safe to use without checking for negative // The invariant guarantees balance >= 0 return `$${balance.toFixed(2)}`;} function transferSafely( from: NonNegativeBalance, to: NonNegativeBalance, amount: number): void { // This operation is safe because both accounts maintain invariants from.withdraw(amount); // Will throw if insufficient funds to.deposit(amount); // If we reach here, both accounts still satisfy invariants}The Trust Chain:
Invariants create a chain of trust throughout your codebase:
If any link in this chain breaks—if any method fails to maintain the invariant—the corruption propagates through the entire chain. Code that was correct becomes incorrect, not because it changed, but because something it trusted broke its promise.
The best way to maintain invariants is to make them impossible to break. Use private fields with controlled accessors. Validate at construction time. Make objects immutable where possible. The goal is to structure your class so that no sequence of method calls can ever create an invalid state.
| Strategy | How It Works | When to Use |
|---|---|---|
| Private fields only | External code cannot directly modify state | Always - default approach |
| Validate in constructor | Reject invalid initial states | Always - establish invariant at birth |
| Validate in mutators | Check before every state change | When mutation is necessary |
| Immutability | Objects can't change, so invariants can't be violated post-construction | When feasible - strongest guarantee |
| Factory methods | Control object creation to ensure valid states | Complex initialization logic |
| Defensive copies | Prevent callers from mutating internal state | When returning mutable internals |
How do you uncover the invariants in code that doesn't document them? This is a critical skill, especially when working with legacy systems or third-party libraries.
Discovery Techniques:
1234567891011121314151617181920212223242526272829303132333435363738394041424344
// Given this class, what are its invariants?class Order { private items: OrderItem[]; private status: OrderStatus; private total: number; private createdAt: Date; private shippedAt: Date | null; constructor(items: OrderItem[]) { if (items.length === 0) { throw new Error("Order must have at least one item"); } this.items = [...items]; // Defensive copy this.status = OrderStatus.PENDING; this.total = this.calculateTotal(); this.createdAt = new Date(); this.shippedAt = null; } private calculateTotal(): number { return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0); } ship(): void { if (this.status !== OrderStatus.PENDING) { throw new Error("Can only ship pending orders"); } this.status = OrderStatus.SHIPPED; this.shippedAt = new Date(); } getItems(): OrderItem[] { return [...this.items]; // Defensive copy - can't modify internal }} // DISCOVERED INVARIANTS:// 1. items.length > 0 (checked in constructor)// 2. total === sum of item prices * quantities (set at construction)// 3. createdAt is never null (set at construction)// 4. If status === SHIPPED, then shippedAt !== null// 5. If status === PENDING, then shippedAt === null// 6. items array is immutable after construction (defensive copies)// 7. status transitions: PENDING -> SHIPPED (never backwards)Common Invariant Patterns:
| Domain | Common Invariants |
|---|---|
| Financial | balance >= 0; credits - debits = balance; transaction amounts > 0 |
| Collections | size >= 0; size = actual count; indices < size |
| Date/Time | start <= end; duration >= 0; times are in valid ranges |
| Graphs | edge connects valid nodes; no orphan edges; degrees are accurate |
| Trees | one root; no cycles; parent-child links consistent |
| Caches | capacity >= 0; size <= capacity; entries match stored keys |
| User Data | id is unique; email format is valid; required fields are non-null |
When you discover undocumented invariants, add them to the code as comments. Future maintainers will thank you. Better yet, add assertions or validation that explicitly checks invariants, making violations fail fast and loudly.
We've explored class invariants in depth. Let's consolidate the key insights:
What's Next:
Understanding invariants is crucial preparation for the next topic: Preserving Invariants in Subclasses. When inheritance enters the picture, invariants become even more critical—and more easily violated.
A subclass inherits not just the code of its parent, but also its invariants. If a subclass weakens or breaks these invariants, it violates the Liskov Substitution Principle, causing polymorphic code to fail in subtle, dangerous ways.
You'll learn exactly how subclasses can accidentally break parent invariants, and how to design inheritance hierarchies that maintain invariant integrity across the entire type hierarchy.
You now understand what class invariants are and why they're fundamental to object integrity. Invariants aren't just nice-to-have documentation—they're the contracts that make polymorphism safe and enable code to trust the objects it works with. Next, we'll explore how subclasses must preserve these invariants to maintain LSP compliance.