JavaRush /Courses /Kotlin SELF /Custom serializer

Custom serializer

Kotlin SELF
Level 48, Lesson 3
Available

1. Why you need a custom serializer

If you used to think that serialization is “press a button, get JSON”, today we will take a step toward a more mature idea: JSON is an external contract. The external contract sometimes requires a format that is inconvenient (or dangerous) to express directly through data class fields. And this is where a custom serializer appears: a tool that tells the library exactly how your type turns into JSON and back.

Imagine a simple real-life situation: you are building a console app (our learning mini-project is a hypothetical ExpenseTracker) where you store expenses. Inside the code, you want to store the amount as a whole number in kopecks/cents so you do not suffer from Double. But in JSON, you want to see the amount as the string "12.34", because it is readable and looks like money rather than internal math.

If you leave everything “as default”, you will get something like:

{ "cents": 1234 }

But you want:

"12.34"

And this is exactly the case where standard “object → object” serialization does not fit, and you need a custom serializer.

A small table to lock in the difference (format is part of the contract):

Question Standard serialization Custom serializer
Who decides the format? The library, based on the class structure You, explicitly
Can you store the type as a string/number instead of an object? Usually not (if the type is a class) Yes
Risk of breaking reverse reading Medium (when fields change) High if you do not keep encode/decode symmetric
When to apply “Normal” models When the format must be special

2. Mini-case: money as a string in JSON, but an integer internally

Let’s smoothly connect theory to practice. We keep thinking in the style of the project: there is an expense; it has a description and an amount. In code, you really want to store the amount in the least “fragile” form—e.g., in kopecks/cents (Long). This is not because programmers like suffering; it is because Double likes surprises: today you have 0.1 + 0.2, and tomorrow you suddenly get 0.30000000000000004, and accounting looks at you like someone who “optimized” reality.

Create a Money type and an expense model:

import kotlinx.serialization.Serializable

@Serializable
data class Money(val cents: Long)

@Serializable
data class Expense(
    val title: String,
    val amount: Money,
)

Now the question: what will be in JSON? By default, Money is an object, and the amount will end up as { "cents": 1234 }. For machines that is fine, but for humans (and sometimes for integrations) it is not great. We want it more compact: let Money be stored as the string "12.34".

And yes, this is that moment when a programmer with a serious face says: “I’m not complicating things. I’m making the contract stable.” And that is true.

How to enable custom serialization: @Serializable(with = ...)

Before writing a serializer, it is important to understand how kotlinx.serialization even knows that you want “non-standard” behavior. The clearest way for a beginner is to specify the serializer right in the @Serializable(with = ...) annotation. It is like saying: “For this type, don’t guess—here are the instructions for how to read/write it.”

It looks like this:

import kotlinx.serialization.Serializable

@Serializable(with = MoneyAsStringSerializer::class)
data class Money(val cents: Long)

For now, there is a mysterious name MoneyAsStringSerializer. In a couple of minutes, we will write it.

It is important to catch the idea: the Money type remains a normal Kotlin type, with its own checks, methods, and convenience, but the JSON shape is no longer required to mirror the field structure. This is the “separation of internal representation and external format.”

3. The KSerializer<T> interface: descriptor, serialize, and deserialize

Now comes the moment when it feels like we are digging into “the guts”. Do not be afraid: we are not going to build our own JSON parser (thank the library gods). We will simply tell kotlinx.serialization how to represent a value. For that, the KSerializer<T> interface is used.

A serializer has three key parts:

flowchart TD
    A["Kotlin object Money"] -->|serialize| B["Encoder"]
    B --> C["JSON value (string)"]
    C -->|deserialize| D["Decoder"]
    D --> E["Kotlin object Money"]

1) descriptor — a description of what kind of data the type is serialized as (string, number, object, etc.).
2) serialize(encoder, value) — how to write the value into JSON.
3) deserialize(decoder) — how to read JSON and reconstruct the Kotlin object.

One more practical point: deserialize must be strict and honest. If the string is broken, it is better to throw a clear error than to silently “fix” it and then spend an hour looking for why amounts suddenly became zero. This is exactly the fail-fast style you have already seen earlier via require/check and exceptions.

4. Writing the serializer: Money as the string "12.34"

Let’s do it gradually, in small pieces (so we do not turn the lecture into a 200-line canvas that nobody reads). We will start with the skeleton and the descriptor.

Skeleton and descriptor: saying “I am a string”

It is convenient to implement the serializer as an object, because it does not store state (it is just a set of rules):

import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor

object MoneyAsStringSerializer : KSerializer<Money> {
    override val descriptor: SerialDescriptor =
        PrimitiveSerialDescriptor("Money", PrimitiveKind.STRING)
}

The key idea: PrimitiveKind.STRING means “in JSON I will be represented as a string.” Not an object, not an array, not a number—specifically a string.

serialize: turn cents into a string

Now we teach the serializer to write the value. We need to turn 1234 into "12.34". Here, integer division and remainder are helpful, plus padStart so the cents part is always two digits.

import kotlinx.serialization.encoding.Encoder

override fun serialize(encoder: Encoder, value: Money) {
    val major = value.cents / 100
    val minor = (value.cents % 100).toString().padStart(2, '0')
    encoder.encodeString("$major.$minor")
}

Notice: we do not use Double at all. This is a deliberate decision: money is better kept in whole units of the minimal currency. And yes, this is that case where “I’m not paranoid; I’ve just already seen 0.30000000000000004.”

deserialize: turn a string into Money(cents = ...)

Now the most important part—the reverse transformation. This is where “safe parsing” appears. We must:

1) trim spaces,
2) find the dot,
3) make sure there are digits before and after the dot,
4) make sure there are exactly 2 digits after the dot (if we chose that contract),
5) parse numbers via toLongOrNull(),
6) assemble cents.

Let’s start with the base:

import kotlinx.serialization.encoding.Decoder

override fun deserialize(decoder: Decoder): Money {
    val s = decoder.decodeString().trim()
    return parseMoneyOrThrow(s)
}

We will move parsing into a separate function so the code stays readable.

5. Safe string parsing: why split is a trap

Now for an important discussion. In beginner code, you often see something like: val parts = s.split("."). And this is where the comedy begins: it is funny at first, and then you cry in try/catch.

Problem #1: in some languages, "." is the regular expression meaning “any character.” Kotlin has overloads for split, and it is easy to write code that works “not the way you think.” Problem #2: even if splitting works, you did not check that there are two parts, that the second part has length 2, that it contains digits, that the string is not "12.", not ".34", not "12.3", not "12.345", not "12,34", and so on.

So we do parsing as straightforward as possible: find the dot via indexOf('.'). It is boring, but boring is good. Boring code usually does not wake you up at 3 a.m.

Here is the parsing function:

import kotlinx.serialization.SerializationException

fun parseMoneyOrThrow(text: String): Money {
    val dot = text.indexOf('.')
    if (dot <= 0 || dot == text.lastIndex) {
        throw SerializationException("Bad money format: '$text'")
    }
    val majorText = text.substring(0, dot)
    val minorText = text.substring(dot + 1)
    if (minorText.length != 2) {
        throw SerializationException("Bad money cents: '$text'")
    }
    val major = majorText.toLongOrNull()
        ?: throw SerializationException("Bad money major: '$text'")
    val minor = minorText.toLongOrNull()
        ?: throw SerializationException("Bad money minor: '$text'")
    return Money(major * 100 + minor)
}

Yes, there are more checks here than lines for the “happy path.” But that is normal: input parsing is always “checks and more checks.” You have already seen this style in user input processing (toIntOrNull(), validations, require/check).

One more nuance: we chose the contract of exactly 2 digits after the dot. This is convenient and unambiguous. If you want to accept "12.3" as "12.30", you can do that, but then the contract becomes less strict and you need to explicitly specify the normalization rule.

6. Putting it all together: Money, the serializer, and expenses JSON

Now let’s make a complete example of what this looks like for our ExpenseTracker. We will create an expense, serialize it, and immediately look at the JSON.

Model and serializer wiring

import kotlinx.serialization.Serializable

@Serializable(with = MoneyAsStringSerializer::class)
data class Money(val cents: Long)

@Serializable
data class Expense(val title: String, val amount: Money)

Encoding to JSON

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

fun main() {
    val json = Json { prettyPrint = true }
    val e = Expense(title = "Coffee", amount = Money(299))

    println(json.encodeToString(e))
    // {
    //     "title": "Coffee",
    //     "amount": "2.99"
    // }
}

You saw the key point: amount became the string "2.99". And now the file you store on disk (or send somewhere) looks like a human document rather than internal bookkeeping in cents.

Decoding back

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

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

    val s = """{"title":"Coffee","amount":"2.99"}"""
    val e = json.decodeFromString<Expense>(s)

    println(e.amount.cents) // 299
}

Notice: internally, we got cents = 299 again. That means you can safely compute sums, add them up, compare them, without touching Double.

Format symmetry: what you write is what you must be able to read

There is one rule worth keeping as a mantra when you write a custom serializer: decode(encode(x)) == x. If this does not hold, you are almost guaranteed to eventually catch a “bug ghost.”

For example, if in serialize you always write 2 digits after the dot, but in deserialize you accept any number of digits and round—you introduce asymmetry. A user saves "2.999", you read it as 299 (or 300), and then you save it back as "2.99" (or "3.00"). In the best case, the data “drifts”; in the worst case, you get a complaint like “the program stole my money.”

That is why in this lecture we intentionally made a strict contract: either a string in the format NNN.NN, or an error. An error is unpleasant, but honest and diagnosable. And “silently fixed it somehow” is like taping over the check engine light: the annoying indicator is gone, the engine is not.

7. Typical mistakes

Mistake #1: serialization and deserialization are not symmetric.
A very common trap: you nicely serialize "12.30", but when reading you allow "12.3" and turn it into 123 (or 120), and then when writing you end up with "12.30" again. It seems like “it’s fine,” but on real data it turns into format drift and unexpected changes on re-save.

Mistake #2: string parsing is written “hope-based”, without checks.
When in deserialize you have something like val parts = s.split('.') and immediately parts[0].toInt(), the program starts living in a world where all users are perfect. And then a string like " 12.34 " or "12,34" or "12." arrives, and you catch an exception not where you can show a clear message, but where “everything just crashed.”

Mistake #3: using Double inside the serializer “for convenience”.
Sometimes you want to do: val d = s.toDouble(); val cents = (d * 100).toLong(). This is convenient until the first binary-precision issue. If the Money type is meant to be exact (via Long cents), then parsing must be exact too—via integers and controlling the number of digits.

Mistake #4: throwing an overly “generic” exception without context.
If you throw error("bad"), then during debugging you are your own enemy. It is better to throw SerializationException with text that includes the original string. Exceptions are a tool, and it is useful to make them informative rather than mysterious.

Mistake #5: the serializer starts “fixing business data”.
A serializer should be responsible for format, not meaning. If your money cannot be negative, that is a business rule. You can validate it in the model (for example, init { require(cents >= 0) }) or at the data validation layer, but do not turn the serializer into a “secret accountant” that suddenly calls abs() and stays silent.

Mistake #6: an overly large custom serializer instead of a small clear one.
If the serializer grows into a monster that supports 12 formats, understands commas, spaces, currencies, suffixes, and “like in Excel”—you almost certainly mixed responsibilities. It is better to choose one strict format, and if you need to support multiple, handle that in a separate normalization layer before serialization.

1
Task
Kotlin SELF, level 48, lesson 3
Locked
Cents on screen
Cents on screen
1
Task
Kotlin SELF, level 48, lesson 3
Locked
Safe parser
Safe parser
1
Task
Kotlin SELF, level 48, lesson 3
Locked
Parser that throws on error
Parser that throws on error
1
Task
Kotlin SELF, level 48, lesson 3
Locked
Money as a string
Money as a string
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION