1

I have recently read about the so-called "distributed enum anti-pattern." In particular, as it relates to using enums in conditional statements. (The idea is apparently that if you were to add a new enum value to the enum, you might forget to update all your code that consumes the enum, leading to bugs. Violates the "single source of truth" principle.)

I'm curious if this issue arises more out of specifically object-oriented patterns rather than general programming. (Enums seem like a nice way of modeling discriminated unions, which I know is not a novel realization on my part!)

The explanations/solutions offered when I did research about this seemed to consistently be overtly OOP in character:

  1. decrying anemic objects;
  2. suggesting the enum be replaced by an object with keys mapping to the former enums and then adding new behaviors to the values belonging to those keys;
  3. replacing enum with an interface that has various classes implement the interface

1: Anemic objects

The idea is that your enum options are effectively anemic objects (i.e., objects that are bereft of methods). Yes, OOP does not like anemic objects. But this is a fundamental architectural preference in functional programming. It seems like I can stop with the analysis there: if you're writing FP rather than OOP, #1 is not a valid criticism.

2: Convert to object/map containing all relevant logic keyed to unique values representing the enum options.

For an example of #2, see here. Discussing this w.r.t. JavaScript, it identifies three areas of concern:

  1. It's easy to forget to update all references to the enum. But if you're using a typed language (like, e.g., TypeScript), this isn't an issue, right? At compile-time you would immediately be informed of all the enum consumers that need updating.

  2. It spreads metadata around the code by placing logic in various switch statements in the code. But isn't this just a statement of what all programming is? Placing logic in various places in the code? "This can make it difficult to really see the differences between the various enum members without scouring the code for all of these bits of logic" is the idea. But surely part of the point of an enum is that you don't need to know all these other bits of logic. Like if some data is an Integer, it's not a valid criticism to say there's all sorts of "metadata" about that data spread around the code simply because logic based on the value of this Integer is in different places. Do you need to know how the value affects the calculation performed in calculateMyFoo except when you're verifying calculateMyFoo is correct?

  3. "Your code no longer reads in a generic fashion" because you've added "configuration" to your code. This does not make sense to me. And if it were true, then it implies enums are always bad, right? I don't see how that doesn't immediately follow from "enums represent configuration."

The link recommends replacing something like

enum Direction = { NORTH, SOUTH }
const areWeGoingNorth = (d: Direction) => d === Direction.NORTH

with

const Direction = {
  NORTH: {
    areWeGoingNorth: () => true,
  },
  SOUTH: {
    areWeGoingNorth: () => false,
  }
}

This seems verbose, and also dogmatically OOP. If you aren't writing OOP code, is this really better? I write primarily functional code, and this code looks like it's only "better" if you accept the premise that behaviors cannot exist independently of objects that own the behaviors as methods.

3: Replace with an interface plus an object/map implementing this interface.

This is just a side effect of the OOP approach. They would have code look something like this:

interface IDirection {
  value: string
}
const Direction: Record<string, IDirection> = {
  NORTH: { value: 'north' },
  SOUTH: { value: 'south' },
}

and then if you need a new function amIGoingNorth you would update the interface and the Direction object implementing the interface:

interface IDirection {
  value: string
  amIGoingNorth: boolean
}
const Direction: Record<string, IDirection> = {
  NORTH: { value: 'north', amIGoingNorth: true },
  SOUTH: { value: 'south', amIGoingNorth: false },
}

First, this seems like you're just recreating the so-called issue with there being two sources of truth. You've got both IDirection and Direction dictating your truth. One the structure, and one the implementation.

it also spreads "metadata" around to different places. (Never mind it allows you to create other objects with keys implementing IDirection in other files.)

7
  • 6
    Well, if working with enums, you need to make your code robust. So instead of case 1: dosomething case 2: dosomething; you have to write case 1: dosomething case 2: dosomething ELSE throw unexpectedEnumError.
    – Pieter B
    Commented Mar 30, 2023 at 7:49
  • 2
    What happend to #4: "Test your code before you deploy the changes"? There is no amount of code you could add, no pattern or OOP magic that would replace testing. Ideally, automated testing.
    – nvoigt
    Commented Mar 30, 2023 at 8:12
  • 1
    You wrote "The explanations/solutions offered when I did research about this seemed to consistently be overtly OOP in character:" - but only provided a single link as reference. Can you give more references? For example #1, can you give a reference explaining the relationship of "distributed enums" with "anemic domain models"?
    – Doc Brown
    Commented Mar 30, 2023 at 9:32
  • 1
    @PieterB, or, if you can rely on that, have a switch without default and compiler warnings (as errors) if not all cases are covered. It is often better to catch these problems at compile time.
    – Carsten S
    Commented Mar 30, 2023 at 13:37
  • 3
    it relates to using enums in conditional statements. It sounds like the article you found was written by someone who's never used a language with pattern matching. In Rust or Scala, a match statement will check that it's used exhaustively, and if you add a new enum case, you get nice compile errors telling you what to update. It's still a downstream breaking change, but you can't break your own code by doing it. In fact, in Rust, there's an annotation you can add to your enum to forbid downstream code from assuming exhaustiveness, so that adding a new case is not a breaking change. Commented Mar 30, 2023 at 14:13

3 Answers 3

9

It sounds to me you are mixing up a problem with it's potential solutions.

  • What you call "distributed enums" is not an Anti-pattern, but something one has to manage - regardless of using OOP or other programming paradigms.

  • What you got right in the question that the core issue is the "single source of truth" problem and the necessity to have different areas of a program behave differently in regards to some enum value. This has nothing to do with OOP.

Your confusion probably(*) rises from the fact that there are a lot of articles at the web which try to tackle such kind of problems with OOP means, which works for certain "local" occurences of the issue. Article writers and blog posters often focus on an audience which wants to learn OOP programming, and local "DRY" violations or local single-source-of-truth violations provide good examples for demonstrating certain OOP aspects.

(*)I can only guess here, since the "list of references" in your question is rather short.

Unfortunately, those OOP means are often not sufficient when it comes to large scale programs or systems, with different layers, modules or (micro-)services. Even more unfortunately, violations of the single-source-of-truth problem start to become a real issue especially in larger systems, at a small scale such violations might be neglected to some degree.

What one has to understand that - especially in a growing system - there is no "one-size-fits-all" solution to this. When one encounters a potential "single-source-of-truth" violation (maybe one caused by an enum used in different places), one has to look into their toolbox and decide which tools can be applied. You already mentioned two of them:

  • replacing an enum by a class hierarchies and polymorphism, maybe with some strategy pattern (those are OOP means, see your topics #1 & #3)

  • using a table-driven approaches (your topic #2)

But there are other design strategies which work on larger scales. Here are some (without claiming completeness):

  • foresee certain "enum" extensions and deliberately accept them by providing some good default behaviour for the otherwise unhandled cases

  • orthogonal design - which means to avoid the need for having checks/switches at different places in the code at all

  • code generators which take the input of an enum from a single source and generate code to be used in different places, different forms, and maybe different programming languages

  • automated tests (often used in conjections with debug assertions that a given enum value is within a certain range)

  • compilers which warn against missing switch/case branches (or missing default branches)

  • restricted visibility within a system - when you know an enum is only visible within one module, you don't have to check other modules

So no, "distributed enums" has not much in common with OOP. OOP provides a few tools to amend the problem, but it is far from being a "silver bullet" and far from providing a totally suffient solution.

7
  • What does "orthogonal design" mean here? Wouldn't that just be polymorphism/strategy patterns again?
    – Bergi
    Commented Mar 30, 2023 at 12:54
  • Don't forget that OO was conceived as a solution to improve maintainability of ever larger programs. That's why we keep types and operations on them together. I find myself programming poor man's OO even in non-OO plain old C: Yes, I want only initialized objects; yes, I want strong types (feet are not meters; I have different structs containing just one double each!); yes, I have tables with operations if necessary, emulating classes or even polymorphism. You can program a macro assembler, but you don't have to. Commented Mar 30, 2023 at 14:17
  • @Peter-ReinstateMonica that sounds like C++, with extra steps /s
    – bracco23
    Commented Mar 30, 2023 at 14:19
  • @bracco23 Yes, of course! The gist of my comment was that I find myself employing the same techniques to cope with complexity in C that were incorporated in C++, for the same reasons. Commented Mar 30, 2023 at 15:19
  • @Peter-ReinstateMonica: OOP was advertised as a solution to improve maintainability of ever larger programs, but only partially fulfilled that promise. Of course, OOP means can indeed help to achieve this goal to some degree, but not automatically. You don't create a single-source-of-truth design just by using OOP means, you also have to use them well. Often, such design has nothing to do with OOP - it can actually start at the requirements level, by giving a user orthogonal configuration options, for example.
    – Doc Brown
    Commented Mar 30, 2023 at 18:05
1

I could only find the one article that you link to talking about this.

The argument made that you need to update all your conditionals when you add an enum is obviously incorrect.

It's not required for all conditionals to account for all options. A simple catch all else case would be good practice and alleviate the "problem".

In their example they add divide to a calculator and complain that it will do nothing when that operation is performed.

But the alternate approach they suggest simply moves your conditional up the chain, somewhere they will have

   if(operator == "+")
   {
      operation.Apply(ADD)
   }

you are still going to have to update it for divide and now you have the additional(...see what i did there?) problem of your operation types being tied to the operation. How are you going to handle ints, doubles, strings etc

4
  • Of course the advice only becomes relevant when there is more than one switch over the enum. For example, you want to parse an operator, perform the operation, and maybe output the operator: You'd have 3 places where you would need to update a switch or an if/else chain when you add another operator. The suggestion is to have a single table with the token, the parse routine, the output routine and the operation routine. Wherever any of these is needed, the code needs no adaptation after adding an entry to the table. Commented Mar 30, 2023 at 14:05
  • And as with many best practices, they increase code size and complexity for small programs. The benefits grow exponentially though with program size and number of team members because everything is self-explanatory. In the case where the switches are distributed across the code base, you need to tell every new colleague over and over again "when you add an operator enum, don't forget to add a branch to the output switch and to the input switch". And Joe from the next desk, who was listening in, adds "and if you want human readable debug output...". And Susan adds "don't forget the serializer." Commented Mar 30, 2023 at 14:07
  • Sure, my point is the suggested pattern doesn't fix that. all you are doing is moving the function from one class to another. You don't reduce conditionals, or classes, or make things easier in large programs. you still have to write all those functions, you are just putting them in odd OperationTypeForWritingAsText.Divide OperationTypeForIntergerCalcs.Divide instead of TextWriter and Calculator
    – Ewan
    Commented Mar 30, 2023 at 18:07
  • If you dont like conditional blocks there are plenty of patterns you can use to replace them, getting rid of enums doesnt help
    – Ewan
    Commented Mar 30, 2023 at 18:10
-1

I agree with Doc Browns answer that this is not necessarily an anti-pattern. While OOP likes to couple data (which would here be enum values) and behavior, especially in the case of enums that is not always required. The main point when not doing so is, as you have identified, keeping the "single source of truth", i.e. making sure that adding new cases/enum values does not negatively affect the usages.

This problem occurs if you switch/pattern match over the enum values in other positions. Thankfully, most programming languages actually help with that:

  • Does your compiler support exhaustive switches? This would make the alternative of always having a default case irrelevant.
  • Some languages support sealed interfaces/classes, i.e. you control which subtypes exist. This addresses your point #3 and makes exhaustive switching easier.
  • Use union types instead of enums

Note that those features are even more important in functional programming.

Lastly, I want to comment on const areWeGoingNorth = (d: Direction) => d === Direction.NORTH. This function will remain correct, regardless of which directions you add. Why is that? Direction is not merely a symbol in your codebase, but the different values actually have meaning (else they would be pointless).

1
  • I would argue that if you added NORTHWEST to the list of possible directions, then you would be going north even in the north-west case. For example, imagine a game where you add diagonal movement and if you are going north you should do bound check against the top side of the map.
    – pqnet
    Commented Apr 4, 2023 at 3:22

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.