Loading content...
You're debugging a mysterious issue in your notification system. Users report that some notifications never arrive. The code looks correct:
function notifyAll(users: User[], message: string): void {
const notificationService = getNotificationService();
for (const user of users) {
notificationService.send(user, message);
logger.log(`Sent notification to ${user.email}`);
}
}
The logs show messages going out. No exceptions. The send() method is being called for every user. Yet certain users never receive notifications.
After hours of investigation, you discover the culprit: MockNotificationService—a test double that was accidentally deployed to production. Its send() method looks like this:
send(user: User, message: string): void {
// Mock implementation - does nothing
}
The method signature matches. The types check out. No exceptions are thrown. But the behavioral contract—"sending actually delivers a notification"—is completely unfulfilled. This is a partial implementation, and it's one of the most insidious forms of LSP violation.
By the end of this page, you will understand partial implementations as LSP violations, recognize the various forms they take (no-ops, stubs, default returns), understand why they're harder to detect than exception-throwing violations, and learn design strategies that prevent partial implementations from entering your codebase.
A partial implementation is a subclass that provides method implementations which are syntactically correct but semantically incomplete. The methods exist, they can be called, they don't throw exceptions—but they don't actually fulfill the behavioral contract the base class establishes.
save(): void { }calculate(): number { return 0; }12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
// TYPE 1: No-op methodsclass NullLogger implements Logger { log(message: string): void { // Does nothing - but callers expect logging to occur } error(message: string, error: Error): void { // Also does nothing - errors are silently swallowed }} // TYPE 2: Stub returnsclass FakeAuthService implements AuthService { authenticate(credentials: Credentials): User | null { // Always returns null - never actually authenticates return null; } isAuthenticated(): boolean { // Always returns false - never tracks auth state return false; }} // TYPE 3: Silent failuresclass BestEffortCache implements Cache<string, any> { get(key: string): any | undefined { try { return this.internalGet(key); } catch { // Silently swallows errors - contract implies throwing on failure return undefined; } } set(key: string, value: any): void { try { this.internalSet(key, value); } catch { // Silently fails - caller thinks value is cached } }} // TYPE 4: Degraded functionalityclass QuickDataProcessor implements DataProcessor { process(data: RawData): ProcessedData { // Contract implies validation, transformation, and enrichment // This implementation skips validation and enrichment return this.transformOnly(data); // Partial fulfillment }} // TYPE 5: Optional behavior omissionclass SimpleRepository implements Repository<Entity> { save(entity: Entity): void { this.database.insert(entity); // Contract implies firing events, updating audit log // This implementation omits those }}The deceptive nature of partial implementations:
Unlike exception-throwing violations (which at least fail explicitly), partial implementations appear to work. They compile. They run. They don't crash. Client code continues executing without any indication that something is wrong.
This makes them far more dangerous than explicit failures. The system operates in a degraded state—missing logs, lost data, incomplete processing—without any alarms firing.
Partial implementations cause silent corruption. Data is lost without errors. Logs that should exist don't. Invariants that should hold don't. The system appears healthy while accumulating invisible damage. By the time problems surface, the root cause is often far removed from the symptom.
Partial implementations don't emerge from malice—they emerge from design pressures that push developers toward incomplete solutions. Understanding these pressures helps prevent them.
| Cause | How It Leads to Partial Implementation | Example |
|---|---|---|
| Interface too large | Subclass can only meaningfully implement some methods | A FileSystem interface with sync and async methods, where some implementations only support one |
| Test doubles in wrong context | Mock/stub objects designed for testing used in production | MockPaymentGateway deployed accidentally |
| Gradual feature development | Methods added as stubs with intent to 'finish later' | // TODO: implement caching that stays forever |
| Different capability profiles | Subclasses genuinely can't do everything the parent can | A PrintableDocument where EncryptedDocument can print only under certain conditions |
| Decorator/Wrapper patterns misuse | Wrapper doesn't forward all behavior correctly | CachingRepository.save() caches but doesn't trigger events |
| Copy-paste inheritance | Copy a class, remove the parts you don't need, provide stubs | Copying FullReportGenerator to make SummaryReportGenerator |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788
// CAUSE 1: Interface too largeinterface DataStore { get(key: string): Promise<any>; set(key: string, value: any): Promise<void>; delete(key: string): Promise<void>; list(): Promise<string[]>; watch(key: string, callback: (value: any) => void): void; transaction<T>(fn: (tx: Transaction) => T): Promise<T>; backup(): Promise<Blob>; restore(backup: Blob): Promise<void>;} // Some implementations can't do everythingclass SimpleMemoryStore implements DataStore { get(key: string): Promise<any> { /* works */ } set(key: string, value: any): Promise<void> { /* works */ } delete(key: string): Promise<void> { /* works */ } list(): Promise<string[]> { /* works */ } watch(key: string, callback: (value: any) => void): void { // Stub - can't implement reactive watching in simple memory store } transaction<T>(fn: (tx: Transaction) => T): Promise<T> { // Stub - no real transaction support return fn(null as any); } backup(): Promise<Blob> { // Stub - returns empty blob return Promise.resolve(new Blob()); } restore(backup: Blob): Promise<void> { // Stub - does nothing return Promise.resolve(); }} // CAUSE 2: Decorator that doesn't forward correctlyclass CachingUserRepository implements UserRepository { constructor(private inner: UserRepository, private cache: Cache) {} async find(id: string): Promise<User> { const cached = this.cache.get(id); if (cached) return cached; const user = await this.inner.find(id); this.cache.set(id, user); return user; } async save(user: User): Promise<void> { await this.inner.save(user); this.cache.set(user.id, user); // MISSING: inner.save() fires domain events // This decorator swallows them by not forwarding } async findByEmail(email: string): Promise<User | null> { // PARTIAL: Only caches by ID, not email // Repeated email lookups hit database every time return this.inner.findByEmail(email); }} // CAUSE 3: Test double used in productionclass TestPaymentProcessor implements PaymentProcessor { // Designed for unit tests - tracks calls but doesn't process private calls: Payment[] = []; process(payment: Payment): Receipt { this.calls.push(payment); // Returns fake receipt - no actual processing return new Receipt('fake-id', payment.amount, 'test'); } refund(receiptId: string): RefundResult { // Always succeeds - because it's a test double return { success: true }; } // Used in tests to verify behavior getCalls(): Payment[] { return this.calls; }} // Somewhere in configuration:if (config.useMockPayments) { container.register(PaymentProcessor, TestPaymentProcessor); // If this flag is mistakenly true in production...}Most partial implementations stem from a single root cause: the interface is defined without considering whether all implementations can fulfill it completely. The cure is to design interfaces around what implementations can actually do, not what would be theoretically useful.
Partial implementations create a spectrum of problems, from subtle behavioral inconsistencies to catastrophic data loss. The severity depends on which parts of the contract are unfulfilled.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
// CONSEQUENCE: Data Lossclass QuickExportService implements ExportService { exportToFile(data: Report, path: string): void { // Contract: Export data to file // Partial: Skips large reports "for performance" if (data.rows.length > 10000) { // Silently does nothing - data never exported return; } fs.writeFileSync(path, serialize(data)); }} // User exports a large report, thinks it's saved, deletes source data... // CONSEQUENCE: State Inconsistencyclass CachedOrderService implements OrderService { submitOrder(order: Order): OrderConfirmation { // Contract: Submit order, update inventory, notify warehouse const confirmation = this.orderRepository.save(order); this.cache.invalidate(order.customerId); // Cache updated // PARTIAL: Inventory and warehouse notification not implemented yet // Inventory remains out of sync with actual orders return confirmation; }} // CONSEQUENCE: Silent Failuresclass ResilientEmailSender implements EmailSender { send(email: Email): void { try { this.smtpClient.send(email); } catch (error) { // "Resilient" means swallowing all errors // Critical password reset emails silently fail } }} // CONSEQUENCE: Debugging Nightmareasync function processWorkflow(engine: WorkflowEngine): Promise<void> { await engine.startWorkflow('approval-flow', { documentId: 123 }); // Workflow started successfully (according to the API) // Days later: "Why is this document not approved?" // Investigation reveals: TestWorkflowEngine was injected // startWorkflow() returns successfully but does nothing} // CONSEQUENCE: Trust Erosion (the meta-consequence)class DefensiveOrderProcessor { async process(cart: ShoppingCart): Promise<void> { // Developer has been burned by partial implementations // Now adds verification everywhere const order = await this.orderService.create(cart); // Don't trust that create() actually created it const verified = await this.orderService.find(order.id); if (!verified) { throw new Error("Order creation returned but didn't persist"); } await this.inventoryService.reserve(order.items); // Don't trust that reserve() actually reserved for (const item of order.items) { const stock = await this.inventoryService.getStock(item.sku); if (stock.reserved < item.quantity) { throw new Error("Reservation returned but didn't reserve"); } } // The entire codebase becomes verification-heavy // All because abstractions can't be trusted }}Once partial implementations erode trust, developers add verification code everywhere. This verification code adds latency, complexity, and its own bugs. The system becomes slower and more fragile, not from the original violation, but from the defensive response to it.
Unlike exception-throwing violations, partial implementations don't announce themselves. Detection requires proactive techniques that go beyond static analysis.
123456789101112131415
# Find empty method bodies# TypeScript/JavaScript - methods that just return or are emptygrep -rPn "\{\s*\}|\{\s*return;?\s*\}|\{\s*return \w+;\s*\}" --include="*.ts" src/ # Find TODO markers in method bodiesgrep -rn "TODO\|FIXME\|HACK\|XXX" --include="*.ts" src/ | grep -v test # Find methods returning default valuesgrep -rPn "return null;|return \[\];|return \{\};|return 0;|return '';|return false;" --include="*.ts" src/ # Find empty catch blocks (silent failures)grep -rPn "catch.*\{\s*\}" --include="*.ts" src/ # Python equivalentsgrep -rn "pass$\|return None$\|return \[\]$" --include="*.py" src/For every method in your contract, ask: 'How would I observe that this method actually did its job?' If you can't observe any effect, you can't verify the contract. Design interfaces so that fulfillment is observable—through return values, side effects queries, or events.
Prevention is far cheaper than detection and remediation. These design strategies minimize the risk of partial implementations emerging in your codebase.
Result<T> instead of throwing or silently failing123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
// STRATEGY 1: Minimal Interfaces // BAD: Large interface forces partial implementationsinterface DataStore { get(key: string): Promise<any>; set(key: string, value: any): Promise<void>; delete(key: string): Promise<void>; list(): Promise<string[]>; watch(key: string, callback: Callback): Subscription; transaction<T>(fn: (tx: Transaction) => T): Promise<T>; backup(): Promise<Blob>; restore(backup: Blob): Promise<void>;} // GOOD: Split into minimal capability interfacesinterface KeyValueStore { get(key: string): Promise<any | undefined>; set(key: string, value: any): Promise<void>; delete(key: string): Promise<void>;} interface ListableStore { list(prefix?: string): Promise<string[]>;} interface WatchableStore { watch(key: string, callback: Callback): Subscription;} interface TransactionalStore { transaction<T>(fn: (tx: Transaction) => T): Promise<T>;} interface BackupableStore { backup(): Promise<Blob>; restore(backup: Blob): Promise<void>;} // Implementations declare exactly what they supportclass SimpleMemoryStore implements KeyValueStore, ListableStore { // Only implements what it can fully support} class RedisStore implements KeyValueStore, ListableStore, WatchableStore, TransactionalStore { // Implements everything it truly supports} // STRATEGY 2: Strong Test Double Separation // BAD: Test double shares production interfaceclass MockEmailSender implements EmailSender { send(email: Email): void { // Does nothing - partial implementation }} // GOOD: Test infrastructure is completely separateinterface EmailSenderSpy { readonly sentEmails: Email[]; readonly sendCalled: boolean; simulateFailure(error: Error): void;} // Factory creates test-specific implementationsfunction createEmailSenderForTesting(): EmailSender & EmailSenderSpy { const sent: Email[] = []; let failWith: Error | null = null; return { get sentEmails() { return sent; }, get sendCalled() { return sent.length > 0; }, simulateFailure(error) { failWith = error; }, send(email) { if (failWith) throw failWith; sent.push(email); // Actually tracks behavior } };} // Test code explicitly uses test typeconst testSender = createEmailSenderForTesting();// TypeScript won't let you use this where production EmailSender is needed// (unless you explicitly cast, which is a code smell) // STRATEGY 3: Result Types for Fallible Operations // BAD: Silent failure possibleinterface NotificationSender { send(notification: Notification): void;} // GOOD: Failure is part of the return typeinterface NotificationSender { send(notification: Notification): SendResult;} type SendResult = | { success: true; messageId: string } | { success: false; error: NotificationError }; // Implementations MUST return a result, can't silently failclass EmailNotificationSender implements NotificationSender { send(notification: Notification): SendResult { try { const id = this.smtp.send(notification); return { success: true, messageId: id }; } catch (error) { // CANNOT silently swallow - must return failure return { success: false, error: new NotificationError(error) }; } }} // STRATEGY 4: Decorator Verification // Helper that ensures decorators forward all behaviorfunction createVerifiedDecorator<T>( inner: T, wrapper: T, methodsToVerify: (keyof T)[]): T { return new Proxy(wrapper, { get(target, prop) { const key = prop as keyof T; // Verify method exists on both if (methodsToVerify.includes(key)) { if (!(key in inner)) { throw new Error(`Decorator missing forwarding for ${String(key)}`); } } return target[key]; } });}Design interfaces around the least capable implementation you need to support. If one implementation can't do transactions, don't put transactions in the base interface. If one implementation can't watch for changes, create a separate WatchableStore interface. This prevents forcing any implementation into partial compliance.
When you've identified partial implementations in an existing codebase, systematic remediation restores LSP compliance without breaking the system.
supports*() methods and documentation1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
// BEFORE: Partial implementation probleminterface ReportGenerator { generatePdf(data: ReportData): Buffer; generateExcel(data: ReportData): Buffer; generateCsv(data: ReportData): string; generateHtml(data: ReportData): string;} // SimpleReportGenerator can't do Excelclass SimpleReportGenerator implements ReportGenerator { generatePdf(data: ReportData): Buffer { /* works */ } generateExcel(data: ReportData): Buffer { return Buffer.alloc(0); // PARTIAL: Returns empty buffer } generateCsv(data: ReportData): string { /* works */ } generateHtml(data: ReportData): string { /* works */ }} // REMEDIATION OPTION 1: Split the interfaceinterface BaseReportGenerator { generatePdf(data: ReportData): Buffer; generateCsv(data: ReportData): string; generateHtml(data: ReportData): string;} interface ExcelCapableGenerator extends BaseReportGenerator { generateExcel(data: ReportData): Buffer;} class SimpleReportGenerator implements BaseReportGenerator { // Now only claims what it can do} class FullReportGenerator implements ExcelCapableGenerator { // Implements everything including Excel} // REMEDIATION OPTION 2: Complete the implementationclass SimpleReportGenerator implements ReportGenerator { private excelLib = new ExcelLibrary(); generateExcel(data: ReportData): Buffer { // Actually implement Excel generation return this.excelLib.generate(data); }} // REMEDIATION OPTION 3: Change to result-based contractinterface ReportGenerator { generate(data: ReportData, format: ReportFormat): GenerateResult; getSupportedFormats(): ReportFormat[];} type GenerateResult = | { success: true; content: Buffer | string } | { success: false; error: 'unsupported' | 'generation_failed' }; class SimpleReportGenerator implements ReportGenerator { getSupportedFormats(): ReportFormat[] { return ['pdf', 'csv', 'html']; // Explicitly no Excel } generate(data: ReportData, format: ReportFormat): GenerateResult { if (!this.getSupportedFormats().includes(format)) { return { success: false, error: 'unsupported' }; } // ... generate supported format }} // Clients check support before calling, or handle result gracefullyYou now understand partial implementations as a subtle but dangerous form of LSP violation. You can detect them through contract testing and behavioral verification, prevent them through minimal interface design, and remediate them through interface splitting or implementation completion. The next page covers systematic refactoring strategies for fixing LSP violations.
Next up:
The final page of this module presents systematic refactoring strategies for LSP—patterns and techniques for transforming LSP-violating code into properly substitutable hierarchies while maintaining system stability throughout the refactoring process.