Replacing Type Code With Subclasses
When the Type-Code Affects Behavior
This post is part of a series:
Replacing Type Code With Subclasses (this post)
When a type code doesn’t just label data but controls behavior, a simple class wrapper won’t cut it. Each type needs its own voice - and that’s where subclasses come in.
Today, we’ll use pets to demonstrate the technique. If you’ve been following the series, this builds on the simpler refactoring from last time. If not, no worries, it stands on its own.
Starting Point
We want to model different pets: dogs, cats, and birds. Initially, we might represent this with a type code:
class Pet {
static final int DOG = 0;
static final int CAT = 1;
static final int BIRD = 2;
int type;
Pet(int type) {
this.type = type;
}
String communicate() {
switch (type) {
case DOG:
return "woof";
case CAT:
return "meow";
case BIRD:
return "chirp";
default:
throw new IllegalArgumentException("Unknown pet type");
}
}
String move() {
switch (type) {
case DOG:
return "run";
case CAT:
return "climb";
case BIRD:
return "fly";
default:
throw new IllegalArgumentException("Unknown pet type");
}
}
}And its usage:
Pet buddy = new Pet(Pet.DOG);
System.out.println(buddy.communicate());We already saw that switch-case has several downsides1. It’s not only repetitive but also violates the Open/Closed Principle. Adding a new pet type means editing multiple parts of the class.
Refactoring: Using an Enum
As we learned in the previous post, we can refactor this code into an enum:
enum Pet {
DOG("woof", "run"),
CAT("meow", "climb"),
BIRD("chirp", "fly");
String sound;
String movement;
Pet(String sound, String movement) {
this.sound = sound;
this.movement = movement;
}
String communicate() {
return sound;
}
String move() {
return movement;
}
}Its usage is even simpler than the previous:
Pet buddy = Pet.DOG;
System.out.println(buddy.communicate());So far so good. But while in the first part, pizzas had only data, pets are more dynamic, and they have behavior, too. For example, they can interact with objects, like balls:
class Ball {
void fetch() {}
void bat() {}
void ignore() {}
}
enum Pet {
DOG("woof", "run"),
CAT("meow", "climb"),
BIRD("chirp", "fly");
String sound;
String movement;
Pet(String sound, String movement) {
this.sound = sound;
this.movement = movement;
}
String communicate() {
return sound;
}
String move() {
return movement;
}
void interact(Ball ball) {
switch (this) {
case DOG:
ball.fetch();
break;
case CAT:
ball.bat();
break;
case BIRD:
ball.ignore();
break;
}
}
}Despite previous efforts, switch-case have returned to the code. Fortunately, we can get rid of it.
Operation as Data
The first possibility is to treat operations like data. There are programming languages where functions are first-class citizens, and we can store them in variables. Such languages are JavaScript, Kotlin, or Python. A possible solution in Kotlin can look like the following:
class Ball {
fun fetch() {}
fun bat() {}
fun ignore() {}
}
enum class Pet(
val sound: String,
val movement: String,
val ballInteraction: (Ball) -> Unit
) {
DOG("woof", "run", Ball::fetch),
CAT("meow", "climb", Ball::bat),
BIRD("chirp", "fly", Ball::ignore);
fun communicate() = sound
fun move() = movement
fun interact(ball: Ball) = ballInteraction(ball)
}But in Java, functions aren't first-class citizens. Since Java 8, we can use function-like constructs: functional interfaces, lambda expressions (similar to JS arrow functions), and method references (which are a syntactic sugar for lambdas2):
enum Pet {
DOG("woof", "run", (ball) -> ball.fetch()), // lambda
CAT("meow", "climb", b -> b.bat()), // lambda
BIRD("chirp", "fly", Ball::ignore); // method reference
String sound;
String movement;
Consumer<Ball> ballInteraction; // functional interface expecting a single Ball argument
Pet(String sound, String movement, Consumer<Ball> ballInteraction) {
this.sound = sound;
this.movement = movement;
this.ballInteraction = ballInteraction;
}
String communicate() {
return sound;
}
String move() {
return movement;
}
void interact(Ball ball) {
ballInteraction.accept(ball);
}
}Despite that it works, this solution has multiple downsides:
Violating the Open/Closed Principle: We still have to modify the
Petenum to introduce new pet types.One instance per type: We can have only one instance per pet type. For example, we can’t have two dogs with different names.
Readability: The instantiation is becoming long and hard to read, especially with many arguments and more complex interactions.
Introducing Subclasses
Since Java is an object-oriented language, let’s apply some polymorphism. Refactor our Pet enum to a base class and subclasses for each pet type. This way, we encapsulate the behavior specific to each pet type within its subclass:
interface Pet {
String communicate();
String move();
void interact(Ball ball);
}
class Dog implements Pet {
@Override
String communicate() {
return "woof";
}
@Override
String move() {
return "run";
}
@Override
void interact(Ball ball) {
ball.fetch();
}
}
class Cat implements Pet {
@Override
String communicate() {
return "meow";
}
@Override
String move() {
return "climb";
}
@Override
void interact(Ball ball) {
ball.bat();
}
}
class Bird implements Pet {
@Override
String communicate() {
return "chirp";
}
@Override
String move() {
return "fly";
}
@Override
void interact(Ball ball) {
ball.ignore();
}
}Here, Pet is an interface rather than an abstract class. A class can implement multiple interfaces but extend only one, so interfaces keep our options open.
And its usage:
Pet buddy = new Dog();
System.out.println(buddy.communicate());The advantages of this approach:
Encapsulation of Behavior: Each subclass defines its unique behavior, making the code more organized and readable.
Open/Closed Principle: Our
Petclass is now open for extension but closed for modification. Adding a new pet type becomes much easier.Elimination of
switch-case: We’ve removed the cumbersomeswitch-casestructures, leading to cleaner and more maintainable code.Dynamicity: Subclasses can have as many instances as we need. Also, they can have their own fields and behaviors that other pet types don’t.
This refactoring’s name is (not surprisingly) replace type code with subclasses.
Conclusion
When type codes affect behavior, refactoring them into subclasses offers numerous benefits for maintainability and scalability. It aligns with the principles of good object-oriented design and results in cleaner, more modular code.
But for simple cases, a good old enum with some function references can be a good choice, too.
The downsides of switch case in the first part of the series
Even though it follows the same syntax as Kotlin, it’s just syntactic sugar in Java. In Kotlin, functions are part of the type system.

