DEV Community

Cover image for The Strategy Pattern in JavaScript: Replace Messy If-Else Logic With Clean Code
Gavin Cettolo
Gavin Cettolo

Posted on

The Strategy Pattern in JavaScript: Replace Messy If-Else Logic With Clean Code

A while ago, I opened a file and immediately knew something was off.

Not because it was broken.
Not because it was complex.

But because it had that shape.

You know the one:

if (...) {
  // something
} else if (...) {
  // something else
} else if (...) {
  // another thing
} else {
  // fallback
}
Enter fullscreen mode Exit fullscreen mode

It worked.

But every time we needed to add a new case, we had to go back into that block and make it just a bit worse.

And that’s when I realized:

This isn’t just messy code. This is a scaling problem.

Let’s fix it.


TL;DR

  • Long if-else chains are a signal that your logic doesn’t scale well.
  • The Strategy Pattern helps you encapsulate behaviors and swap them easily.
  • In JavaScript, you don’t need heavy OOP: functions are often enough.

Table of Contents


The Problem: The If-Else Monster

At first, if-else feels fine.

It’s simple.
It’s readable.
It gets the job done.

Until it doesn’t.

The moment your logic starts growing based on types, modes, or variants, things start getting messy.

And more importantly:

every new feature requires modifying existing logic

That’s the real problem.


A Real Example: Payment Methods

Let’s say you’re building a checkout system.

You need to handle multiple payment methods:

  • credit card
  • PayPal
  • bank transfer

A typical implementation might look like this:

function processPayment(method, amount) {
  if (method === "credit-card") {
    console.log("Processing credit card payment:", amount)
    // card logic
  } else if (method === "paypal") {
    console.log("Processing PayPal payment:", amount)
    // paypal logic
  } else if (method === "bank-transfer") {
    console.log("Processing bank transfer:", amount)
    // bank logic
  } else {
    throw new Error("Unsupported payment method")
  }
}
Enter fullscreen mode Exit fullscreen mode

It works, but you can already feel the tension.


Why This Doesn’t Scale

The issue isn’t the code itself.

It’s how it evolves.

Every time you add a new payment method:

  • you modify this function
  • you increase its complexity
  • you risk breaking existing logic

After a few iterations, you end up with:

  • long functions
  • duplicated patterns
  • fragile logic

This is exactly where the Strategy Pattern shines.


Introducing the Strategy Pattern

The idea is simple:

Instead of having one function decide how to do everything,
delegate each behavior to a separate “strategy”.

Each strategy handles one case.

The main function just selects which one to use.

In practice:

replace conditionals with a lookup-based approach


Step-by-Step Refactoring

Let’s improve the previous example step by step.


Step 1: Extract behaviors

function payWithCreditCard(amount) {
  console.log("Processing credit card payment:", amount)
}

function payWithPaypal(amount) {
  console.log("Processing PayPal payment:", amount)
}

function payWithBankTransfer(amount) {
  console.log("Processing bank transfer:", amount)
}
Enter fullscreen mode Exit fullscreen mode

Each function now has a clear responsibility.


Step 2: Create a strategy map

const paymentStrategies = {
  "credit-card": payWithCreditCard,
  "paypal": payWithPaypal,
  "bank-transfer": payWithBankTransfer,
}
Enter fullscreen mode Exit fullscreen mode

This replaces the if-else chain with a simple lookup.


Step 3: Simplify the main function

function processPayment(method, amount) {
  const strategy = paymentStrategies[method]

  if (!strategy) {
    throw new Error("Unsupported payment method")
  }

  strategy(amount)
}
Enter fullscreen mode Exit fullscreen mode

No branching logic.

Just delegation.


Final Result: Clean and Extensible

Now adding a new payment method is straightforward:

function payWithCrypto(amount) {
  console.log("Processing crypto payment:", amount)
}

paymentStrategies["crypto"] = payWithCrypto
Enter fullscreen mode Exit fullscreen mode

No need to modify existing logic.

No risk of breaking unrelated behavior.

👉 This is the real benefit: open for extension, closed for modification


Using TypeScript for Safer Strategies

So far, everything works perfectly in JavaScript.

And thanks to this guard:

if (!strategy) {
  throw new Error("Unsupported payment method")
}
Enter fullscreen mode Exit fullscreen mode

invalid inputs are handled safely.

However, the error is still detected at runtime.

With TypeScript, we can catch the same issue earlier, before the code even runs.


Step 1: Define allowed methods

type PaymentMethod = "credit-card" | "paypal" | "bank-transfer"
Enter fullscreen mode Exit fullscreen mode

Now only valid methods are allowed.


Step 2: Type the strategies

type PaymentStrategy = (amount: number) => void

const paymentStrategies: Record<PaymentMethod, PaymentStrategy> = {
  "credit-card": payWithCreditCard,
  "paypal": payWithPaypal,
  "bank-transfer": payWithBankTransfer,
}
Enter fullscreen mode Exit fullscreen mode

Now TypeScript guarantees:

  • every method has a strategy
  • every strategy has the correct signature

Step 3: Type the main function

function processPayment(method: PaymentMethod, amount: number) {
  const strategy = paymentStrategies[method]
  strategy(amount)
}
Enter fullscreen mode Exit fullscreen mode

No need for runtime checks anymore.

Errors are caught at compile time.


Why this matters

With JavaScript, you make your code flexible.
With TypeScript, you make it reliable.

You’re not just cleaning your code.

You’re making invalid states impossible.


Optional: Class-Based Strategy

If you prefer a more OOP-style approach with JavaScript, you can model strategies as classes:

class CreditCardStrategy {
  execute(amount) {
    console.log("Processing credit card payment:", amount)
  }
}

class PayPalStrategy {
  execute(amount) {
    console.log("Processing PayPal payment:", amount)
  }
}
Enter fullscreen mode Exit fullscreen mode

Then use them like this:

const strategies = {
  "credit-card": new CreditCardStrategy(),
  "paypal": new PayPalStrategy(),
}

function processPayment(method, amount) {
  const strategy = strategies[method]

  if (!strategy) {
    throw new Error("Unsupported payment method")
  }

  strategy.execute(amount)
}
Enter fullscreen mode Exit fullscreen mode

It’s more structured, but also more verbose.

In JavaScript, the functional approach is usually enough.


When to Use (and Not Use) Strategy

Use it when:

  • you have multiple variants of the same behavior
  • new cases are added frequently
  • conditionals are growing

Avoid it when:

  • you only have a couple of simple cases
  • the logic is unlikely to change
  • it adds unnecessary complexity

The goal is not to apply patterns, but to solve real problems.


Final Thoughts

The Strategy Pattern isn’t about being clever, it’s about writing code that can grow without becoming fragile.

Most messy if-else chains don’t start messy, they become messy over time and by the time you notice, changing them feels risky.

The good news is:

You don’t need a rewrite, just a better way to organize behavior.


If this helped you:

  • Leave a ❤️ reaction
  • Drop a 🦄 unicorn
  • Share the worst if-else monster you’ve seen

And if you enjoy this kind of content, follow me here on DEV for more.

Top comments (6)

Collapse
 
lucaferri profile image
Luca Ferri

Great explanation of how the Strategy Pattern can simplify complex conditional logic, the examples really make the benefits clear. I especially like how you highlighted the flexibility it brings when adding new behaviors without touching existing code.

One thing I’m curious about: how would you decide between using the Strategy Pattern and a simple object map of functions in JavaScript for smaller cases? Do you see one approach scaling better in real-world applications?

Collapse
 
gavincettolo profile image
Gavin Cettolo

Thank you @lucaferri , I’m glad the examples resonated! That’s a really insightful question, because in JavaScript the line between a “Strategy Pattern” and a simple object map can get blurry.

For smaller cases, I’d usually start with an object map of functions. It’s lightweight, very readable, and fits naturally with how JS handles first-class functions. In fact, many “strategy-like” implementations in JS are just that, an object where each key maps to a function, and you select one at runtime.

Where the Strategy Pattern starts to shine is when the problem grows in complexity. A few signals I look for:

  • Shared interface or contract becomes important (especially with TypeScript)
  • Strategies need internal state or dependencies
  • You want to enforce separation of concerns more strictly
  • You expect the behavior set to grow over time or be extended by others

At that point, wrapping strategies in objects/classes gives you better structure, testability, and scalability.

So in practice, I see it less as “one vs the other” and more as a progression:

  • Object map → great for simple, static behavior switching
  • Strategy Pattern → better for complex, evolving, or extensible systems

In terms of scaling, the Strategy Pattern generally holds up better in larger codebases because it makes behavior explicit and composable, whereas large object maps can become harder to reason about as they grow.

A good rule of thumb I use:

Start simple with a function map, and refactor to Strategy when the logic starts needing structure.

Curious to hear how others approach this trade-off too—especially in TypeScript-heavy projects.

Collapse
 
lucaferri profile image
Luca Ferri

Glad that made sense. I really like the way you framed it as a progression rather than a strict either/or 👍

I tend to follow a similar path in practice. I’ll almost always start with an object map because it keeps things simple and avoids over-engineering early on. In many cases, that’s honestly all you ever need.

Where I’ve seen things shift is exactly where you pointed out: once behaviors start accumulating logic, dependencies, or state, the map can quietly become harder to reason about. At that point, moving to a more explicit Strategy-style structure feels less like adding complexity and more like making the existing complexity visible and manageable.

I also appreciate your point about TypeScript: having a shared contract really nudges things toward a Strategy approach, especially in larger teams where consistency matters.

So yeah, your rule of thumb resonates a lot:

start simple, then refactor when structure is actually needed

That balance between pragmatism and scalability is really the key.
Thank you @gavincettolo

Collapse
 
elenchen profile image
Elen Chen

Really enjoyed this breakdown, especially how you framed the Strategy Pattern as a way to pay down conditional complexity over time instead of just “cleaning up if/else.” That nuance often gets lost.

One thing that stood out to me is how naturally this maps to systems that need runtime flexibility. In distributed environments (thinking feature flags, multi-tenant behavior, or even traffic routing), being able to swap strategies without touching the calling code is incredibly powerful, and aligns well with the idea that strategies are interchangeable algorithms selected at runtime ().

I’m curious how you think about the boundary between Strategy and over-engineering, though. In practice, I’ve seen teams introduce the pattern early, only to end up with a “strategy explosion” where each variation becomes its own class/function, even when a simple conditional might have been more readable (a concern that comes up often in community discussions too).

So my question for you:
👉 What heuristics do you use to decide when to introduce a Strategy vs. sticking with a straightforward conditional, especially in smaller code paths that might evolve later?

Also, how would you approach strategy selection in a dynamic system (e.g., per-request or per-user)? Would you lean toward a factory/DI approach, or keep the selection closer to the call site to preserve clarity?

Would love to hear how you’ve handled that trade-off in real-world projects.

Collapse
 
gavincettolo profile image
Gavin Cettolo

Great points, @elenchen, I really appreciate the thoughtful take (and the distributed systems angle).

You’re absolutely right that the Strategy Pattern can become overkill if introduced too early. I tend to follow a pretty simple rule of thumb:

  • If I have 1-2 simple branches, I stick with conditionals
  • Once I hit 3+ variations and the logic starts to diverge or grow independently, that’s when Strategy starts to pay off
  • Especially if I know new variants are coming or the behavior needs to change at runtime

I like how you framed it as “paying down conditional complexity over time”—that’s exactly how I think about it too. It’s less about upfront “clean code” and more about creating a structure that can absorb change without becoming brittle.

On the “strategy explosion” problem: totally agree. I’ve seen that happen when every tiny variation gets abstracted prematurely. One thing that helps me is asking: Is this variation a real domain concept, or just an implementation detail?
If it’s not meaningful at the domain level, I usually avoid turning it into its own strategy.

Unfortunately, I'm not an expert in dynamic systems, so I'll leave comments on this topic to other developers with more expertise in the field.

That said, early on, I’ll often keep the selection closer to the call site for clarity, like you mentioned. It’s a trade-off between explicitness vs. extensibility, and the “right” choice tends to evolve with the system.

Really liked your point about feature flags and routing, those are perfect real-world examples where Strategy fits naturally.

Thanks again for the great comment

Collapse
 
elenchen profile image
Elen Chen

Thanks again for the thoughtful response and for taking the time to engage 🙌 Curious to see what others will add to that part of the conversation!