DEV Community

Cover image for Replacing Type Code With Class
Attila Fejér
Attila Fejér

Posted on • Updated on • Originally published at rockit.zone

Replacing Type Code With Class

In the previous part, we had an overview of why switch-case could be hard to maintain. This part will focus on the simplest scenario: when type-code only affects data, not behavior. We'll do this by modeling a pizzeria.

Initial Solution

In our pizzeria, when customers place an order, they can choose the size and kind of toppings they want. The price of the pizza only depends on its size. For the sake of simplicity (and because our slogan is "You dream it, we make it"), we don't want to limit what kind of toppings customers can choose. Therefore, we'll model the toppings as a list of strings.
So our pizza class will look like the following:

class Pizza {
  static final int SIZE_SMALL = 0;
  static final int SIZE_NORMAL = 1;
  static final int SIZE_LARGE = 2;

  List<String> toppings;
  int size;

  Pizza(List<String> toppings, int size) {
    this.toppings = toppings;
    this.size = size;
  }

  int price() {
    switch (size) {
      case SIZE_SMALL:
        return 2;

      case SIZE_NORMAL:
        return 3;

      case SIZE_LARGE:
        return 4;

      default:
        throw new IllegalStateException("The field 'size' has an invalid value");
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

The use of this class is pretty straightforward. Assuming that we want to calculate the price of our new favorite normal-size coconut-catnip pizza1, we would write:

Pizza pizza = new Pizza(List.of("coconut", "catnip"), Pizza.SIZE_NORMAL);
int price = pizza.price();

Enter fullscreen mode Exit fullscreen mode

Until this, we always gave five pizzas to our delivery guy at every turn he made because he couldn't carry more large pizzas. But what about if we get an order of ten small pizzas? It would be a shame to deliver it in two rounds. After all, two small pizzas weigh less than a large one.

So to optimize our process, we want to calculate the weight of each pizza. Again, for simplicity's sake, we assume a pizza's weight depends only on size. So we add this method to our class:

double weight() {
  switch (size) {
    case SIZE_SMALL:
      return 0.5;

    case SIZE_NORMAL:
      return 0.75;

    case SIZE_LARGE:
      return 1.25;

    default:
      throw new IllegalStateException("The field 'size' has an invalid value");
  }
}

Enter fullscreen mode Exit fullscreen mode

It was as easy as copying the previous method and changing the result type, the method name, and the return values.

What would happen if we needed one more method, depending on the size? Let's say a toString() method. We would make new copies again and again. Those who don't like this much code duplication, raise your hands! Well, if you didn't raise your hand, shame on you! But don't worry; I'll convince you it isn't good.

Extending Size Options

Let's say our pizzeria is so popular we want to present monster-size pizzas. It's pretty simple; we should create a new constant for the new size and a new case clause for each switch statement. Our new code:

class Pizza {
  static final int SIZE_SMALL = 0;
  static final int SIZE_NORMAL = 1;
  static final int SIZE_LARGE = 2;
  static final int SIZE_MONSTER = 3;

  List<String> toppings;
  int size;

  Pizza(List<String> toppings, int size) {
    this.toppings = toppings;
    this.size = size;
  }

  int price() {
    switch (size) {
      case SIZE_SMALL:
        return 2;

      case SIZE_NORMAL:
        return 3;

      case SIZE_LARGE:
        return 4;

      default:
        throw new IllegalStateException("The field 'size' has an invalid value");
    }
  }

  double weight() {
    switch (size) {
      case SIZE_SMALL:
        return 0.5;

      case SIZE_NORMAL:
        return 0.75;

      case SIZE_LARGE:
        return 1.25;

      case SIZE_MONSTER:
        return 2;

      default:
        throw new IllegalStateException("The field 'size' has an invalid value");
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

We can already feel a smell. We had to modify a method to extend its capabilities. That violates the Open/Closed Principle.

Unfortunately, we have bigger issues when we want to use it:

Pizza pizza = new Pizza(List.of("coconut", "catnip"), Pizza.SIZE_MONSTER);
int price = pizza.price();

Enter fullscreen mode Exit fullscreen mode

We get an IllegalStateException. Why?

Of course, we just forgot to insert the new case statement into our price() method. You probably noticed it already, but it's an elementary example. If you had a more complex situation, it's much harder to remember every switch-cases to change. I almost don't dare to mention how much harder it is if your switch-cases are not in a simple class but scattered all over your codebase2.

Still not convinced? Don't worry; there's more.

Let's forget for a minute that we implemented this class. Let's look at our only constructor's signature:

Pizza(List<String>, int)

Enter fullscreen mode Exit fullscreen mode

What does it tell us? Not so much. And of course, the class doesn't have any JavaDoc. No worries, we will figure out how to use it. Our first try:

Pizza pizza = new Pizza(null, 100);
int price = pizza.price();

Enter fullscreen mode Exit fullscreen mode

Aaaand we get an IllegalStateException in the second row. Pretty sure it's because we used null as the first argument, right?

Of course, we know that this isn't the problem: we provided an invalid size value. Yes, it would be better if we did some argument checking in the constructor, but that's not the point. If somehow we modified our size field internally and it ended up invalid, the problem would be the same. It doesn't matter how hard we try to avoid these scenarios; the possibility remains.

It would be much better if we resolved the root cause of the problem and our field couldn't have an invalid value at all. More accurately, if the value validation would happen at compile time and not runtime.

What? Validation at compile time? Of course! We call it static typing.

Introducing an Enum

Let's try a new approach. Instead of int constants, we declare a Size enum:

class Pizza {
  enum Size {
    SMALL, NORMAL, LARGE, MONSTER
  }

  List<String> toppings;
  Size size;

  Pizza(List<String> toppings, Size size) {
    this.toppings = toppings;
    this.size = size;
  }

  int price() {
    switch (size) {
      case Size.SMALL:
        return 2;

      case Size.NORMAL:
        return 3;

      case Size.LARGE:
        return 4;

      case Size.MONSTER:
        return 6;

      default:
        throw new IllegalStateException("The field 'size' has an invalid value");
    }
  }

  double weight() {
    switch (size) {
      case Size.SMALL:
        return 0.5;

      case Size.NORMAL:
        return 0.75;

      case Size.LARGE:
        return 1.25;

      case Size.MONSTER:
        return 2;

      default:
        throw new IllegalStateException("The field 'size' has an invalid value");
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

Instantiation:

Pizza pizza = new Pizza(List.of("coconut", "catnip"), Pizza.Size.MONSTER);

Enter fullscreen mode Exit fullscreen mode

Because of static typing in Java, the size field will always have a valid value (except when we pass null, but let's leave that problem to another day). But the switch-case is still there. Every time we create a new size, all switch-case statements we use with size as a condition must be updated. We will probably forget one or two.

Getting Rid of Switch-Case

Let's modify the Size enum to a class. Classes can have fields, so we don't have to use the switch-case statement anymore:

class Pizza {
  static class Size {
    int price;
    double weight;

    private Size(int price, double weight) {
      this.price = price;
      this.weight = weight;
    }

    int getPrice() {
      return price;
    }

    int getWeight() {
      return weight;
    }
  }

  static final Size SIZE_SMALL = new Size(2, 0.5);
  static final Size SIZE_NORMAL = new Size(3, 0.75);
  static final Size SIZE_LARGE = new Size(4, 1.25);
  static final Size SIZE_MONSTER = new Size(6, 2);

  List<String> toppings;
  Size size;

  Pizza(List<String> toppings, Size size) {
    this.toppings = toppings;
    this.size = size;
  }

  int price() {
    return size.getPrice();
  }

  double weight() {
    return size.getWeight();
  }
}

Enter fullscreen mode Exit fullscreen mode

Using it:

Pizza pizza = new Pizza(List.of("coconut", "catnip"), Pizza.SIZE_MONSTER);

Enter fullscreen mode Exit fullscreen mode

Why is it better? We have multiple reasons:

  • We can't pass an invalid Size. The Size constructor is private, which means it can't be called from outside. Only predefined size constants can be used.
  • If we want to support a new size, we create a new instance. The compiler will raise an error if we forget to set a required value since the constructor lacks an argument.
  • Because of the absence of the switch-case statement, our code is much cleaner.
  • If we want the pizza to have a new property that depends on the size, we only have to add a new field to the Size class, make it mandatory in the constructor and write a new getter. The compiler will mark all errors; we are good to go after fixing them.

So this is the ultimate solution? Well, almost. The enum way was a bit cleaner because it declared all its constants. Let's see how we can return to them.

The Revenge of the Enums

First, let's refactor our code and move the constant declarations into the Size class:

class Pizza {
  static class Size {
    static final Size SMALL = new Size(2, 0.5);
    static final Size NORMAL = new Size(3, 0.75);
    static final Size LARGE = new Size(4, 1.25);
    static final Size MONSTER = new Size(6, 2);

    int price;
    double weight;

    private Size(int price, double weight) {
      this.price = price;
      this.weight = weight;
    }

    int getPrice() {
      return price;
    }

    int getWeight() {
      return weight;
    }
}

  List<String> toppings;
  Size size;

  Pizza(List<String> toppings, Size size) {
    this.toppings = toppings;
    this.size = size;
  }

  int price() {
    return size.getPrice();
  }

  double weight() {
    return size.getWeight();
  }
}

Enter fullscreen mode Exit fullscreen mode

Usage:

Pizza pizza = new Pizza(List.of("coconut", "catnip"), Pizza.Size.MONSTER);

Enter fullscreen mode Exit fullscreen mode

But this way, we can create new Size instances in the Pizza class too, which can be confusing. We could move the Size class to its file. Because of the private constructor, it couldn't be called from outside.

There is a different solution, which involves enums again. But we already covered that part; what new can enums possibly provide? Let's examine what enums are and how they work in Java.

Understanding Enums

In C (and most languages) enum is a convenient way of declaring automatically incrementing int constants. If we wanted, we could set the value of some of the constants manually or even repeat the values:

typedef enum pizza_size { SMALL, NORMAL, LARGE = 3, EXTRA_LARGE, MONSTER = EXTRA_LARGE };
enum pizza_size size = NORMAL;

Enter fullscreen mode Exit fullscreen mode

This way, SMALL, NORMAL, LARGE, and MONSTER will have the values 0, 1, 3, 4, and 4, respectively. But basically, those are just int constants. Enum variables can still have any value. C++ and C# also use a similar approach.

But not Java. Of course, you could say Java always chooses its path. Don't judge quickly because the Java enum is very cool.

Java enums are similar to the Size class we just implemented. They are classes (static ones, if declared as inner types) with a private constructor. The only instances they can have are the ones we declared.

There are a couple of advantages of this design:

  • We can compare enum variables with ==, like ints. If the contained constants differ, we compare the reference of two different instances, which returns false. If they have the same value, they will be represented by the same object, so the references will be the same. Hence, the comparison will return true. It means convenient, fast, and readable comparison.
  • Because they are classes, they can have properties, methods, and constructors. The only limitation we have is the visibility of the constructors: they are always private.
  • We cannot accidentally instantiate them (because of the private constructor). Instantiation always happens as a new constant declaration.
  • They are always ordered, which can be rather valuable sometimes. The order is the declaration order, and we can query the (0-based) ordinal with the implicit int ordinal() method.
  • Similarly, we can access the declared name using the String name() method or convert the name to a constant with the static T valueOf(String) method.
  • Instances are always public static final, and constructors are private. We don't have to specify these keywords (in fact, we get an error if we use different visibility).
  • Because of the private constructor, we cannot extend enums (would it even make sense?).

Applying Smart Enums

Because of the above, we can rewrite the Size class to an enum:

class Pizza {
  enum Size {
    SMALL(2, 0.5),
    NORMAL(3, 0.75),
    LARGE(4, 1.25),
    MONSTER(6, 2);

    int price;
    double weight;

    Size(int price, double weight) {
      this.price = price;
      this.weight = weight;
    }

    int getPrice() {
      return price;
    }

    int getWeight() {
      return weight;
    }
  }

  List<String> toppings;
  Size size;

  Pizza(List<String> toppings, Size size) {
    this.toppings = toppings;
    this.size = size;
  }

  int price() {
    return size.getPrice();
  }

  double weight() {
    return size.getWeight();
  }
}

Enter fullscreen mode Exit fullscreen mode

It's very similar to our previous example but with less boilerplate code. Sweet.

Dealing With User Input

So far, we have determined values based on size. But that size comes from the user. If we have a web application, it's most probably comes from a dropdown or a radio button:

<select name="size">
  <option value="SMALL">Small</option>
  <option value="MEDIUM">Medium</option>
  <option value="LARGE">Large</option>
  <option value="MONSTER">Monster</option>
</select>
Enter fullscreen mode Exit fullscreen mode

And here, sizes are strings. How can we convert those to Size instances?

In Java, it's pretty straightforward with the enum solution:

String sizeName;
Pizza.Size size = Pizza.Size.valueOf(sizeName);

Enter fullscreen mode Exit fullscreen mode

But what if we need to work with a different language, let's say JavaScript? We could always introduce a switch-case, but we know better now.

A better solution is to use lookup tables. Every language has an implementation for it with possibly different names: map, dictionary, table; you name it.

For example, in JavaScript, the simplest solution is to use an object:

const sizes = {
  SMALL: Pizza.Size.SMALL,
  MEDIUM: Pizza.Size.MEDIUM,
  LARGE: Pizza.Size.LARGE,
  MONSTER: Pizza.Size.MONSTER,
};
Enter fullscreen mode Exit fullscreen mode

The usage is straightforward:

let sizeName;
let size = sizes[sizeName];
Enter fullscreen mode Exit fullscreen mode

If we want, we can even inline the constant declarations:

const sizes = {
  SMALL: new Pizza.Size(2, 0.5),
  NORMAL: new Pizza.Size(3, 0.75),
  LARGE: new Pizza.Size(4, 1.25),
  MONSTER: new Pizza.Size(6, 2),
};
Enter fullscreen mode Exit fullscreen mode

Since lookup tables are more flexible in certain circumstances, this is a good solution, too.

Conclusion

In this post, our type code was the pizza size. We can use the above solutions effectively if:

  • A handful of attributes' values depend on the type code
  • The methods' behavior is always the same (They don't do different things if the type code changes. Returning different values is a single behavior. Eating a pizza or wearing it as a hat are not.)

We introduced a few possible ways to refactor the code and get rid of switch-case:

  • Introducing a class with a fixed set of constant instances3
  • In Java, using enums to achieve the same
  • Using lookup tables

If the behavior of our class also depends on the type code, we have to use different approaches. We will cover them in the following parts.


  1. Yummy 

  2. For a deeper explanation, take a look at the first part of the series 

  3. This refactoring even has a name: Replace Type Code With Class 

Top comments (0)