A chain of conditionals is not automatically a design problem. Sometimes each branch expresses genuinely different behavior, and an if or switch is the clearest way to show it. But another kind of branching appears when the logic is identical in every branch and only a value changes.

That distinction matters. If the program is repeatedly asking, “Which constant belongs to this case?”, the conditionals are acting as a hand-written lookup mechanism. A lookup table can make the relationship explicit: keys represent cases, values represent the data associated with them.

This article explains how to recognize that situation, refactor it safely, choose a policy for unknown keys, and avoid turning real behavior into an opaque table merely to remove conditionals.

Recognize data disguised as control flow

Consider a shipping estimate that depends on a service level:

function deliveryDays(serviceLevel):
    if serviceLevel == "standard":
        return 5
    if serviceLevel == "priority":
        return 2
    if serviceLevel == "express":
        return 1
    raise UnknownServiceLevel(serviceLevel)

There is nothing incorrect about this code. For three cases, it may remain perfectly readable.

Now look at what varies between branches. Each branch performs the same operation: return a number of days. Only two pieces of data differ: the service-level name and its corresponding number.

The code therefore represents a mapping:

standard -> 5
priority -> 2
express  -> 1

A lookup table stores that mapping directly. Depending on the language, the concrete structure might be called a map, dictionary, associative array, or table. The important idea is independent of the container: selection is performed by a key rather than by a sequence of explicit branches.

Start with the smallest useful refactoring

The previous function can be expressed as:

DELIVERY_DAYS = {
    "standard": 5,
    "priority": 2,
    "express": 1
}

function deliveryDays(serviceLevel):
    if not DELIVERY_DAYS.contains(serviceLevel):
        raise UnknownServiceLevel(serviceLevel)

    return DELIVERY_DAYS[serviceLevel]

The observable policy has not changed. Known service levels return the same values, and an unknown service level still causes an explicit error.

What changed is where the variation lives. Before the refactoring, the mapping was encoded in control flow. After it, the mapping is data and the control flow is constant.

That gives a useful mental model:

same action + different associated values
            -> consider a lookup table

different actions or different rules
            -> keep behavior explicit

The goal is not fewer lines or fewer if statements. The goal is to represent the nature of the problem accurately.

Why the distinction helps maintenance

Suppose the business adds an economy service with a seven-day estimate.

In the branching version, a developer must understand where another condition belongs and preserve the surrounding control flow. In the table version, the change is an addition to the mapping:

"economy": 7

That is useful when adding or changing entries is the dominant kind of change. Reviewers can inspect the supported cases as a compact set, and tests can often exercise the table systematically.

The table also separates two questions that were previously mixed together:

  1. What values are associated with each supported key?
  2. What should the program do when a key is not supported?

Keeping those questions separate makes the boundary behavior easier to see.

Decide what an unknown key means

A lookup table introduces an important boundary condition: the requested key may not exist.

Do not let the container’s default behavior accidentally define application policy. Different languages and APIs may return a null-like value, throw an exception, or provide a caller-supplied default. The engineering decision comes first.

For the shipping example, an unknown service level probably indicates invalid input or inconsistent configuration. Failing explicitly is appropriate:

if not DELIVERY_DAYS.contains(serviceLevel):
    raise UnknownServiceLevel(serviceLevel)

In another domain, a default may be legitimate. Imagine a table of optional display labels where an unknown code should be shown unchanged:

function displayLabel(code):
    return LABELS.getOrDefault(code, code)

The difference is semantic, not syntactic. A default is suitable only when the domain actually defines one.

A dangerous refactoring would silently change the original behavior:

return DELIVERY_DAYS.getOrDefault(serviceLevel, 5)

If the old code rejected unknown levels, returning five days is not a harmless simplification. It converts bad input into apparently valid output and may make the source of the error harder to find.

When refactoring existing code, preserve the old unknown-case behavior unless changing that policy is an intentional, separately reviewed decision.

Keep keys inside a trusted vocabulary when possible

String keys make teaching examples compact, but production code should respect the application’s existing model.

If service levels are already represented by an enum, domain type, or another constrained value, use that type as the table key when the language permits it. That reduces spelling mistakes and prevents unrelated strings from looking like valid cases.

For example, conceptually:

DELIVERY_DAYS = {
    ServiceLevel.STANDARD: 5,
    ServiceLevel.PRIORITY: 2,
    ServiceLevel.EXPRESS: 1
}

The lookup table does not replace input validation. External input still needs to be parsed or validated before it becomes a trusted ServiceLevel. The table simply expresses the relationship between already-understood cases and their associated values.

Tables can hold simple policies, not only constants

Sometimes each case has several related values. Repeating separate conditionals for each property can scatter one concept across the codebase.

Suppose each service level has both an estimated duration and an insurance limit:

SERVICE_POLICY = {
    "standard": { days: 5, insuranceLimit: 100 },
    "priority": { days: 2, insuranceLimit: 500 },
    "express":  { days: 1, insuranceLimit: 1000 }
}

Now one entry describes the simple data associated with a service level.

This can improve cohesion when those values change together. It can also make consistency checks easier. For example, a test can verify that every supported service level has a positive duration and a non-negative insurance limit.

But there is an important boundary: a table should remain understandable as data. If entries begin accumulating callbacks, nested condition languages, ordering rules, and exceptions, the design may be recreating a programming language inside a data structure. At that point, ordinary functions or domain objects may communicate the behavior more clearly.

Do not replace meaningful behavior with a table

Consider a payment workflow:

if method == "card":
    authorizeCard()
    captureCard()
else if method == "bank_transfer":
    reserveOrder()
    waitForSettlement()
else if method == "store_credit":
    verifyBalance()
    deductCredit()

The branches are not merely selecting different constants. They describe different sequences, failure modes, and side effects.

It is possible to build a table of function references, but doing so does not automatically improve the design. The table would hide a behavioral dispatch mechanism behind data syntax while the real problem remains behavioral variation.

For a small number of cases, the explicit conditional may be clearer. If the variants grow and each represents a coherent behavior, polymorphism, strategy objects, or separate functions may become appropriate. The choice depends on how the behavior changes and how much structure each variant needs.

A practical test is to finish this sentence:

For each key, the program needs a different ____.

If the blank is “number”, “label”, “timeout”, or another simple value, a lookup table is a strong candidate. If the blank is “workflow”, “algorithm”, or “set of side effects”, treat the problem as behavioral variation first.

Avoid premature tables for tiny, stable decisions

A lookup table adds a data structure, a name for that structure, and an unknown-key policy. Those are small costs, but they are still costs.

This code is easy to read:

if isWeekend:
    return 0
return 8

Turning it into a two-entry table keyed by a Boolean would add indirection without revealing a useful domain relationship.

Likewise, a three-case switch that is unlikely to change may be easier for a reader to understand than a separately declared map. Refactor when the table clarifies the model, not because a conditional has crossed an arbitrary line count.

Watch for duplicated tables

Moving a mapping into data does not solve duplicated knowledge if the same mapping is then copied into several modules.

Suppose delivery estimates are needed by checkout, order tracking, and customer support. Three identical DELIVERY_DAYS tables create the same maintenance problem as three identical conditional chains: a future change can update one copy and miss the others.

If the mapping represents one shared business decision, give that decision one appropriate owner and expose it through the module boundary that already owns shipping policy. Do not create a global constants file merely to make the table reachable everywhere; ownership should follow the domain responsibility.

On the other hand, two similar-looking tables may represent different decisions. A customer-facing estimate and an internal warehouse target can happen to contain the same numbers today while having different reasons to change. Combining them only because their current data matches would couple independent policies.

The question is not whether the values are identical. Ask whether they represent the same knowledge and should change for the same reason.

Test the mapping and its boundary behavior

For a small table, direct examples are usually enough:

assert deliveryDays("standard") == 5
assert deliveryDays("express") == 1
assert deliveryDays("unknown") raises UnknownServiceLevel

When the set of supported cases is defined elsewhere, a useful additional test checks coverage. Conceptually:

for each level in ServiceLevel.values:
    assert DELIVERY_DAYS.contains(level)

That test protects against a common maintenance failure: someone adds a new valid service level but forgets to add its associated policy data.

Be careful not to test the implementation twice. A test that merely iterates over the table and asserts that every entry equals itself proves nothing. Tests should verify domain expectations, completeness, boundary behavior, or invariants that can fail independently of the table’s current contents.

Use the refactoring when the change pattern supports it

A lookup table is a good fit when the cases form a finite set, selection depends on a key, each case produces the same kind of result, and most future changes are likely to add or edit associations rather than invent new behavior.

Keep ordinary branching when the conditions express ranges, precedence, dependencies between facts, or a short decision that reads naturally as control flow. For example, pricing rules such as “10% off when the order exceeds a threshold and the customer is eligible” are not automatically improved by encoding every combination into a table. A decision table may help when combinations themselves are the problem, but that is a different technique with different trade-offs.

The key design question is simple: is the variation data, or is it behavior?

When the variation is data, represent it as data. A lookup table can make supported cases visible, isolate unknown-key policy, and turn repetitive branching into a direct relationship between keys and values. When the variation is behavior, keep that behavior explicit and choose an abstraction that explains it rather than hiding it.