Loading content...
You're reviewing a pull request when something catches your eye. A colleague has added this method to a payment processing service:
function processPayment(payment: Payment): Result {
if (payment instanceof CreditCardPayment) {
return processCreditCard(payment);
} else if (payment instanceof PayPalPayment) {
return processPayPal(payment);
} else if (payment instanceof CryptoPayment) {
return processCrypto(payment);
} else {
throw new Error("Unknown payment type");
}
}
On the surface, this looks functional. It handles all current payment types. The code compiles. Tests pass. But experienced engineers feel an immediate sense of unease seeing code like this.
Why? Because this pattern—checking the concrete type of an object at runtime—is one of the most reliable indicators that something is fundamentally wrong with the design. It signals that the Payment abstraction has failed. The type hierarchy isn't working. Polymorphism has been abandoned.
This is the type checking smell, and it's your first and most reliable detector for Liskov Substitution Principle violations.
By the end of this page, you will understand why type checking in client code is a critical design smell, how to recognize its various forms, why it indicates LSP violations rather than just style issues, and how to systematically eliminate it through proper abstraction design.
When client code uses instanceof, typeof, type guards, or similar mechanisms to determine an object's concrete type before deciding how to interact with it, we call this the type checking smell. It appears in several forms:
instanceof chains — Explicit checks against concrete class types123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
// VARIANT 1: instanceof chainsfunction calculateArea(shape: Shape): number { if (shape instanceof Circle) { return Math.PI * shape.radius ** 2; } else if (shape instanceof Rectangle) { return shape.width * shape.height; } else if (shape instanceof Triangle) { return 0.5 * shape.base * shape.height; } throw new Error("Unknown shape");} // VARIANT 2: Type discriminator fieldinterface Shape { type: "circle" | "rectangle" | "triangle";} function calculateArea(shape: Shape): number { switch (shape.type) { case "circle": return Math.PI * (shape as Circle).radius ** 2; case "rectangle": return (shape as Rectangle).width * (shape as Rectangle).height; case "triangle": return 0.5 * (shape as Triangle).base * (shape as Triangle).height; }} // VARIANT 3: Capability checkingfunction exportShape(shape: Shape): void { if ('exportToSvg' in shape && typeof shape.exportToSvg === 'function') { shape.exportToSvg(); } else if ('exportToPng' in shape && typeof shape.exportToPng === 'function') { shape.exportToPng(); } else { throw new Error("Shape cannot be exported"); }} // VARIANT 4: Defensive castingfunction processNotification(notification: Notification): void { const email = notification as EmailNotification; if (email.emailAddress) { sendEmail(email); } else { const sms = notification as SmsNotification; if (sms.phoneNumber) { sendSms(sms); } }}Each variant expresses the same fundamental problem: the code that consumes the abstraction doesn't trust the abstraction to handle the operation uniformly. Instead, it inspects the concrete type and implements different logic for each case.
This distrust isn't irrational—it exists because the abstraction isn't providing what the client needs. The type hierarchy has failed to capture the behavioral commonality that would make uniform treatment possible.
Type checking in client code isn't merely a style issue or a minor design smell—it's a direct consequence of LSP violations. Let's understand the causal chain:
The LSP promise: If S is a subtype of T, then code written against T should work unchanged with any S.
What this means for methods: Every method on T should be safely callable on any subtype, producing consistent behavior that the caller can rely upon.
When LSP is violated: One or more subtypes don't fulfill the behavioral contract of the parent. They might:
The client's forced response: When subtypes don't behave uniformly, client code must compensate. It cannot call methods blindly—it must first determine which subtype it has, then apply type-specific handling.
| LSP Violation Pattern | Client's Forced Workaround | Type Check Manifestation |
|---|---|---|
| Subtype throws exception for unsupported operation | Must check type before calling | if (!(shape instanceof NonRotatable)) shape.rotate() |
| Subtype returns null where parent returns value | Must check type and handle null conditionally | if (payment instanceof CashPayment) return 0; return payment.getTransactionId() |
| Subtype has different behavior for same method | Must branch logic by type | if (user instanceof AdminUser) processAdmin(user); else processRegular(user) |
| Subtype requires additional preconditions | Must check type to validate properly | if (doc instanceof SecureDocument) validateAccess(doc); doc.open() |
| Subtype provides extended capabilities | Must check type to access extensions | if (vehicle instanceof ElectricVehicle) vehicle.chargeBattery() |
Think of type checks as symptoms, not diseases. When you see an instanceof check, don't just remove it—ask why it exists. Someone added it because the abstraction wasn't providing what they needed. The type check is evidence of an underlying design problem.
The causality runs in one direction:
LSP Violation → Subtypes don't behave uniformly → Client can't rely on abstraction → Type checking becomes necessary
This means you cannot fix the problem by simply removing type checks. If you delete the instanceof statements without addressing the underlying LSP violation, you'll get runtime errors, crashes, or incorrect behavior. The type checks exist for a reason—they're compensating for broken polymorphism.
The real fix requires fixing the abstraction itself.
Type checking might seem like a pragmatic solution—"just add an instanceof check and move on." But this approach carries severe costs that compound over time, eventually creating architectural debt that becomes extremely expensive to address.
BankTransferPayment means hunting down and updating every instanceof chain.instanceof chains. The compiler can't help; these become runtime bugs that may not surface until production.123456789101112131415161718192021222324252627282930313233343536373839404142434445
// The OCP disaster: Every location needs updating for new types // Location 1: API Handlerfunction handlePaymentRequest(payment: Payment): Response { if (payment instanceof CreditCardPayment) { /* ... */ } else if (payment instanceof PayPalPayment) { /* ... */ } else if (payment instanceof CryptoPayment) { /* ... */ } // NEW TYPE: Must add BankTransferPayment case} // Location 2: Validation Servicefunction validatePayment(payment: Payment): ValidationResult { if (payment instanceof CreditCardPayment) { /* card-specific validation */ } else if (payment instanceof PayPalPayment) { /* paypal-specific validation */ } else if (payment instanceof CryptoPayment) { /* crypto-specific validation */ } // NEW TYPE: Must add BankTransferPayment case} // Location 3: Risk Assessmentfunction assessRisk(payment: Payment): RiskScore { if (payment instanceof CreditCardPayment) { /* card risk */ } else if (payment instanceof PayPalPayment) { /* paypal risk */ } else if (payment instanceof CryptoPayment) { /* crypto risk */ } // NEW TYPE: Must add BankTransferPayment case} // Location 4: Reportingfunction getPaymentCategory(payment: Payment): string { if (payment instanceof CreditCardPayment) return "card"; if (payment instanceof PayPalPayment) return "digital_wallet"; if (payment instanceof CryptoPayment) return "cryptocurrency"; // NEW TYPE: Must add BankTransferPayment case} // Location 5: Refund Handlerfunction processRefund(payment: Payment): RefundResult { if (payment instanceof CreditCardPayment) { /* card refund */ } else if (payment instanceof PayPalPayment) { /* paypal refund */ } else if (payment instanceof CryptoPayment) { /* crypto refund */ } // NEW TYPE: Must add BankTransferPayment case} // PROBLEM: Adding one new payment type requires changing 5+ files// PROBLEM: Forgetting one location creates a silent bug// PROBLEM: The compiler cannot help you find all locationsThe arithmetic of type-checking debt:
Consider a system with:
Current state: 20 functions × 10 types × 15 lines = 3,000 lines of type-specific code
Adding one new type: 20 functions need modification = 20 PRs or one massive PR with 300+ new lines
Removing one type: Same 20 locations need updating
Restructuring the hierarchy: Every function must be rewritten
As the system grows, this pattern creates exponential complexity. What starts as "pragmatic" becomes an architectural straitjacket.
Type checks breed more type checks. Once the pattern exists, it becomes the path of least resistance for handling new requirements. Each addition makes the proper fix more expensive, creating a debt spiral that can eventually require ground-up rewrites.
Type checking doesn't always appear as obvious instanceof chains. In production codebases, it manifests in subtler forms that are equally problematic. Learning to recognize these patterns is essential for effective code review and refactoring.
instanceof keyword usagetypeof on object fields.supportsFeature())12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
// SUBTLE FORM 1: Capability flagsclass Document { supportsPrinting: boolean = false; supportsEditing: boolean = false; print(): void { if (!this.supportsPrinting) { throw new Error("Printing not supported"); } // This IS type checking - checking capability IS checking type }} // Client must check:if (doc.supportsPrinting) { doc.print(); // Still a type check in disguise!} // SUBTLE FORM 2: Strategy maps keyed by typeconst paymentProcessors: Record<string, PaymentProcessor> = { 'CreditCardPayment': new CreditCardProcessor(), 'PayPalPayment': new PayPalProcessor(), 'CryptoPayment': new CryptoProcessor(),}; function process(payment: Payment): void { const processor = paymentProcessors[payment.constructor.name]; // Using constructor.name IS instanceof in disguise if (!processor) throw new Error("Unknown payment type"); processor.process(payment);} // SUBTLE FORM 3: Method existence checkingfunction exportDocument(doc: Document): void { // Duck typing IS type checking if ('toHtml' in doc) { return (doc as any).toHtml(); } if ('toMarkdown' in doc) { return (doc as any).toMarkdown(); } throw new Error("No export capability");} // SUBTLE FORM 4: Visitor accepting concrete typesinterface ShapeVisitor { visitCircle(circle: Circle): void; visitRectangle(rect: Rectangle): void; visitTriangle(tri: Triangle): void; // Still type-aware! Just moves the checks to dispatch} // SUBTLE FORM 5: Type-based factory selectionfunction createHandler(entity: Entity): Handler { // Even "clean" factory patterns can hide type checking const handlerType = HANDLER_REGISTRY[entity.getType()]; return new handlerType(entity);}The common thread: In each case, the client code is making decisions based on the concrete type of an object rather than treating all instances of the abstraction uniformly.
Key insight: The Visitor pattern (Form 4) is interesting because it's sometimes presented as a "proper" solution. But the Visitor pattern is embracing type checking rather than eliminating it—it formalizes the double dispatch that instanceof provides. This can be appropriate when you genuinely need type-specific behavior external to the types themselves, but it doesn't solve LSP violations.
When is type checking acceptable?
Legitimate type checking occurs at system boundaries or in infrastructure code. Problematic type checking occurs in business logic where polymorphism should be handling the variation. Ask: 'Should this code need to know the concrete type?' If the answer is no, you've found an LSP violation symptom.
Finding type checks in large codebases requires systematic approaches. Here are proven strategies used by experienced engineers to surface LSP violations hidden in production code.
instanceof, typeof, .constructor.name, type discriminator patterns123456789101112131415161718192021
# Find instanceof usagegrep -rn "instanceof" --include="*.ts" --include="*.tsx" src/ # Find type discriminator patternsgrep -rn "\.type ===" --include="*.ts" src/grep -rn "\[.*\.type\]" --include="*.ts" src/ # Find constructor.name usagegrep -rn "constructor\.name" --include="*.ts" src/ # Find typeof on objects (not primitives)grep -rn "typeof.*=== ['"]object['"]" --include="*.ts" src/ # Find 'in' operator for capability checkinggrep -rn "if.*'\w\+' in " --include="*.ts" src/ # Find hasOwnProperty checksgrep -rn "hasOwnProperty" --include="*.ts" src/ # Count occurrences by file to find hotspotsgrep -rn "instanceof" --include="*.ts" src/ | cut -d: -f1 | sort | uniq -c | sort -rn | head -20Automated detection with linting rules:
Many organizations create custom ESLint or similar rules to catch type checking patterns during development:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
// Example ESLint rule structure for detecting instanceof in business logic// Note: This is pseudocode illustrating the concept const lspInstanceofRule = { meta: { type: "suggestion", docs: { description: "Warn against instanceof in business logic (LSP violation symptom)", }, }, create(context) { return { BinaryExpression(node) { if (node.operator === "instanceof") { // Check if we're in business logic (not serialization, tests, etc.) const filePath = context.getFilename(); if (!isInfrastructureCode(filePath) && !isTestCode(filePath)) { context.report({ node, message: "instanceof in business logic suggests LSP violation. " + "Consider polymorphic dispatch instead.", }); } } }, }; },}; // Companion rule for type discriminatorsconst lspTypeDiscriminatorRule = { create(context) { return { SwitchStatement(node) { // Check if switching on a .type property if (isTypeDiscriminatorSwitch(node)) { context.report({ node, message: "Switch on type discriminator may indicate LSP violation. " + "Consider polymorphic method.", }); } }, }; },};The most effective teams integrate LSP violation detection into their CI/CD pipelines. Add grep-based checks or custom lint rules that run on every PR. Even if they're warnings rather than errors, they create awareness and prompt discussions during code review.
Finding type checks is only the first step. The real work is understanding why they exist and how to eliminate them through proper abstraction design.
The investigation process:
When you find a type check, don't immediately try to remove it. Instead:
123456789101112131415161718192021222324252627282930313233343536373839404142434445
// FOUND: Type check in payment processingfunction getProcessingFee(payment: Payment): Money { if (payment instanceof CreditCardPayment) { return payment.amount.multiply(0.029).add(Money.cents(30)); } else if (payment instanceof PayPalPayment) { return payment.amount.multiply(0.034); } else if (payment instanceof CryptoPayment) { return Money.cents(0); // No fee } throw new Error("Unknown payment type");} // INVESTIGATION:// Q: What varies by type? → The fee calculation formula// Q: Why is it external? → Someone thought "fee" wasn't a Payment responsibility// Q: Should it be in abstraction? → Yes! Each payment knows its own fee structure// Q: Missing abstraction? → Payment should have getProcessingFee() method // RESOLUTION: Move fee calculation into the typesabstract class Payment { abstract getProcessingFee(): Money;} class CreditCardPayment extends Payment { getProcessingFee(): Money { return this.amount.multiply(0.029).add(Money.cents(30)); }} class PayPalPayment extends Payment { getProcessingFee(): Money { return this.amount.multiply(0.034); }} class CryptoPayment extends Payment { getProcessingFee(): Money { return Money.cents(0); }} // Client code becomes:function getProcessingFee(payment: Payment): Money { return payment.getProcessingFee(); // Polymorphism!}Common resolution patterns:
| Pattern | Approach | When to Use |
|---|---|---|
| Push method to base class | Add abstract method to parent, implement in each subtype | When each subtype has its own version of the behavior |
| Extract to interface | Create interface for the capability, implement where appropriate | When only some subtypes have the capability |
| Strategy injection | Move varying behavior into injected strategies | When behavior varies independently from type identity |
| Visitor pattern | Use double dispatch to external behavior | When behavior must be external AND new operations are frequent |
| Hierarchy restructure | Redesign the type hierarchy to eliminate partial implementations | When the hierarchy itself is wrong |
You now understand type checking in client code as the primary symptom of LSP violations. You can recognize its various forms—from obvious instanceof chains to subtle capability flags—and understand why it creates significant architectural debt. The next page explores another critical symptom: unexpected exceptions from subclasses.
Moving forward:
The next page examines a related symptom: unexpected exceptions from subclasses. When subtypes throw exceptions that clients don't expect, it's another form of behavioral non-substitutability that creates fragile systems and defensive code patterns.