The Horror: Real Code from a Production Codebase
Last week, I was asked to add a new payment method to an e-commerce app. I opened the checkout file and found this:
function processPayment(order: Order, paymentMethod: string, user: User) {
if (paymentMethod === 'credit_card') {
if (user.country === 'US') {
if (order.total > 1000) {
if (user.isVerified) {
// Process with Stripe, high-value US customer
return stripeProcess(order, 'premium');
} else {
// Require verification first
return { error: 'Verification required for orders over $1000' };
}
} else {
// Standard US credit card
return stripeProcess(order, 'standard');
}
} else if (user.country === 'EU') {
if (order.total > 500) {
// EU high value needs 3DS
return stripeProcess(order, '3ds_required');
} else {
return stripeProcess(order, 'standard');
}
} else {
// International
if (SUPPORTED_COUNTRIES.includes(user.country)) {
return stripeProcess(order, 'international');
} else {
return { error: 'Country not supported' };
}
}
} else if (paymentMethod === 'paypal') {
if (user.country === 'US' || user.country === 'EU') {
if (order.total > 2000) {
return { error: 'PayPal limit exceeded' };
}
return paypalProcess(order);
} else {
return { error: 'PayPal not available in your region' };
}
} else if (paymentMethod === 'crypto') {
if (user.isVerified) {
if (order.total > 10000) {
return { error: 'Crypto limit exceeded' };
}
return cryptoProcess(order);
} else {
return { error: 'Verification required for crypto' };
}
} else if (paymentMethod === 'bank_transfer') {
// ... another 50 lines
}
return { error: 'Unknown payment method' };
}This is If-Else Hell. The code works, but:
Step 1: Extract Guard Clauses
The first refactor: move validation checks to the top and return early.
function processPayment(order: Order, paymentMethod: string, user: User) {
// Guard clauses - fail fast
if (!VALID_PAYMENT_METHODS.includes(paymentMethod)) {
return { error: 'Unknown payment method' };
}
if (!SUPPORTED_COUNTRIES.includes(user.country)) {
return { error: 'Country not supported' };
}
if (paymentMethod === 'crypto' && !user.isVerified) {
return { error: 'Verification required for crypto' };
}
if (paymentMethod === 'credit_card' && order.total > 1000 && !user.isVerified) {
return { error: 'Verification required for orders over $1000' };
}
// Now the happy path is cleaner...
}Guard clauses reduce nesting by handling edge cases first.
Step 2: Replace Conditionals with Lookup Tables
Instead of if-else chains for configuration, use objects:
const PAYMENT_LIMITS = {
paypal: { US: 2000, EU: 2000, default: 0 },
crypto: { default: 10000 },
credit_card: { default: Infinity },
bank_transfer: { default: 50000 },
};
const REGION_RESTRICTIONS = {
paypal: ['US', 'EU', 'UK', 'CA'],
crypto: ['US', 'EU', 'UK', 'SG', 'JP'],
credit_card: SUPPORTED_COUNTRIES,
bank_transfer: ['US', 'EU'],
};
function getPaymentLimit(method: string, country: string): number {
const limits = PAYMENT_LIMITS[method];
return limits[country] ?? limits.default ?? 0;
}
function isRegionSupported(method: string, country: string): boolean {
return REGION_RESTRICTIONS[method]?.includes(country) ?? false;
}Now adding a new country or adjusting limits is a config change, not a code change.
Step 3: Strategy Pattern for Payment Processors
Each payment method has different processing logic. Use the Strategy pattern:
interface PaymentProcessor {
process(order: Order, user: User): Promise<PaymentResult>;
validate(order: Order, user: User): ValidationResult;
}
class StripeProcessor implements PaymentProcessor {
async process(order: Order, user: User): Promise<PaymentResult> {
const tier = this.determineTier(order, user);
return stripeProcess(order, tier);
}
private determineTier(order: Order, user: User): string {
if (user.country === 'EU' && order.total > 500) return '3ds_required';
if (user.country === 'US' && order.total > 1000) return 'premium';
if (!['US', 'EU'].includes(user.country)) return 'international';
return 'standard';
}
validate(order: Order, user: User): ValidationResult {
if (order.total > 1000 && !user.isVerified) {
return { valid: false, error: 'Verification required' };
}
return { valid: true };
}
}
class PayPalProcessor implements PaymentProcessor {
async process(order: Order, user: User): Promise<PaymentResult> {
return paypalProcess(order);
}
validate(order: Order, user: User): ValidationResult {
const limit = getPaymentLimit('paypal', user.country);
if (order.total > limit) {
return { valid: false, error: 'PayPal limit exceeded' };
}
return { valid: true };
}
}
class CryptoProcessor implements PaymentProcessor {
// Similar implementation
}Step 4: Factory to Wire It Together
const processors: Record<string, PaymentProcessor> = {
credit_card: new StripeProcessor(),
paypal: new PayPalProcessor(),
crypto: new CryptoProcessor(),
bank_transfer: new BankTransferProcessor(),
};
async function processPayment(
order: Order,
paymentMethod: string,
user: User
): Promise<PaymentResult> {
// Get processor
const processor = processors[paymentMethod];
if (!processor) {
return { error: 'Unknown payment method' };
}
// Check region
if (!isRegionSupported(paymentMethod, user.country)) {
return { error: `${paymentMethod} not available in your region` };
}
// Validate
const validation = processor.validate(order, user);
if (!validation.valid) {
return { error: validation.error };
}
// Process
return processor.process(order, user);
}The Result: Before vs After
Before:
After:
When to Refactor If-Else
Not every if-else needs refactoring. Use these patterns when:
1. More than 3 branches - Simple if/else/else is fine
2. Nesting deeper than 2 levels - Guard clauses help
3. Same condition checked multiple places - Lookup tables
4. Different behavior per type - Strategy pattern
5. Adding new cases frequently - Any of the above
Quick Wins You Can Apply Today
1. Flip conditions for early returns
// Before
if (user) {
if (user.isActive) {
// 50 lines of logic
}
}
// After
if (!user) return null;
if (!user.isActive) return null;
// 50 lines of logic (no nesting)2. Use object literals instead of switch
// Before
switch (status) {
case 'pending': return 'yellow';
case 'approved': return 'green';
case 'rejected': return 'red';
default: return 'gray';
}
// After
const statusColors = {
pending: 'yellow',
approved: 'green',
rejected: 'red',
};
return statusColors[status] ?? 'gray';3. Extract complex conditions
// Before
if (user.age >= 18 && user.country === 'US' && user.hasVerifiedEmail && !user.isBanned) {
// After
const canPurchase = user.age >= 18
&& user.country === 'US'
&& user.hasVerifiedEmail
&& !user.isBanned;
if (canPurchase) {Conclusion
If-else hell is not a character flaw -- it is what happens when code grows organically. The fix is not to avoid conditionals, but to organize them:
1. Guard clauses for validation
2. Lookup tables for configuration
3. Strategy pattern for behavior variation
Next time you see nested conditionals, do not just add another else-if. Take 30 minutes to refactor. Your future self will thank you.