1. Introduction
If you’re writing a small program, it feels normal that everything is just String, Int, Long. But as soon as you have at least a mini project (for example, our console expense tracker), a weird reality pops up: the same data type can represent completely different entities.
For example, Int can be both an “expense ID”, and “number of days”, and “discount percent”. And String can be both a “category name”, and an “email”, and a “CLI command”. The compiler doesn’t care: it just sees Int and just String. And that’s where the classic begins: “oops, I passed the wrong thing, but it compiled”.
Our goal today is to give words in code proper names. Sometimes just for readability, and sometimes so that the compiler becomes our strict friend who doesn’t let nonsense through.
2. typealias: a nice name without a new type
When you read a function signature like fun addExpense(id: Int, amount: Long, category: String), your brain pauses: “And Int is what? How many? An ID? The cat’s age?”. And it’s not your fault—this is how primitive types work: they don’t carry meaning.
typealias solves exactly the readability problem. It lets you declare an alias for an existing type: essentially you say, “let String in this place be called CategoryName”. But the important point: this is not a new type, just another name.
Mini example of typealias in our project
Let’s imagine that in our “Expense Tracker” app an expense has:
- id (for now Int)
- category (for now String)
Let’s make the code a bit more “human”:
typealias ExpenseId = Int
typealias CategoryName = String
data class Expense(
val id: ExpenseId,
val title: String,
val category: CategoryName,
val amountCents: Long,
)
Now function signatures become nicer:
typealias ExpenseId = Int
fun removeExpenseById(expenses: MutableList<Expense>, id: ExpenseId): Boolean {
val index = expenses.indexOfFirst { it.id == id }
if (index == -1) return false
expenses.removeAt(index)
return true
}
It looks cleaner: it’s immediately clear that the parameter is specifically the expense identifier.
The main catch with typealias
A critically important fact: typealias is assignment-compatible with the original type and with other aliases of the same original type. Kotlin explicitly emphasizes the difference: typealias is just an alternative name for an existing type, not a new type.
So code like this… will compile:
typealias UserId = String
typealias ProductId = String
fun printUser(id: UserId) {
println("user=$id")
}
fun main() {
val product: ProductId = "P-10"
printUser(product) // compiles because both are String
}
From Kotlin’s point of view it’s “sure, String is String”. But from a meaning point of view, we just fed a function that expects a “user ID” with a product identifier. It’s like labeling a package “coffee” and putting screws inside: formally it’s a box, but expectations suffer.
When typealias is good
After that catch, it’s easy to want to say: “So why do we need it at all?”. We do, and often.
typealias works great when:
- you need to make function signatures readable (especially in utilities/services);
- mixing values up is either unlikely or not critical;
- you don’t want to complicate code with wrappers and .value.
For example, for a “category name” (CategoryName) the mistake “confused it with another String” is possible, but you often still have to validate it with logic anyway (empty string, whitespace, case normalization). There, type safety doesn’t always pay off.
Another common scenario is long functional types (we covered them earlier), where typealias turns an “arrow spaghetti” into a normal word. But in today’s lecture we stay closer to domain values.
3. value class: a distinct type on top of a single value
Now for the “heavy artillery”. If typealias is like “renaming the sign on the door”, then value class is like “building a separate room”. It looks similar: in both cases you want to “express meaning”. But the result is fundamentally different.
value class creates a real new type, which the compiler distinguishes from the underlying type and from other value classes built on top of the same underlying type. That’s exactly what gives us protection from accidental substitutions.
Kotlin describes these as inline value classes: they store one value, and that value gets “inlined” into usage (without a separate “fat object” in most cases).
On Kotlin/JVM (and we are on the JVM right now), a value class is typically written with the annotation @JvmInline.
Example: ExpenseId as a value class
Let’s make the expense identifier a real separate type:
@JvmInline
value class ExpenseId(val value: Int)
And now the model:
data class Expense(
val id: ExpenseId,
val title: String,
val category: String,
val amountCents: Long,
)
What this gives you in practice
Now the compiler won’t let you confuse ExpenseId with “just a number”:
@JvmInline
value class ExpenseId(val value: Int)
fun removeExpenseById(expenses: MutableList<Expense>, id: ExpenseId): Boolean = true
fun main() {
val id = ExpenseId(10)
// removeExpenseById(mutableListOf(), 10) // does not compile
removeExpenseById(mutableListOf(), id) // ok
}
So an “error in meaning” turns into a “compile-time error”. That’s the ideal outcome: you catch the problem before running the program, before tests, before the user writes to you “everything is broken”.
4. Limitations and rules of value class
value classes have limitations. Not because Kotlin is being picky, but because this construct must remain “lightweight” and predictable.
Exactly one field in the primary constructor
An inline value class must have one and only one property initialized in the primary constructor.
So this is allowed:
@JvmInline
value class Money(val cents: Long)
But this is not:
@JvmInline
value class Money(val cents: Long, val currency: String) // error: two fields
If you need two fields, that’s a candidate for a data class, not a value class.
You can have init and validate invariants
A very nice plus: a value class can have an init, and you can immediately protect the meaning of the type. And it’s convenient to do the checks via require(...), which we already used in constructors and init blocks of regular classes.
For example, “money must not be negative”:
@JvmInline
value class Money(val cents: Long) {
init {
require(cents >= 0) { "Money must be non-negative: $cents" }
}
}
This turns a “mental agreement” into a “rule in code”. And that’s great: you can spend brainpower on problems, not on keeping hundreds of agreements in your head.
Why === doesn’t make sense for a value class
Inline value classes can be represented as a “raw value” or as a “wrapper” in some cases. Therefore, referential equality (===) is prohibited for them as meaningless.
Practically, the takeaway is simple: compare value classes as values, using ==.
@JvmInline
value class ExpenseId(val value: Int)
fun main() {
val a = ExpenseId(1)
val b = ExpenseId(1)
println(a == b) // true
}
5. Integrating value class into the “Expense Tracker” project
Now we’ll take a very practical step: strengthen the model so it allows fewer meaningless states.
Creating domain types: ExpenseId and Money
@JvmInline
value class ExpenseId(val value: Int)
@JvmInline
value class Money(val cents: Long) {
init {
require(cents >= 0) { "Money must be non-negative: $cents" }
}
}
Updating the Expense model
Note: we make money a Money type, but we’ll keep the category as a string (or a typealias) for now, because category is more of a “text label”, while money and IDs are especially prone to mix-ups.
typealias CategoryName = String
data class Expense(
val id: ExpenseId,
val title: String,
val category: CategoryName,
val amount: Money,
)
Adding a small ID generator
For now, without “complex storage” and without topics from future lessons—just neatly generate the next ID based on current expenses.
fun nextExpenseId(expenses: List<Expense>): ExpenseId {
val max = expenses.maxOfOrNull { it.id.value } ?: 0
return ExpenseId(max + 1)
}
Yes, it’s not ideal for concurrency, databases, and the real world. But for a learning console app it’s great. And the main thing: you can see exactly where we cross into a “raw number” (.value).
Expense add function
fun addExpense(
expenses: MutableList<Expense>,
title: String,
category: CategoryName,
amountCents: Long,
) {
val expense = Expense(
id = nextExpenseId(expenses),
title = title,
category = category,
amount = Money(amountCents),
)
expenses.add(expense)
}
Notice a nice effect: the Money(amountCents) constructor validates input by itself. If you try to pass -10, the program fails immediately and honestly, instead of creating an “expense of minus 10 cents” (which later breaks reports and raises a philosophical question: “did they pay me to buy bread?”).
Printing an expense
fun printExpense(e: Expense) {
println("#${e.id.value}: ${e.title}") // #1: Coffee
println("Category: ${e.category}") // Category: food
println("Amount: ${e.amount.cents} cents") // Amount: 250 cents
}
Here .value / .cents is the price of type safety. Often that’s totally fine: you explicitly see the boundary between “our semantic type” and a “raw primitive”.
6. typealias vs value class: a selection table
When tools look similar on the surface, a beginner’s brain starts to panic: “what should I choose so it won’t be embarrassing?”. Let’s remove the anxiety with a simple comparison.
| Criterion | typealias | value class |
|---|---|---|
| Creates a new type? | No, it’s an alias of an existing type. | Yes, it’s a distinct type on top of a single value. |
| Protects against mixing up UserId and ProductId? | No | Yes |
| Requires .value / .cents? | No | Yes (usually yes, because there is a wrapper) |
| Can you validate meaning in init? | No (it’s not a class) | Yes, you can init { require(...) } |
| “One field” restriction | Not applicable | Yes, one field in the primary constructor |
| Referential equality === | Possible (if the underlying type is a reference type), but almost never needed | Meaningless and prohibited |
And a very practical everyday rule: if you just want to read code without decoding, use typealias. If you want the compiler to slap your hands for mixing things up, use value class.
7. API nuance: don’t turn code into a “wrapper museum”
There’s a temptation to wrap absolutely everything in a value class: Title, CategoryName, Command, Email, CityName… and suddenly your code starts looking like a wrapper museum, where you can’t just pass a string without three rituals.
Psychologically it’s a understandable temptation: “if it’s type-safe, it must be better”. But in practice type safety must pay for its “ceremonial overhead”.
In our app, what’s genuinely dangerous is:
- IDs (easy to mix up and hard to notice an error),
- money/amounts (a unit error or a minus sign often breaks calculations).
But “category” most often still requires normalization (trim, lowercase) and allows free-form user input. So a typealias is often enough there, and safety is achieved not through types but through string validation.
8. Typical mistakes
Mistake #1: expecting typealias to protect you from mixing up values.
typealias makes code more readable, but it does not create a new type: everything remains compatible with the original type, and the compiler won’t stop you if you pass “the wrong String”. If you need actual protection—use value class.
Mistake #2: trying to make a value class with two (or zero) fields.
An inline value class must have exactly one field in the primary constructor. If you need more data, it’s no longer a value class, but a candidate for a data class.
Mistake #3: forgetting that a value class is a new type, and being surprised “why it doesn’t compile”.
After switching from Int to ExpenseId, you can no longer call functions with “old numbers”. This isn’t a bug, it’s the point of the change: the compiler now requires explicit construction of ExpenseId(…), so you consciously say “this is an ID”.
Mistake #4: not validating meaning even though the type is practically asking for it.
If you introduce Money but allow negative values, you lose half the benefit. init { require(...) } is a great way to embed the invariant right into the type.
Mistake #5: trying to use === with a value class.
Referential equality for inline value classes is meaningless and prohibited, because such values can be represented differently (as a value or as a wrapper). For comparison, use ==, and in most cases that’s exactly what you want semantically.
Mistake #6: wrapping everything and making the API inconvenient.
If you wrap every string and every number into a value class, you get a lot of noisy conversions and .value. Type safety should be targeted: protect places where an error is actually likely and costly (IDs, money, keys, tokens), and don’t turn the whole codebase into a packaging factory.
GO TO FULL VERSION