Ben Holmes I'm a web dev, UX freak, and restless tinkerer. Let me teach you the art of building websites!

Using Kotlin data classes to eliminate Java POJO boilerplates

5 min read 1465

Using Kotlin data classes to eliminate Java POJO boilerplates

It always amazes me how much Kotlin is able to offer over “plain” Java, and data classes are no exception. In this post, we’ll explore how Kotlin’s data classes take all the boilerplate out of old-school POJOs, the power of built-in equals, hashcode, and copy methods, and learn easy destructuring with generated componentN helpers. Finally, we’ll check out a little gotcha when mixing inheritance with data classes.

Onward!

What is Kotlin?

As a quick refresher, Kotlin is a modern, statically typed language that compiles down for use on the JVM. It’s often used wherever you’d reach for Java, including Android apps and backend servers (using Java Spring or Kotlin’s own Ktor).

How do Kotlin’s data classes compare to old Java habits?

If you’ve set up a POJO in Java before, you’ve probably dealt with some boilerplate-y code: getters and setters, a nice toString for debugging, some overrides for equals and hashCode if you want comparability… lather, rinse, repeat, right?

Well, Kotlin didn’t like all this ceremony. They created a special type of class to handle:

  • A generated equals function based on your constructor parameters (instead of not-so-useful memory references)
  • A nice, human-readable toString() value based on those constructor params
  • A copy function to clone instances at-will, without piping between constructors yourself
  • Destructure-ability by using parens ()

These are some pretty big wins over the POJO standards of yore. Not only will Kotlin handle all the getters and setters for you (since constructor parameters are publicly available by default), but it also gives you comparability for free!

Let’s learn how we can take a huge class declaration like this:

class UniversityStudentBreakfast {
  private int numEggs;
  public UniversityStudentBreakfast(int numEggs) {
    this.numEggs = numEggs;
  }
  public int getNumEggs() {
    return numEggs;
  }
  public void setNumEggs(int numEggs) {
    this.numEggs = numEggs;
  }
  @Override
   public boolean equals(Object o) {
     if (this == o) return true;
     if (o == null || getClass() != o.getClass()) return false;
     UniversityStudentBreakfast breakfast = (UniversityStudentBreakfast) o;
     return numEggs == breakfast.numEggs;
   }
   @Override
   public String toString() {
     return "UniversityStudentBreakfast(" +
             "numEggs='" + numEggs + '\'' +
             ')';
   }
  // don't get me started on copy-ability...
}

…and turn it into a nice one-liner 😄

data class UniversityStudentBreakfast(
  val numEggs: Int,
)

Using the built-in equals trait

Let’s start with huge value-add over standard classes: a built-in equality function based on our constructor params.

In short, Kotlin will generate a neat equals function (plus a complimentary hashCode function) that evaluates your constructor params to compare instances of your class:

We made a custom demo for .
No really. Click here to check it out.

data class UniversityStudentBreakfast(
  val numEggs: Int,
)
val student1Diet = UniversityStudentBreakfast(numEggs=2)
val student2Diet = UniversityStudentBreakfast(numEggs=2)
student1Diet == student2Diet // true
student1Diet.hashCode() == student2Diet.hashCode() // also true

⚠️ Note: Under-the-hood, this calls the equals function for all constructor params when comparing. This sadly means memory reference issues can crop up again when your data classes contain lists or references to other classes.

Using the toString method

Yes, data classes give you a nice toString helper for simpler debugging. Instead of getting a random memory reference for our UniversityStudentBreakfast class above, we get a nice mapping of constructor keys to values:

println(student1Diet)
// -> UniversityStudentBreakfast(numEggs=2)

Using the copy trait

Kotlin’s copy trait addresses a common pitfall of traditional classes: we want to take an existing class and build a new one that’s just slightly different. Traditionally, there’s two ways you could approach this. The first is to manually pipe everything from one constructor to another by hand:

val couponApplied = ShoppingCart(coupon="coupon", eggs=original.eggs, bread=original.bread, jam=original.jam...)

…but this is pretty obnoxious to pull off, especially if we have nested references to worry about duplicating. Option two is to simply admit defeat and open everything for mutation using apply {...}:

val couponApplied = original.apply { coupon = "coupon" }

…but you may not like this approach if your team is working with functional programming techniques. If only we could have a syntax similar to apply that doesn’t mutate the original value…

The good news? If you’re using a data class, copy lets you do just that!

data class ShoppingCart(
  val coupon: String, // just a regular "val" will work
  val eggs: Int,
  val bread: Int,
  ...
)
val original = checkoutLane.ringUpCustomer()
val couponApplied = original.copy(coupon="coupon")

You’ll also notice that copy is just a regular function call without an option for a lambda. This is the beauty of the Kotlin compiler — it generates all the arguments for you based on the constructor params 💪.

Demystifying componentN in Kotlin

With data classes, each property is accessible as a component using extension functions like component1, component2, etc., where the number corresponds to an argument’s position in the constructor. You could probably use an example for this one:

data class MyFridge(
 val doesPastaLookSketchy: Boolean,
 val numEggsLeft: Int,
 val chiliOfTheWeek: String,
)
val fridge = MyFridge(
 doesPastaLookSketchy=true,
 numEggsLeft=0,
 chiliOfTheWeek="Black bean"
)
fridge.component1() // true
fridge.component2() // 0
fridge.component3() // "Black bean"

You may be thinking, “OK, but why the heck would I fetch a value by calling component57()?” Fair question! You probably won’t be calling these helpers directly like this. However, these are pretty useful to Kotlin under-the-hood to pull off destructuring.

Destructuring with Kotlin data classes

Say we have a pair of coordinates on a map. We could use the Pair class the represent this type as a Pair of integers:

val coordinates = Pair<Int, Int>(255, 255)

So how do we grab the x and y values out of here? Well, we can use those component functions we saw earlier:

val x = coordinates.component1()
val y = coordinates.component2()

Or, we can just destructure using parens () on our variable declarations:

val (x, y) = coordinates

Nice! Now we can let Kotlin call those ugly component functions for us.

We can use this same principle for our own data classes. For instance, if we want our coordinates to have a third z dimension, we can make a nice Coordinates class, like so:

data class Coordinates(
 val x: Int,
 val y: Int,
 val z: Int,
)

And then destructure as we see fit 👍.

val (x, y, z) = Coordinates(255, 255, 255)

⚠️ Note: This can get hairy when the argument order isn’t implied. Yes, it’s pretty clear that x comes before y (which comes before z) in our Coordinates example. But if an engineer absent-mindedly moves value z to the top of the constructor, they could break destructuring statements across the codebase!

An important gotcha for inheritance

As you start getting comfortable with data classes, you may start using them as a type-safe object for every occasion.

But not so fast! Problems start to emerge when you start getting object-oriented. To expand on our Fridge example from earlier, say you want a special data class with extra fields to represent your own kitchen mayhem:

data class Fridge(
  val doesPastaLookSketchy: Boolean,
  val numEggsLeft: Int,
)
data class YourFridge(
  val servingsOfChickenNoodleLeft: Int,
) : Fridge()

In other words, you want to piggy-back off the first data class and keep the equality and copy traits in-tact. But if you try this in a playground, you’ll get a nasty exception:

No value passed for parameter 'doesPastaLookSketchy'
No value passed for parameter 'numEggsLeft'

Hm, it looks like we’ll need to duplicate our Fridge constructor to allow all our values to pass through. Let’s do that:

data class Fridge(
  open val doesPastaLookSketchy: Boolean,
  open val numEggsLeft: Int,
)
data class YourFridge(
  override val doesPastaLookSketchy: Boolean,
  override val numEggsLeft: Int,
  val servingsOfChickenNoodleLeft: Int,
) : Fridge(doesPastaLookSketchy, numEggsLeft)

…which leaves us with a much different exception 😬

Function 'component1' generated for the data class conflicts with member of supertype 'Fridge'
Function 'component2' generated for the data class conflicts with member of supertype 'Fridge'
This type is final, so it cannot be inherited from

Now it looks like there’s a problem with using override on these constructor params. This comes down to a limitation of the Kotlin compiler: in order for the componentN() helpers to point at the right value, data classes need to be kept “final.”

So, once you set those parameters, they can’t be overruled (or even extended).

Luckily, you can pull off our inheritance as long as the parent is not a data class. An abstract class would probably do the trick for us:

abstract class Fridge(
  open val doesPastaLookSketchy: Boolean,
  open val numEggsLeft: Int,
)
data class YourFridge(
  override val doesPastaLookSketchy: Boolean,
  override val numEggsLeft: Int,
  val servingsOfChickenNoodleLeft: Int,
) : Fridge(doesPastaLookSketchy, numEggsLeft)

Yes, we still need to duplicate the parameters we want using override, but it does give us some type safety for shared parameters between our data classes, all while keeping the equality, copy, and hashing traits in working order.

Conclusion

As you can tell, data classes offer some nice benefits with almost zero developer overhead. This is why I’d recommend using data almost anywhere you use a regular class for those added comparability benefits. So, go forth and rewrite some POJOs!

: Full visibility into your web apps

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.

.
Ben Holmes I'm a web dev, UX freak, and restless tinkerer. Let me teach you the art of building websites!

Leave a Reply