JavaRush /Courses /Kotlin SELF /Annotations @SerialName and @Transient

Annotations @SerialName and @Transient

Kotlin SELF
Level 48, Lesson 1
Available

1. Why names and fields in JSON are almost a public API

If you’re just starting to program, it’s easy to think: “Well, the model is mine, the file is mine; tomorrow I’ll rename a field — and that’s it.” But as soon as you save JSON to disk, send it over the network, or simply keep it between program runs, the format turns into a contract. And that’s where the adventure begins: you renamed a property in Kotlin for aesthetics, and old files suddenly stopped being readable.

Imagine JSON is a “form” filled out by your program’s previous versions. If tomorrow you change the form’s question "name" to "fullName", old forms won’t magically become different. You need a compatibility mechanism. That’s exactly what @SerialName and @Transient are for.

Our practical goal for this lecture: learn how to change a Kotlin model (readability, style, refactoring) without breaking the format, and conversely — adapt the format to external requirements without turning Kotlin code into a collection of weird snake_case names.

2. @SerialName: one name in Kotlin, another in JSON

The @SerialName("...") annotation is a way to tell the serializer: “In code, the property is called this, but in JSON I want to see a different name.” It sounds almost boring, but that’s exactly the case where boring = reliable. You separate internal code readability from the external format contract.

It’s important to understand: @SerialName is not about “making it prettier”; it’s about “making it more resilient.” A resilient format has one great bonus: you can refactor Kotlin code without worrying that users (or you, a month later) will open the program and see “can’t read the file, everything is lost.”

Mini diagram of what happens:

flowchart LR
    A[Kotlin property: note] -->|"@SerialName('title')"| B[JSON field: 'title']
    C["JSON field: 'title'"] -->|decode| D[Kotlin property: note]

That is, @SerialName is a two-way agreement: that’s what the field is called both when writing and when reading.

Practice: you renamed a field and didn’t break old JSON

Let’s imagine we’re evolving our console expense-tracking app BudgetBuddy. On the previous day, we already had a serializable model that we save to JSON.

Let’s say an expense used to look like this:

import kotlinx.serialization.Serializable

@Serializable
data class ExpenseV1(
    val title: String,
    val amount: Int
)

At some point you decide that the word title doesn’t reflect the meaning (it’s more like a “note/description”), and you rename the field to note:

import kotlinx.serialization.Serializable

@Serializable
data class ExpenseV2(
    val note: String,
    val amount: Int
)

Now let’s imagine old JSON (it’s already stored on disk):

val oldJson = """{"title":"Coffee","amount":250}"""

If you try to read it as ExpenseV2, the serializer will honestly say: “I don’t know what "title" is, and I don’t see the required note.” This typically ends with an exception.

The fix is done exactly via @SerialName:

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

@Serializable
data class ExpenseV2(
    @SerialName("title") val note: String,
    val amount: Int
)

Now the old JSON will continue to be readable, but in Kotlin code you’ll have the normal name note.

Mini check: read old data and write new data in the old format

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

@Serializable
data class Expense(
    @SerialName("title") val note: String,
    val amount: Int
)

fun main() {
    val json = Json { prettyPrint = true }
    val e = json.decodeFromString<Expense>("""{"title":"Coffee","amount":250}""")
    println(e)                       // Expense(note=Coffee, amount=250)
    println(json.encodeToString(e))  // {"title":"Coffee","amount":250}
}

Notice the last output: we both read and write the field as "title", because that is our format contract. At the same time, in code we live with note.

@SerialName for compatibility with external JSON

Renaming for refactoring is not the only reason to use @SerialName. A second common reason: you receive JSON from the outside world where snake_case is common, while Kotlin uses camelCase.

Suppose the external format requires:

{
  "user_id": 7,
  "full_name": "Ada Lovelace"
}

In Kotlin, we want a normal model:

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

@Serializable
data class UserDto(
    @SerialName("user_id") val userId: Int,
    @SerialName("full_name") val fullName: String
)

And that’s exactly when @SerialName makes the code readable and compatible at the same time. Without it, you either keep properties like user_id (which is bad Kotlin style), or you write a manual JSON parser (which is early and usually painful).

A small table: what to choose and when

Situation What you want in Kotlin What you need in JSON Solution
Refactoring: renamed title → note note old name "title" @SerialName("title")
External format in snake_case createdAt, userId "created_at", "user_id" @SerialName("created_at"), @SerialName("user_id")
You invented the format yourself and it never existed anywhere any any you can do without @SerialName, but it’s better to agree on a naming style in advance

3. @Transient: the field is needed by the object, but not by the format

If @SerialName controls the name in the format, then @Transient controls the very fact that a property is present in the format. That is, the property exists in the Kotlin object, but it is not serialized and not deserialized.

There’s a very important meaning here: @Transient is about the boundary between “data” and “convenience.” For example, you want to keep a cache, a debug marker, a temporary UI value, or any field that must not “leak” into a file. In that case, @Transient is your safety fuse.

Another important detail: for a @Transient property, you almost always need a default value, otherwise when reading JSON it will be impossible to create the object (because the serializer won’t take the value from JSON). In that case, it’s more correct to either provide a default or reconsider the design.

By the way, in the Kotlin ecosystem there are several different “Transient” annotations, and you can accidentally import the wrong one. Even the compiler/tools highlight such cases as a frequent problem.

Example: a debug field that must not end up in saved data

Let’s return to BudgetBuddy. Suppose at some point you want to add a field debugNote to an expense to understand where it came from: typed by hand, imported, restored from a file. This is useful for debugging, but it’s definitely not something that should live in a JSON store forever.

import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient

@Serializable
data class Expense(
    val note: String,
    val amount: Int,
    @Transient val debugNote: String = ""
)

Now, even if you create an object with debugNote, it won’t end up in JSON.

import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json

@Serializable
data class Expense(
    val note: String,
    val amount: Int,
    @Transient val debugNote: String = ""
)

fun main() {
    val json = Json { prettyPrint = true }
    val e = Expense(note = "Coffee", amount = 250, debugNote = "typed by user")
    println(json.encodeToString(e))
    // {
    //     "note": "Coffee",
    //     "amount": 250
    // }
}

And yes, if you’re thinking “can I just make the field private?” — you can, but that’s a different story. private controls access from code, while @Transient controls the format contract. These are two different planes.

Reading JSON: why you need a default and where you can crash

When you read JSON, the serializer creates an object and must fill all properties that are part of serialization. But @Transient is not part of it, so the value is taken only from the default.

If there’s no default, the object “doesn’t come together.” In such situations, it’s better to fail fast and clearly. In Kotlin this is often done via require, check, error — standard precondition functions that throw exceptions when conditions are violated.

Mini example: a bad transient without a default

import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient

@Serializable
data class Session(
    val token: String,
    @Transient val cachedLabel: String // no default — problem
)

With high probability you’ll get an error when trying to decodeFromString<Session>(...).

Another nuance: what if the field exists in JSON

Imagine that in old JSON there was a field "debugNote" for some reason, and now you made it @Transient. For the serializer, this field becomes “unknown.” If your Json is configured as Json { ignoreUnknownKeys = false } (that’s the default), reading may crash because of the extra field.

So @Transient is not “magically ignore the field”; it is specifically “this field is not part of the schema.” Ignoring unknown fields is a separate Json setting that we already know from previous lectures.

A careful style: separate storage and convenience

Sometimes students want to put absolutely everything into one @Serializable model: what must be stored, what’s convenient for computations, caches, and some temporary flags. It works… until the first serious change.

A calmer style is to remember that a serializable model is like a “data contract,” and it should be fairly boring: simple fields, clear names, minimal surprises. And “convenience things” are either transient fields with a default, or computed by separate functions, or live separately altogether.

While we’re not diving into big architecture (that will be a separate big discussion later), a simple rule is enough: if a field is not required to survive save/load, then either make it @Transient, or don’t make it part of the @Serializable model.

4. Common mistakes

Mistake #1: you renamed a property in Kotlin and forgot that JSON files already exist.
The most frustrating mistake is the one that doesn’t show up immediately. You changed title to note, everything compiles beautifully, new files are saved, but old ones no longer load. The cure is discipline: if the format is already “out in the world,” stabilize the name with @SerialName, and change the Kotlin name however you like.

Mistake #2: using @SerialName as a “make it pretty” tool, instead of as a contract.
When @SerialName is added “just in case” to every field, the format turns into a set of random strings, and migrations become harder. The purpose of the annotation is to lock down important places: compatibility with old files, matching an external API, a single naming style (for example, snake_case).

Mistake #3: adding @Transient but not providing a default value.
@Transient means the field will not be read from JSON. Therefore, during decode it has to come from somewhere — almost always from a default. If there is no default, the object cannot be created. And this is not “nitpicking”, but constructor logic: without a value, the field is not initialized.

Mistake #4: importing “the wrong Transient.”
In Kotlin/JVM you can encounter other Transient annotations, and beginners sometimes auto-import them via the IDE. As a result, the field is not excluded from serialization or behaves differently than expected. So it’s useful to verify the import visually: you need exactly kotlinx.serialization.Transient. In practice this is such a common story that even tools/inspections warn about it explicitly.

Mistake #5: assuming @Transient will automatically “swallow” a field from incoming JSON.
If a field exists in JSON, but the model doesn’t have it (or it is @Transient), for the serializer it’s an unknown key. And if you haven’t enabled ignoreUnknownKeys, reading may crash. So @Transient is about excluding from the schema, while tolerance for junk/extra fields is a Json setting.

Mistake #6: storing in the serializable model what should not live in the file.
This is less about compilation and more about common sense. Debug notes, temporary flags, caches, derived fields — all of this is better either computed on the fly or marked @Transient with a safe default. Otherwise you’ll create a “historical archive of junk” for yourself that you’ll later have to support for compatibility.

1
Task
Kotlin SELF, level 48, lesson 1
Locked
Profile from the API
Profile from the API
1
Task
Kotlin SELF, level 48, lesson 1
Locked
Renaming the note
Renaming the note
1
Task
Kotlin SELF, level 48, lesson 1
Locked
Internal note
Internal note
1
Task
Kotlin SELF, level 48, lesson 1
Locked
Extra field in the history
Extra field in the history
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION