Loading learning content...
We've established two rules:
Many developers find this confusing at first. "Weaken" sounds negative, but weakening preconditions is good? "Strengthen" sounds positive, but strengthening preconditions is bad? The terminology becomes intuitive once you understand the underlying logic.
This page consolidates and deepens your understanding of weakening and strengthening. We'll build mental models, work through edge cases, and develop a reliable intuition for analyzing any contract modification.
By the end of this page, you will: • Develop a reliable intuition for when modifications are safe • Understand the logic implication interpretation of weakening/strengthening • Master the "client perspective" mental model for LSP analysis • Recognize and resolve subtle edge cases in contract modification • Apply formal reasoning to any contract change scenario
First, let's precisely define what "weaker" and "stronger" mean in contract terms.
In logic, we say proposition A is stronger than B if:
Conversely, A is weaker than B if:
Let's see concrete examples:
| Stronger Condition | Weaker Condition | Why? |
|---|---|---|
x > 0 | x >= 0 | Positive is a subset of non-negative |
x > 10 | x > 0 | x > 10 implies x > 0, but not vice versa |
x == 5 | x >= 0 && x <= 10 | Exactly 5 is more restrictive than 0-10 range |
list is sorted | list exists | Sorted lists are a subset of all lists |
user is admin | user is authenticated | Admins are authenticated, not all authenticated are admins |
false | true | False is never satisfied; true always is |
The key insight: A stronger condition is harder to satisfy because it imposes more requirements. A weaker condition is easier to satisfy because it accepts more cases.
Think of conditions as filters:
A stronger condition describes a smaller set of possibilities. A weaker condition describes a larger set of possibilities.
If set A ⊂ set B (A is a subset of B), then:
Now let's understand why preconditions and postconditions have opposite rules. It comes down to who is responsible and who relies on what.
Preconditions (Caller → Method):
The caller must satisfy preconditions. The caller is written to the parent's precondition. If the subclass has a stronger precondition (harder to satisfy), the caller might not meet it — breakage!
If the subclass has a weaker precondition (easier to satisfy), the caller definitely meets it. Whatever satisfies P_parent will also satisfy anything weaker.
Postconditions (Method → Caller):
The caller relies on postconditions. The caller is written to handle the parent's postcondition. If the subclass has a weaker postcondition (promises less), the caller might get something it doesn't know how to handle — breakage!
If the subclass has a stronger postcondition (promises more), the caller can definitely handle it. Whatever handles Q_parent can handle anything that implies Q_parent.
The formal rules: • Preconditions: P_parent → P_child (parent implies child) • Postconditions: Q_child → Q_parent (child implies parent)
Parent precondition must imply child precondition (child accepts at least what parent accepts). Child postcondition must imply parent postcondition (child delivers at least what parent promises).
The most reliable way to analyze contract changes is to think from the client's perspective. The client is written against the parent's contract. What happens when the subclass is substituted?
Let's develop a concrete mental model:
1234567891011121314151617181920212223242526272829303132333435363738394041
// ═══════════════════════════════════════════════════════════════// PARENT CONTRACT// ═══════════════════════════════════════════════════════════════ abstract class DataProcessor { /** * @precondition data.length >= 1 * @postcondition returns number in range [0, 100] */ abstract process(data: number[]): number;} // ═══════════════════════════════════════════════════════════════// CLIENT CODE (written to parent contract)// ═══════════════════════════════════════════════════════════════ function analyzeData(processor: DataProcessor, dataset: number[][]): void { for (const data of dataset) { // Client ENSURES: data.length >= 1 (satisfies precondition) if (data.length < 1) continue; // Skip empty arrays const result = processor.process(data); // Client EXPECTS: result in [0, 100] (relies on postcondition) const percentage = result / 100; // Safe division const category = getCategory(result); // Assumes 0-100 range displayResult(percentage, category); }} function getCategory(score: number): string { // Written assuming score in [0, 100] if (score < 50) return "Low"; if (score < 75) return "Medium"; return "High"; // 75-100} // ═══════════════════════════════════════════════════════════════// NOW: Analyze different subclass modifications// ═══════════════════════════════════════════════════════════════123456789101112131415161718
class FlexibleProcessor extends DataProcessor { /** * @precondition data.length >= 0 // WEAKER (accepts empty!) * @postcondition returns number in range [0, 100] */ process(data: number[]): number { if (data.length === 0) return 0; // Handles empty case return this.compute(data); }} // CLIENT ANALYSIS:// // Client sends: data.length >= 1 (always)// Subclass accepts: data.length >= 0 (always)// // Since 1 >= 1 implies 1 >= 0, client's input is ALWAYS valid.// ✅ SAFE: Client can use this subclass.For any contract change, ask:
If answers are Yes, Yes, No and No respectively — the change is safe.
The basic rules are simple, but real-world cases can be subtle. Let's explore tricky scenarios:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
// ═══════════════════════════════════════════════════════════════// EDGE CASE 1: Exception vs. Return Value// ═══════════════════════════════════════════════════════════════ class Parser { /** * @postcondition returns AST if valid, null if invalid */ parse(code: string): AST | null { try { return this.doParse(code); } catch { return null; } }} // Is this a violation?class StrictParser extends Parser { /** * @postcondition returns AST if valid, throws if invalid */ parse(code: string): AST { const result = super.parse(code); if (result === null) { throw new ParseError("Invalid syntax"); } return result; }} // ANALYSIS: This is SUBTLE.// // Return type changed from "AST | null" to "AST".// On success: Same behavior (returns AST)// On failure: Instead of returning, it throws//// This IS a weakening because the original postcondition said// "returns null if invalid." Not returning is different from// returning null. Client code doing://// const ast = parser.parse(code);// if (ast === null) { handleError(); }//// Will never reach handleError() — exception bypasses it.//// ⚠️ VERDICT: VIOLATION (though often acceptable in practice) // ═══════════════════════════════════════════════════════════════// EDGE CASE 2: Narrowing Type vs. Strengthening// ═══════════════════════════════════════════════════════════════ class Container { /** * @postcondition returns the stored value */ getValue(): object { return this.value; }} class TypedContainer extends Container { /** * @postcondition returns the stored value as User */ getValue(): User { // User extends object return this.value as User; }} // ANALYSIS: Return type is more specific.// // User ⊂ object (User is a subset of object)// So "returns User" → "returns object" (valid implication)//// ✅ VERDICT: SAFE strengthening // ═══════════════════════════════════════════════════════════════// EDGE CASE 3: Widening Type vs. Weakening Precondition// ═══════════════════════════════════════════════════════════════ class StringProcessor { /** * @precondition input is non-null string */ process(input: string): Result { return this.doProcess(input); }} class FlexibleProcessor extends StringProcessor { /** * @precondition input is string or null */ process(input: string | null): Result { if (input === null) { return Result.empty(); } return super.process(input); }} // ANALYSIS: Input type is broader.//// "non-null string" is a subset of "string or null"// Parent's valid inputs are still valid for child.//// ✅ VERDICT: SAFE weakening of precondition // ═══════════════════════════════════════════════════════════════// EDGE CASE 4: The "Extra Functionality" Trap// ═══════════════════════════════════════════════════════════════ class Logger { /** * @postcondition message is written to log file */ log(message: string): void { this.file.write(message); }} class MetricsLogger extends Logger { /** * @postcondition message is written to log file * @postcondition metrics are updated (NEW SIDE EFFECT) */ log(message: string): void { super.log(message); this.metrics.increment("log_count"); }} // ANALYSIS: Extra postcondition added.//// Original: message written// New: message written AND metrics updated//// "A AND B" → "A" (if both are true, A is true)// Stronger postcondition (more guaranteed).//// ✅ VERDICT: SAFE strengthening// (Client expects logs, gets logs + metrics — bonus!) // ═══════════════════════════════════════════════════════════════// EDGE CASE 5: The "Conditional Behavior" Trap// ═══════════════════════════════════════════════════════════════ class Cache { /** * @postcondition returns cached value if present, null if not */ get(key: string): T | null { return this.store.get(key) ?? null; }} class ExpiringCache extends Cache { /** * @postcondition returns cached value if present AND not expired * @postcondition returns null if not present OR expired */ get(key: string): T | null { const entry = this.store.get(key); if (!entry) return null; if (this.isExpired(entry)) { this.store.delete(key); // Evict expired return null; // Return null for expired } return entry.value; }} // ANALYSIS: This is SUBTLE.//// Original null postcondition: "null if not present"// New null postcondition: "null if not present OR expired"//// The subclass returns null in MORE cases (also when expired).// Client expecting a value (because it knows it put one in)// might get null if the value expired.//// Is this weakening? Depends on interpretation:// - If "present" means "was ever added", this is WEAKENING (violation)// - If "present" means "currently valid entry", this is PRESERVING//// ⚠️ VERDICT: DEPENDS ON CONTRACT INTERPRETATION// Best practice: Make the parent contract explicit about expirationMany real-world contracts are underspecified. When analyzing inheritance:
For complex cases, it helps to have a systematic approach. Here's a framework for formally analyzing whether a contract change is safe:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
// ═══════════════════════════════════════════════════════════════// APPLYING THE FRAMEWORK// ═══════════════════════════════════════════════════════════════ // PARENT CONTRACTclass SortingAlgorithm { /** * Sorts an array in ascending order. * * P_parent: array is not null * Q_parent: returned array is sorted ascending * returned array contains same elements as input */ sort(array: number[]): number[] { return [...array].sort((a, b) => a - b); }} // PROPOSED CHILDclass OptimizedSort extends SortingAlgorithm { /** * P_child: array is not null AND length < 1_000_000 * Q_child: returned array is sorted ascending * returned array contains same elements as input */ sort(array: number[]): number[] { if (array.length >= 1_000_000) { throw new Error("Array too large"); } // Uses O(n log n) algorithm with O(n) memory return this.mergeSort(array); }} // ═══════════════════════════════════════════════════════════════// STEP 1: Parent Contract// ═══════════════════════════════════════════════════════════════// P_parent: array != null// Q_parent: sorted ascending ∧ same elements // ═══════════════════════════════════════════════════════════════// STEP 2: Child Contract// ═══════════════════════════════════════════════════════════════// P_child: array != null ∧ array.length < 1_000_000// Q_child: sorted ascending ∧ same elements // ═══════════════════════════════════════════════════════════════// STEP 3: Check Precondition Rule (P_parent → P_child?)// ═══════════════════════════════════════════════════════════════// Does (array != null) → (array != null ∧ length < 1M)?// // NO! An array that is not null might have length >= 1M.// The implication doesn't hold.// // ❌ PRECONDITION VIOLATION // ═══════════════════════════════════════════════════════════════// STEP 4: Check Postcondition Rule (Q_child → Q_parent?)// ═══════════════════════════════════════════════════════════════// Does (sorted ∧ same elements) → (sorted ∧ same elements)?//// YES! Identical postconditions, so trivially true.//// ✅ POSTCONDITION OK // ═══════════════════════════════════════════════════════════════// STEP 5: Boundary Examples// ═══════════════════════════════════════════════════════════════// Client code might sort an array of 2,000,000 elements.// Parent: Works fine// Child: Throws exception! // ═══════════════════════════════════════════════════════════════// STEP 6: Client Perspective// ═══════════════════════════════════════════════════════════════// A client using SortingAlgorithm for large datasets would break.// The child rejects inputs the parent accepts. // ═══════════════════════════════════════════════════════════════// CONCLUSION: OptimizedSort VIOLATES LSP// ═══════════════════════════════════════════════════════════════// To fix: Make large arrays a capability of the BASE class// or use composition instead of inheritance.Before we summarize, let's briefly connect to invariants — the third element of Design by Contract that we'll cover fully in the next page.
Invariants are conditions that must be true at all times (or at least, at all observable moments). They bridge preconditions and postconditions:
| Element | When It Applies | LSP Rule | Example |
|---|---|---|---|
| Precondition | At method start | May weaken | age > 0 |
| Postcondition | At method end | May strengthen | name is non-empty after save |
| Invariant | Always (entry/exit of any public method) | May strengthen | balance >= 0 always |
Invariants are postconditions that apply to every method — conditions that are maintained across the object's lifetime. Like postconditions, subclasses may only strengthen invariants, never weaken them.
If a parent class guarantees balance >= 0, a subclass can guarantee balance >= 100 (stronger), but never balance can be negative (weaker).
The next page explores invariants in detail: what they are, how they differ from postconditions, and how to reason about them in inheritance hierarchies. For now, know that they follow the same "strengthening only" rule as postconditions.
Here's a practical decision tree for analyzing contract modifications:
What's next:
We've covered preconditions, postconditions, and the logic of weakening/strengthening. The next page brings everything together with Contract Rules for LSP — a comprehensive treatment of how preconditions, postconditions, and invariants combine into the complete behavioral contract that governs proper inheritance.
You now have a reliable intuition for analyzing contract modifications. The rules that seemed counterintuitive at first should now feel natural: subclasses that accept more and deliver more are safe substitutes.