JavaRush /Courses /Kotlin SELF /Serializing enum and sealed class: discriminator and Ok/E...

Serializing enum and sealed class: discriminator and Ok/Error

Kotlin SELF
Level 48, Lesson 2
Available

1. enum: values in JSON and @SerialName

When you write a program, you constantly need to represent “one of several.” For example: an operation status (success/error), an expense category (food/transport), a command type ("add"/"list"/"remove"), or an order state. The naive approach is to store this as strings like "ok" or "food". It works exactly until the moment you make a typo ("foood"), and the program happily continues to live—just in a parallel universe.

enum and sealed class are ways to tell the compiler: “Look, there are only a few options, and there can’t be any others.” In Kotlin this is especially nice because when over an enum and a sealed class can be exhaustive: if you forget to handle a case, the compiler (in expression mode) will start complaining and save you from a bug. This behavior—“the set of options is finite”—is exactly what you want to preserve in your JSON contract as well.

What an enum looks like in JSON by default

An enum is essentially a “dictionary of allowed values.” In Kotlin, enum class also gives you convenient things like name, ordinal, entries, valueOf(...), and so on. But today we care about the main thing: how an enum turns into JSON.

By default, kotlinx.serialization serializes an enum as a string with the constant name. That is, FOOD becomes "FOOD". This is simple, readable, and often sufficient, but there’s a subtle issue: the constant name is the name in code, while JSON is an external format. Sometimes the external format requires different values (for example, "food" instead of "FOOD"), and sometimes you want to freely rename the constant in code without breaking old files.

Let’s make a mini example based on a training console app for “expense tracking.” One expense model and a category as an enum.

import kotlinx.serialization.Serializable

@Serializable
enum class ExpenseCategory {
    FOOD, TRANSPORT, ENTERTAINMENT
}

Now, serialization:

import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

fun main() {
    val json = Json { prettyPrint = true }

    println(json.encodeToString(ExpenseCategory.FOOD))
    // "FOOD"
}

The key idea: JSON contains the enum constant name. And if tomorrow you rename ENTERTAINMENT to FUN (because you want it shorter), then old JSON files with "ENTERTAINMENT" will no longer be read the same way.

Stable values via @SerialName

At this point, a very human desire usually appears: “Let’s never rename enums.” This is like the promise “I will never eat sweets.” Formally possible; psychologically questionable.

It’s better to separate the “internal name in code” from the “external name in the format.” For this, you can put @SerialName on enum values. Then you can name the constant in code however you like, while storing in JSON what you agreed on in the format.

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
enum class ExpenseCategory {
    @SerialName("food")
    FOOD,

    @SerialName("transport")
    TRANSPORT,

    @SerialName("entertainment")
    ENTERTAINMENT
}

Let’s verify serialization:

import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

fun main() {
    val json = Json { prettyPrint = false }

    println(json.encodeToString(ExpenseCategory.TRANSPORT))
    // "transport"
}

Now the JSON is more “human-friendly” (or more “API-ish”) and, more importantly, more stable: you can rename ENTERTAINMENT to FUN in Kotlin code, but keep @SerialName("entertainment"), and the format won’t suffer.

2. sealed class: discriminator and @SerialName

Why JSON needs a discriminator

If an enum is “one of N values,” then a sealed class is “one of N types.” And each type can carry its own data. It’s like a surprise box, but an honest one: there are only a few surprises, all known ahead of time, and the compiler keeps the list.

A classic example: an operation result. Success carries useful data (for example, the id of a created expense), while an error carries an error message.

import kotlinx.serialization.Serializable

@Serializable
sealed class OpResult {
    @Serializable
    data class Ok(val id: Int) : OpResult()

    @Serializable
    data class Error(val message: String) : OpResult()
}

And then the question arises: how do we store this in JSON?

If we just write the fields, we lose the main thing: when we read JSON back, how do we know—is it Ok or Error? JSON has no built-in notion of a “sealed class variant.” It only sees an object { ... }.

That’s why polymorphic serialization (and sealed is polymorphism “in miniature”) needs a special service field: a type discriminator. This field says: “the variant is this one.”

A typical shape:

{ "type": "ok", "id": 10 }

or

{ "type": "error", "message": "Invalid input" }

Then, when reading JSON, the library looks at "type" and decides which subtype to create.

classDiscriminator in Json { ... }

It’s important that the discriminator has a stable name and doesn’t conflict with your normal fields. The most popular option is "type", but in some domains the word type is already taken by business meaning (“product type,” “operation type”). Then it’s better to choose another name, for example "kind" or "resultType".

In kotlinx.serialization, the discriminator field name is set via the classDiscriminator setting.

import kotlinx.serialization.json.Json

val json = Json {
    prettyPrint = true
    classDiscriminator = "type"
}

A small real-life detail: the setting must be the same where you write JSON and where you read JSON. If you saved a file with "type", and then read it with Json { classDiscriminator = "kind" }, the library will look in the wrong place and won’t “recognize” the variants.

@SerialName on variants: pinning ok/error

Right now our variants are named Ok and Error. If we serialize them “as is,” the external format may start depending on class and package names (and you can absolutely refactor packages). Also, in JSON you want to see short, clear markers: "ok" and "error".

Here @SerialName helps again, but now we put it not on a field and not on an enum value, but on a subtype of the sealed class.

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
sealed class OpResult {

    @Serializable
    @SerialName("ok")
    data class Ok(val id: Int) : OpResult()

    @Serializable
    @SerialName("error")
    data class Error(val message: String) : OpResult()
}

Now we explicitly pin the Ok/Error convention as a format contract, not as “whatever we happened to name the classes.”

Let’s verify serialization:

import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

fun main() {
    val json = Json {
        prettyPrint = true
        classDiscriminator = "type"
    }

    val r: OpResult = OpResult.Ok(id = 10)
    println(json.encodeToString(r))
    /*
    {
        "type": "ok",
        "id": 10
    }
    */
}

Notice a small but important thing: the variable r has type OpResult, not OpResult.Ok. And this isn’t just “prettier”—it affects the JSON.

Nuance: the discriminator appears when serializing through the base type

This is the point where students most often get “why doesn’t it work?” and “why is the JSON different?” The reason is the static type of the expression in Kotlin.

If you serialize an object as the concrete type OpResult.Ok, the library assumes “the type is already known,” so a discriminator might not be needed. But if you serialize as OpResult, you do need a discriminator, because there are multiple variants.

Compare:

import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

fun main() {
    val json = Json { classDiscriminator = "type" }

    val asChild = OpResult.Ok(id = 10)
    val asBase: OpResult = OpResult.Ok(id = 10)

    println(json.encodeToString(asChild)) // {"id":10}
    println(json.encodeToString(asBase))  // {"type":"ok","id":10}
}

Technically both JSON outputs are valid, but the second one can be restored unambiguously because it contains "type". The first one cannot be restored as OpResult without additional information: { "id": 10 } could be anything (and even more so—it could become anything after the model evolves).

A practical rule: if you’re designing a JSON format for a sealed class and want reliable decodeFromString<OpResult>(...), serialize the result through the base type (or explicitly specify the type parameter).

3. Decoding and handling via when

Now let’s do the reverse: from JSON back to a Kotlin object. This is where you’ll feel why we need a discriminator: it’s exactly what allows the library to choose the variant.

import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json

fun main() {
    val json = Json { classDiscriminator = "type" }

    val s1 = """{"type":"ok","id":10}"""
    val s2 = """{"type":"error","message":"Bad input"}"""

    val r1 = json.decodeFromString<OpResult>(s1)
    val r2 = json.decodeFromString<OpResult>(s2)

    println(r1) // Ok(id=10)
    println(r2) // Error(message=Bad input)
}

Next is classic Kotlin: handle the result with an exhaustive when. With a sealed class this is especially nice because Kotlin knows all variants and can require you to handle them all.

fun toHumanText(r: OpResult): String = when (r) {
    is OpResult.Ok -> "Success: created id=${r.id}"
    is OpResult.Error -> "Error: ${r.message}"
}

This when is good because you don’t use null, you don’t use Boolean flags like isOk, and you don’t use “magic” strings. You have a strict result model, and the compiler helps you not forget to handle a variant.

4. Mini diagram of encode/decode for sealed

To internalize “why type appears there,” it helps to view the process as a small pipeline.

flowchart TD
    A["Kotlin: OpResult (sealed)"] -->|encodeToString| B["JSON object"]
    B --> C["discriminator field: type"]
    C --> D["value: ok / error"]
    B -->|decodeFromString⟨OpResult⟩| E["Kotlin: OpResult.Ok or OpResult.Error"]
    E -->|when| F["human-readable text / further logic"]

If at the JSON stage you don’t have type, then the path “back” into OpResult becomes either impossible or turns into guesswork based on an object’s internals (which is usually very fragile).

5. Example in expense tracking: command results as Ok/Error

Now we’ll take a step that feels small, but in practice makes the program more mature: we’ll stop returning “error strings” and start returning a strict result Ok/Error. And then (if we want) we can serialize this result into JSON—for example, for logs, for export, or for tests.

Let’s add minimal application models:

import kotlinx.serialization.Serializable

@Serializable
data class Expense(
    val id: Int,
    val title: String,
    val amount: Int,
    val category: ExpenseCategory
)

And a service that adds an expense and returns OpResult. The logic is intentionally simple, without future architectural “layers”—we’re still learning to keep code understandable.

class ExpenseService {
    private val items = mutableListOf<Expense>()
    private var nextId = 1

    fun add(title: String, amount: Int, category: ExpenseCategory): OpResult {
        if (title.isBlank()) return OpResult.Error("Title must not be blank")
        if (amount <= 0) return OpResult.Error("Amount must be > 0")

        val e = Expense(id = nextId++, title = title, amount = amount, category = category)
        items.add(e)
        return OpResult.Ok(id = e.id)
    }
}

Notice how nicely it reads: the function returns not a Boolean, not an Int where “-1 means error,” and not a String? (where null means success, and a string means an error). It returns a clear domain result.

Now in main we can make a demo and also show result serialization:

import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

fun main() {
    val service = ExpenseService()

    val json = Json {
        prettyPrint = true
        classDiscriminator = "type"
    }

    val r: OpResult = service.add("Coffee", 250, ExpenseCategory.FOOD)
    println(toHumanText(r))                  // Success: created id=1
    println(json.encodeToString(r))
}

If you ask right now, “Why would I serialize OpResult if I’m just printing it?”, the answer is: today this is a learning example, but in real life such results often go either to a file (log), to the network (API), or into test “golden files.” And that’s when the discipline of Ok/Error and stable variant names starts to pay off for real.

6. Common mistakes when serializing enum and sealed class

Mistake #1: storing an enum in JSON as the constant name and then freely renaming it in code.
The problem isn’t that you can’t rename it—the problem is that you silently change the external format. Today you have "FOOD", tomorrow "MEAL", and old files turn into a museum of errors. The fix is simple: lock down external values via @SerialName on enum values, and rename Kotlin names as much as you want (within reason).

Mistake #2: expecting a sealed class to be restored from JSON without a type field.
JSON doesn’t store “subtype information” by itself. If you didn’t add a discriminator, decodeFromString<OpResult>(...) won’t be able to tell which subtype to create. The discriminator is not “extra bureaucracy”—it’s the minimal price for unambiguity.

Mistake #3: choosing a discriminator name that conflicts with a business field.
If you set classDiscriminator = "type", and then add a field val type: String for business meaning, you’re throwing a conflict party. It’s better to reserve a name for the discriminator in advance (for example, "type" or "kind") and not use it in regular data, or choose a less conflict-prone name.

Mistake #4: serializing a sealed variant as a concrete subtype and being surprised that there is no discriminator.
If you serialize OpResult.Ok(...) as OpResult.Ok, the JSON may omit "type". And then you try to read it as OpResult—and that’s it, you’re stuck. If you’re designing an external format, serialize through the base type (or explicitly specify the type parameter as encodeToString<OpResult>(...)) so that the discriminator is always present.

Mistake #5: creating a “zoo” of result variants instead of a stable Ok/Error pair.
It’s very tempting to introduce Success/Failure, then somewhere else Ok/Error, then also Fine/Bad. As a result, code and format drift apart. For a learning project and for most application-level operations, it’s useful to keep a single result template: Ok for success and Error for failure. This makes when handling consistent everywhere and reduces the number of “special cases” you have to keep in your head.

1
Task
Kotlin SELF, level 48, lesson 2
Locked
Expense with a category
Expense with a category
1
Task
Kotlin SELF, level 48, lesson 2
Locked
Operation result
Operation result
1
Task
Kotlin SELF, level 48, lesson 2
Locked
Result message
Result message
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION