skip to content

You're designing a Kotlin library consumed by both Kotlin and Java teams, using `@JvmInline value class` domain types and `internal` helpers. How do you architect the public surface so mangling never bites Java consumers?

level: principalimportance: nice to knowfreq 12%

answer

  1. Java only meets public, unmangled, stable signatures
  2. internal -> public facade, never call mangled internals
  3. value class -> underlying-type overload for Java
  4. @get:JvmName for leaked value-class getters
  5. CI: bin-compat validator + Java consumer smoke test

basics

~20 s

Keep the value classes for Kotlin callers, but give Java a separate, plain-typed public API. Don't let Java touch internal members or value-class-typed methods directly; expose facade functions that take primitive/boxed types and have stable names.

solid answer

~40 s

Treat the Java-facing surface as a deliberately designed layer. (1) Never expect Java to call `internal` members — they're `$module`-mangled and ABI-unstable; if Java needs the logic, expose a `public` facade. (2) For functions touching `@JvmInline value class` types, Java sees `-<hash>` names it can't type; provide either `@JvmName`-annotated entry points (verifying no platform clash) or **overloads that take the underlying type** (`fun amount(cents: Long)`), so Java never handles the wrapper. (3) Use property accessor control (`@get:JvmName`) where value-class-typed properties leak. (4) Run Kotlin/Java consumer smoke tests and a binary-compatibility validator (which ignores mangled members) in CI. (5) Document that value classes are a Kotlin-only ergonomic; Java gets primitives. The goal: Java consumers only ever see stable, unmangled, public signatures — mangling stays an internal implementation detail.

code

kotlin · 11 lines
kotlin
@JvmInline value class Money(val cents: Long)

// Kotlin-idiomatic (mangled to charge-<hash> in bytecode)
fun charge(amount: Money) = chargeCents(amount.cents)

// Stable Java-facing facade: plain Long, unmangled name
fun chargeCents(cents: Long) { /* core logic */ }

// Leaked value-class getter -> give Java a clean accessor
val Account.total: Money get() = Money(0)
val Account.totalCents: Long get() = total.cents

go deeper

for a junior

Understands Java should call plain, public methods, not mangled ones.

for a middle

Adds underlying-type overloads and @JvmName to expose value-class logic to Java.

for a senior

Separates internal helpers from a public facade and controls property-accessor leaks with @get:JvmName.

for a principal

Designs the whole Java surface as a stable primitive-typed layer, backs it with ABI validation + Java consumer tests, and documents value classes as Kotlin-only ergonomics.

## Framing Mangling is invisible to Kotlin callers but a wall for Java. Library design must ensure **Java only meets unmangled, stable, public signatures**. Two mangling sources to neutralize: `internal` members (`$module`) and value-class signatures (`-<hash>`). ## Principle 1 — internals are not API `internal` helpers exist for Kotlin module-internal reuse. Their JVM names are module-derived and unstable. **Never** ask Java to call them. If Java needs the behavior, add a `public` function (a facade) that delegates internally. This keeps Kotlin visibility honest and gives Java a maintained symbol. ## Principle 2 — value classes are a Kotlin ergonomic `@JvmInline value class Money(val cents: Long)` erases to `Long`, and any function using it gets a hyphenated name Java can't reference. Options, in order of preference: ```kotlin @JvmInline value class Money(val cents: Long) // Kotlin-facing, idiomatic fun charge(amount: Money) { chargeCents(amount.cents) } // Java-facing overload using the underlying type — no wrapper, stable name fun chargeCents(cents: Long) { /* core logic */ } ``` - **Underlying-type overload** (best): Java never sees `Money` at all. - **`@JvmName` entry point**: gives a clean name but you must guarantee no platform-declaration clash with the underlying-type overload. - **Property accessors**: a `val total: Money` getter is mangled; use `@get:JvmName("getTotalCents")` or expose `val totalCents: Long`. ## Principle 3 — guard with tooling - A **binary-compatibility validator** dumps the `public`/`protected` ABI; it ignores mangled internal/value-class members, so reviewing its output confirms what Java actually sees. - Add a small **Java consumer module in tests** that compiles against the library — if Java can't reach an intended API, the mangling leaked and the build fails fast. ## Principle 4 — documentation and intent State clearly: value classes are zero-cost types for Kotlin; Java callers use primitive overloads. This prevents Java teams from hand-coding hyphenated names (which they can't type anyway) or depending on internal `$module` symbols. ## Edge cases - **Unsigned types** (`UInt`, `ULong`) are value classes too → same mangling; avoid them in Java-facing signatures. - **Nullable value class** (`Money?`) forces boxing and changes the signature/mangling — be deliberate. - **Multi-field value classes** (newer Kotlin) erase differently; re-check the Java view. ## Key takeaway Don't fight mangling at the call site; **design it out** of the Java surface. Mangled names should only ever appear behind public, primitive-typed, stably-named facades.

  • Why prefer an underlying-type overload over `@JvmName` for Java consumers of a value-class function?
    The overload keeps Java free of the wrapper entirely and avoids platform-declaration-clash risk; `@JvmName` removes the safety net and Java still conceptually deals with the erased type.
  • How can CI catch a leaked mangled name before release?
    Compile a small Java consumer module against the library in tests, and run a binary-compatibility validator — both surface whether intended APIs are reachable and unmangled.

Build a clean lobby (public primitive API) for Java visitors; keep the value-class plumbing and internal corridors behind staff doors they never need to open.

saying these in an interview costs you the question

  • Exposing `@JvmName`-ed internals as the Java API instead of real public facades
  • Forgetting that unsigned types are value classes and also mangle
  • Assuming nullable/multi-field value classes erase identically to single-Long ones
  • No Java-consumer or ABI test to detect leaks

context