Why would you wrap a String or Long in a value class such as UserId or Email instead of using the raw primitive type directly?
answer
- Stringly-typed -> distinct compile-time type
- UserId vs OrderId can't be swapped
- value class + @JvmInline wraps ONE primitive
- Type safety with ~zero overhead via inlining
- Home for validation + domain methods
basics
~20 sTo make the type meaningful and safe. A plain String could be anything; a UserId can only be a user id, so the compiler stops you from mixing up a user id with an order id or an email.
solid answer
~40 sRaw primitives are 'stringly typed': a function taking two String parameters (userId, email) lets callers swap them with no compile error. Wrapping each in a separate value class (`@JvmInline value class UserId(val raw: String)`, `value class Email(val value: String)`) gives each a distinct compile-time type, so the compiler rejects passing an Email where a UserId is expected. You get domain-level type safety and self-documenting signatures with essentially zero runtime overhead, because the compiler inlines the wrapper to the underlying primitive in most cases. This is the canonical use case for value classes: typed identifiers and typed scalars (Email, Money, Percentage). It also gives a natural home for validation and domain methods on that concept.
code
kotlin · 9 lines@JvmInline value class UserId(val value: String)
@JvmInline value class Email(val value: String)
fun sendWelcome(id: UserId, to: Email) { /* ... */ }
val id = UserId("u-123")
val email = Email("[email protected]")
sendWelcome(id, email) // OK
// sendWelcome(email, id) // compile error: types swappedgo deeper
Knows the motivation: a value class makes a raw primitive a distinct, meaningful type so the compiler catches mix-ups.
Contrasts it with type alias and regular class, and notes the inlining gives near-zero overhead.
Frames it as eliminating primitive obsession at the domain boundary and adding validation, while understanding the one-property constraint.
Discusses where typed primitives belong in the architecture (domain model vs DTO edges), serialization implications, and team conventions for adopting them.
## The problem: primitive obsession When a domain concept (a user id, an email address, a quantity of money) is represented by a raw primitive like `String`, `Long`, or `Int`, the type system can't tell two concepts apart. Both `userId` and `orderId` are just `String`, so the compiler happily lets you swap them: ```kotlin fun transfer(fromAccount: String, toAccount: String) { /* ... */ } // Caller accidentally swaps the arguments — compiles fine, breaks at runtime transfer(toAccount, fromAccount) ``` This is called **primitive obsession** or being 'stringly typed'. ## The fix: a value class as a typed primitive A **value class** (declared `value class` and, on the JVM, annotated `@JvmInline`) wraps **exactly one** read-only property. It introduces a brand-new compile-time type around an existing primitive: ```kotlin @JvmInline value class AccountId(val value: String) fun transfer(from: AccountId, to: AccountId) { /* ... */ } ``` Now `AccountId` and, say, `Email` are different types — the compiler rejects passing one for the other. You make illegal states unrepresentable at compile time. ## Why not just a regular class? A regular `class Email(val value: String)` also gives type safety, but it allocates a real object on the heap. A **value class** is special: the Kotlin compiler tries to **inline** it — at runtime it is represented directly by its single underlying value (the `String`), with no wrapper object allocated in the common case. So you get the safety of a distinct type with (usually) the performance of the raw primitive. (Boxing exceptions exist but are out of scope here.) ## Bonus benefits - **Self-documenting APIs:** `fun findUser(id: UserId)` reads better than `fun findUser(id: String)`. - **A home for behavior/validation:** you can add an `init` block (Kotlin 1.4.30+) to validate, and add methods/computed properties on the concept. - **Refactor safety:** changing the underlying representation later is localized. ## Keywords to know `value class`, `@JvmInline`, single `val` property, primitive obsession, type safety, inlining.
- Does wrapping a String in a value class cost a heap allocation on every call?Usually no — the compiler inlines the wrapper so it is represented by the underlying String directly. An object is only materialized in specific boxing situations (nullable use, generics, etc.), which is a separate topic.
- Could you achieve the same type safety with a type alias?No. A `typealias Email = String` is just a different name for the same type, so Email and String stay interchangeable and the compiler offers no protection. A value class creates a genuinely distinct type.
Like labeling identical jars 'Salt' and 'Sugar' — same white powder underneath, but the labels stop you from grabbing the wrong one.
saying these in an interview costs you the question
- Claiming a value class can wrap multiple properties for an id
- Saying type alias gives the same compile-time safety
- Thinking every value-class usage allocates a heap object
- Confusing value class with a regular data class wrapper
- Believing primitives already give domain type safety