skip to content

How are equals/hashCode generated for a @JvmInline value class, and what are the implications of a private wrapped val plus init-block validation?

level: seniorimportance: should knowfreq 35%

answer

  1. equals/hashCode/toString derived from the one value
  2. value semantics, not reference identity
  3. private val + factory = hide raw representation
  4. init runs at construction => parse-don't-validate
  5. boxing still compares underlying value

basics

~20 s

Equality and hashCode come from the one wrapped value, so two wrappers are equal when their values are equal. You can make the value private and validate it in an init block so invalid instances can never exist.

solid answer

~50 s

A `@JvmInline value class` derives `equals`, `hashCode`, and `toString` from its single wrapped property: two instances are equal iff their underlying values are equal, and `hashCode` delegates to the value's `hashCode`. This means a `UserId("x")` equals another `UserId("x")` with no reference-identity concerns — exactly what you want for a value-semantics wrapper. You can mark the property `private` to hide the raw value and force callers through factory functions or computed accessors, while still keeping value equality. The primary-constructor `init` block runs **at construction**, so `require(...)`/`check(...)` validation guarantees every constructed instance is valid (a *smart constructor* / parse-don't-validate pattern). Caveat: when a value class boxes (e.g. used as `Any?`, generic, or nullable), equality goes through the boxed wrapper but still compares underlying values, so semantics stay consistent. Note `init`/validation runs on construction, including when re-boxing reconstructs nothing — the value is created once.

code

kotlin · 14 lines
kotlin
@JvmInline
value class Percentage private constructor(private val value: Int) {
    companion object {
        fun of(v: Int): Percentage {
            require(v in 0..100) { "out of range: $v" }
            return Percentage(v)
        }
    }
    operator fun plus(o: Percentage) = of((value + o.value).coerceAtMost(100))
}

val a = Percentage.of(40)
val b = Percentage.of(40)
check(a == b)  // value equality from the wrapped Int

go deeper

for a junior

Knows two value-class instances with equal values are equal.

for a middle

Explains generated equals/hashCode from the value and that init validates at construction.

for a senior

Combines private val + factory + init into a parse-don't-validate domain primitive and reasons about boxing-consistent equality.

for a principal

Sets codebase conventions for correct-by-construction primitives and weighs them against interop/boxing costs.

## Generated equality and hashing For: ```kotlin @JvmInline value class UserId(val raw: String) ``` The compiler generates: - `equals(other)`: true iff `other` is a `UserId` and `this.raw == other.raw`. - `hashCode()`: delegates to `raw.hashCode()`. - `toString()`: like `UserId(raw=...)`. So value classes have **value semantics**: identity is the wrapped value, never object reference. `UserId("a") == UserId("a")` is `true`. ## Private wrapped val The single property can be `private`, which is a common pattern to prevent leaking the raw representation: ```kotlin @JvmInline value class Password private constructor(private val value: String) { companion object { fun of(raw: String): Password { require(raw.length >= 8) { "too short" } return Password(raw) } } fun matches(candidate: String) = candidate == value } ``` Here callers can't read `value` directly and must go through `matches`/factory — encapsulation while staying allocation-free. ## init-block validation (parse, don't validate) The `init` block runs **once at construction**, so it's the place to enforce invariants: ```kotlin @JvmInline value class Percentage(val value: Int) { init { require(value in 0..100) { "out of range: $value" } } } ``` This is the **smart constructor / parse-don't-validate** idea: once you hold a `Percentage`, the type *guarantees* it's in range, so downstream code never re-checks. Throwing in `init` means an invalid instance can never escape construction. ## Interaction with boxing When a value class is **boxed** (used as a nullable `UserId?`, as a generic `List<UserId>` element, or upcast to `Any`/an interface), the runtime materializes the wrapper object. Even then: - `equals`/`hashCode` still compare the underlying value, so `setOf(UserId("a")) == setOf(UserId("a"))` works. - The `init` validation already happened at original construction; boxing/unboxing just moves the same value between representations. ## Why this matters Combining value equality + private val + init validation gives you a **correct-by-construction domain primitive** with no allocation in the hot path — a frequent senior-level use of `@JvmInline`.

  • Does init-block validation run again when a boxed value class is unboxed?
    No. init runs once at original construction; boxing/unboxing just moves the already-valid value between representations.
  • Why make the wrapped val private?
    To encapsulate the raw representation and force access through validated factories or computed accessors, preventing misuse while keeping value equality.

Like a sealed, stamped certificate: once issued (constructed) it's guaranteed valid, and two certificates are 'equal' if they carry the same printed value.

saying these in an interview costs you the question

  • Saying value classes use reference identity for equals
  • Claiming you can't validate in a value class
  • Thinking equality breaks once the value boxes
  • Believing init runs on every unbox
  • Saying the wrapped val cannot be private

context