When designing a public Kotlin library API, what boxing- and mangling-related trade-offs should drive whether you expose a @JvmInline value class versus a plain type or a data class — especially considering Java consumers and identity-sensitive operations like equals/hashCode and `===`?
answer
- Public API = contract, not just an optimization
- Mangling hurts Java; Int↔value-class swap is a binary break
- Zero-cost is conditional; boxes at boundaries
- === unreliable; boxes are fresh/uncached
- equals/hashCode structural over the wrapped value
basics
~20 sValue classes give type safety with no allocation in the common case, but they box at many boundaries and produce ugly, Java-unfriendly names. Weigh those costs against the safety benefit, especially for Java users and code that compares object identity.
solid answer
~50 sExposing a `@JvmInline value class` in a public API is a contract decision, not just an optimization. Pros: distinct type safety with zero allocation when used directly (Kotlin callers, hot paths). Cons that drive the trade-off: (1) Mangled JVM names make functions awkward from Java unless you add `@JvmName`, and Int↔value-class param changes are binary-incompatible. (2) Boxing leaks back at every generic/nullable/`Any`/collection boundary, so the 'free' promise is conditional and easy for consumers to lose. (3) Identity: `===` on value classes compares the underlying value (there's no stable wrapper identity), and boxed instances aren't cached, so you must never rely on reference identity; `equals`/`hashCode` are structural over the wrapped value. For Java-heavy consumers or identity-sensitive APIs, a plain typealias-free wrapper or a small data class may be more predictable. Reserve value classes for Kotlin-first APIs where the type-safety-per-byte win is real.
code
kotlin · 14 lines@JvmInline value class UserId(val raw: Long)
fun a() {
val x = UserId(1)
val y = UserId(1)
println(x == y) // true: structural over raw
val bx: Any = x
val by: Any = y
println(bx === by) // NOT guaranteed: separate, uncached boxes
}
// Java-friendly export:
@JvmName("lookupById")
fun lookup(id: UserId): String = TODO()go deeper
Knows value classes save allocations but not the API-contract or identity nuances.
Can compare value class vs data class on allocation and equality, and mentions Java friction.
Weighs boxing leaks, mangling, and binary compatibility together when shaping an API.
Sets a deliberate policy across Java interop, conditional zero-cost, identity semantics, and alternatives, choosing per consumer profile.
## The decision frame Choosing `@JvmInline value class` for a **public** API surface trades raw inlining for ergonomic and contractual costs. Evaluate along four axes. ### 1. Java interop & binary compatibility - Functions taking/returning the value class get **mangled** JVM names (hyphenated hash suffix), so Java can't call them without a `@JvmName` alias. - Swapping a parameter between `Int` and the value class changes the mangled name → **binary break** for existing compiled callers. - If a meaningful fraction of consumers are Java, the friction may outweigh the safety gain. ### 2. Conditional zero-cost The allocation-free benefit holds only for the concrete, non-null, non-generic path. It **leaks** at: - nullable use (`T?` for primitive-backed), - generics / collections (`List<T>`, `map`, `Comparator`), - `Any`/supertype/interface dispatch. Consumers can unknowingly box on every element, so don't market it as universally free. ### 3. Identity & equality semantics ```kotlin @JvmInline value class UserId(val raw: Long) ``` - `equals`/`hashCode` are **structural** over the wrapped value (auto-generated), like a data class. - `===` (referential equality) is unreliable: there's no guaranteed stable wrapper identity, and boxing produces **fresh, uncached** objects (unlike small `Integer` caching). Two boxes of the same value may be different references. - Therefore never key behavior on `===` or on a value class instance's identity. Locking on it, identity maps, etc. are bugs. ### 4. Alternatives - **Plain underlying type** (`Long`): zero ceremony, full Java ergonomics, but loses type safety (can mix up IDs). - **`typealias`**: documentation only, no safety, no boxing — but no real type. - **`data class`**: always a real object (allocation + identity), great Java story, structural equals; pay allocation everywhere but predictable. - **`@JvmInline value class`**: best per-byte safety for Kotlin-first hot paths; worst Java ergonomics. ## A pragmatic policy - Internal/Kotlin-first, allocation-sensitive → value class. - Public, Java-facing, or identity-sensitive → prefer data class or plain type, or provide `@JvmName` overloads and document boxing. - Never expose value classes through APIs that depend on reference identity. ## Bottom line Value classes are a sharp tool: a zero-cost typed wrapper whose guarantees and ergonomics degrade exactly at the boundaries public APIs live on. Design the surface deliberately.
- Can you rely on `===` to dedupe value-class instances?No. Boxing creates fresh, uncached objects and there's no stable wrapper identity; use structural == over the wrapped value.
- When would a data class be the better public choice?When Java consumers matter or the API needs predictable object identity/ergonomics, accepting the allocation cost everywhere.
- How do you keep a value-class API usable from Java?Add @JvmName aliases (and document boxing), or expose plain-type overloads at the boundary.
A value class is a featherweight passport stamp — great for fast domestic travel, but every international border (Java/generics/null) makes you fill out the full form again.
saying these in an interview costs you the question
- Promising value classes are universally allocation-free
- Relying on === or instance identity for value classes
- Ignoring Java-interop mangling in a public API
- Assuming boxed value-class instances are cached like small Integers
- Treating value class vs data class as purely a style choice