1. Why you need enum class
If you have ever written something like if (category == "food"), then you are already on the path to the ancient evil called “magic strings”. They are magical because any typo turns your logic into a pumpkin: "Food", "FOOD", "food ", and "fod" look almost the same to a human, but to a program they are different values. In the end, the compiler does not break your program — real life does.
enum class is a way to tell the compiler: “Here is the full list of valid options. There cannot be any others.” And then Kotlin starts helping you: it checks types, suggests options in the IDE, and complains if you try to assign something “not on the list”.
Let’s imagine a small table to feel the difference:
| Approach | What we store | What can go wrong |
|---|---|---|
| Strings | |
typos, different casing, different languages, extra spaces |
| enum class | |
practically only errors when parsing user input (and that is solvable) |
And one more real-world example: expense categories. If you store a category as a string, one day you will have both "Transport" and "TRANSPORT", and a “by categories” report will suddenly show two almost identical categories. That is not a bug, that is a “feature of user input” — but you will be the one suffering.
Minimal enum
When people say “enum”, they often imagine something scary and corporate. In reality, the basic form is as simple as it gets: you list the options separated by commas. Each option (also called an enum entry) is a ready-made value of the enum type.
Here is the most minimal example:
enum class Status { NEW, IN_PROGRESS, DONE }
fun main() {
val s: Status = Status.DONE
println(s) // DONE
}
Here Status.DONE is not a string, not a number, and not “just text”. It is a value of type Status. And Kotlin will not let you accidentally assign something else there:
enum class Status { NEW, IN_PROGRESS, DONE }
fun main() {
val s: Status = Status.DONE
// s = "DONE" // not allowed: String type does not match
}
Key idea: an enum is a “type with a fixed set of values”. That is exactly why enum is a great replacement for strings where “the options are known in advance”.
name and ordinal
When you print an enum via println(s), Kotlin usually prints the entry name (DONE). This is not an accident: every enum value has standard properties name and ordinal. name is the string name of the constant, and ordinal is its index, starting from zero. This is standard enum behavior in Kotlin.
Let’s see:
enum class Status { NEW, IN_PROGRESS, DONE }
fun main() {
println(Status.NEW.name) // NEW
println(Status.NEW.ordinal) // 0
println(Status.DONE.ordinal) // 2
}
name is usually useful for debugging and simple output. But ordinal sounds tempting (“oh, a free index!”) — and that is a trap.
Imagine you saved ordinal to a file as a “status code”, and then decided to reorder the entries:
enum class Status { NEW, DONE, IN_PROGRESS } // changed the order
That’s it: now DONE has a different ordinal. So old data will start being read incorrectly. That is why you cannot treat ordinal as a stable identifier if the order can change (and it almost always can — even if only because you wanted it to look “nicer”).
If you need a stable identifier, make it an explicit enum parameter.
All enum values: entries, values(), and valueOf
A practical question appears almost immediately: “How do I show the user a list of all categories?” or “How do I iterate all options?”. Enums have synthetic (i.e., automatically generated by the compiler) ways to get “all enum values”.
Modern Kotlin provides the entries property, which returns a specialized list of all options.
Example:
enum class Category { FOOD, TRANSPORT, BOOKS }
fun main() {
for (c in Category.entries) {
println(c.name)
}
// FOOD
// TRANSPORT
// BOOKS
}
Previously (and in many old examples on the internet) you will see values() — it returns an array. But nowadays entries is considered a better option: it does not create a new array on each call and reads more “collection-like”. Kotlin officially notes that before entries existed, people used values().
Another useful thing is valueOf("NAME"), which returns an enum value by its string name. But it has a personality: if you pass an unknown name, it throws IllegalArgumentException. That is, valueOf is “strict parsing” — it works well when you are confident in the data.
Enums with parameters
Up to this point, an enum looked like just a list of words. But in a real project you often need each option to have “its own description”, “code”, “human-readable name”, “limit”, etc. And here is the nice part: an enum can have constructor parameters — almost like a regular class.
Let’s make expense categories “human-friendly”: with an English display name and, for example, a stable code.
enum class Category(val code: Int, val title: String) {
FOOD(10, "Food"),
TRANSPORT(20, "Transport"),
BOOKS(30, "Books")
}
fun main() {
println(Category.FOOD.title) // Food
println(Category.FOOD.code) // 10
}
Now instead of ordinal we have code, and it is stable: we defined it ourselves. Reorder options, add new ones — code will not change by itself. This is a great approach if you plan to persist the category somewhere external: a file, a database, or send it over the network.
And title is exactly what you show to the user in the UI or in a report, so you do not print TRANSPORT in all caps (although, of course, if you like the “shouting accountant” style, why not).
Methods inside an enum
Data is good, but sometimes you want each enum entry to do something useful. And that is fine: an enum is a class, and its values are objects of that class. So an enum can have methods that use its parameters (code, title, etc.).
Let’s make a method that formats the category for output:
enum class Category(val code: Int, val title: String) {
FOOD(10, "Food"),
TRANSPORT(20, "Transport"),
BOOKS(30, "Books");
fun label(): String = "$title (#$code)"
}
fun main() {
println(Category.TRANSPORT.label()) // Transport (#20)
}
Notice the ; after the list of values. This is not a decorative semicolon “for seriousness”, but a syntax rule: if an enum has a body (methods, properties), then after the last enum entry you must put a separator ;.
Why? Because otherwise it is hard for the parser to understand where the list of constants ends and the class members begin. Kotlin tries to keep compiler errors understandable, not “something went wrong in the depths of the universe”.
Commas and ; in enums
In practice, the most pain with enums is not the idea or the logic, but “why does it not compile, I only added a method”. The usual answer: “because you forgot ;”.
A rule of thumb: as long as you only list options, it is a comma-separated “list”. As soon as you want to add anything else (method/property), you put ;, and then you have the “regular class body”.
Here is the correct template:
enum class Category(val title: String) {
FOOD("Food"),
TRANSPORT("Transport"),
BOOKS("Books");
fun upperTitle(): String = title.uppercase()
}
And one more small nuance in the same area: the comma between enum entries is mandatory (except after the last one). And ; is placed after the last entry if there are class members below.
If IntelliJ highlights it in red and says something like “Expecting member declaration” or “There must be a semicolon after the last enum entry” — you have almost certainly landed exactly here.
2. Example: enum in console expense tracking
Now let’s do the most useful thing: apply an enum not “in a vacuum”, but in our learning console application. Imagine we already have a simple expense model (we built it earlier using data class) and somewhere in the program we store a list of expenses. Previously, the category could be a string, but we want type safety.
Introduce Category as an enum
Let’s start with the basics: list the categories. To make it convenient for the user, we will give each category a “human-friendly name”.
enum class Category(val title: String) {
FOOD("Food"),
TRANSPORT("Transport"),
BOOKS("Books")
}
Update the expense model
Now Expense stores category as Category, not as String.
data class Expense(
val amount: Int,
val category: Category,
val note: String
)
Safely parse the category from input
The user will type something like food or FOOD. But Category.valueOf(...) requires the exact name and can also throw an exception.
So for “user input” it is better to do a soft parse: normalize the string and search among entries.
Here is a neat function:
fun parseCategoryOrNull(raw: String): Category? {
val key = raw.trim().uppercase()
return Category.entries.firstOrNull { it.name == key }
}
Here entries gives us the list of all enum options. If the user entered something unknown, we return null, and then we handle it in the input logic (without crashing).
For clarity, you can imagine a mini diagram of this process:
flowchart TD
A["User entered a string"] --> B["trim()"]
B --> C["uppercase()"]
C --> D["search Category.entries by name"]
D -->|found| E["return Category"]
D -->|not found| F["return null"]
Add an expense: read → parse → create an object
Let’s assemble a small function for adding an expense. Let the format be simple: amount, category, note (the note is the rest of the line).
fun addExpense(rawAmount: String, rawCategory: String, note: String): Expense? {
val amount = rawAmount.trim().toIntOrNull() ?: return null
val category = parseCategoryOrNull(rawCategory) ?: return null
return Expense(amount = amount, category = category, note = note.trim())
}
And test it with a mini example:
fun main() {
val e = addExpense("120", "food", "lunch") ?: return
println(e) // Expense(amount=120, category=FOOD, note=lunch)
}
For now, the output for category will be FOOD, because that is the name. But for the user we will show title:
fun main() {
val e = addExpense("120", "food", "lunch") ?: return
println("${e.amount} ₣ — ${e.category.title} (${e.note})")
// 120 ₣ — Food (lunch)
}
User hint: what categories exist
Since we have an enum, we can easily print the list of categories:
fun printCategories() {
val text = Category.entries.joinToString { it.name.lowercase() }
println("Categories: $text")
// Categories: food, transport, books
}
entries is again used here as the “single source of truth” for all options.
This approach is good because if you add a category HEALTH("Health"), the hint updates automatically (and you do not have to remember where you manually typed a list of strings).
3. Common mistakes when working with enum class
Mistake #1: keep storing options as strings “because it is faster”.
Sometimes it feels like String is simpler: read, compare, done. But as soon as you have reporting, filtering, statistics, and even a single user who types food with a trailing space, you will start spending time on “string normalization” in a dozen places. Enums move the problem into one place: input parsing. And inside the program, the values become strict and consistent.
Mistake #2: use ordinal as a stable identifier.
ordinal depends on the declaration order. Today FOOD has ordinal = 0; tomorrow you reorder the entries — and everything quietly breaks without warnings. ordinal is fine for very local things (for example, “show the position in the list right now”), but not for storage and not for protocols. If you need a stable code, add your own field, for example code.
Mistake #3: forget the ; before enum methods and properties.
This is a classic situation: you declared the entries, then added a method — and the compiler suddenly starts complaining out of nowhere. The reason is simple: if an enum has class members, the list of entries must be separated by ;. This is a Kotlin enum rule, and it really is mandatory.
Mistake #4: parse user input with valueOf() without protection.
valueOf("FOOD") is convenient, but if the string does not match, it throws IllegalArgumentException. For “external data” (user input), you usually want safe parsing: via entries.firstOrNull { ... } to get null and show a clear message instead of crashing with an exception.
Mistake #5: mix a “human-readable name” with the enum entry’s name.
name is the technical constant name (FOOD); it is great for code and debugging, but it often looks unfriendly to a user. If you want a nice output, add a field like title: String and display it. Then you do not tie the UI to technical names, and you can change the display without rewriting the logic.