Serialization

kotlinx.serialization: @Serializable, encode/decode, custom names, default values, polymorphism, and lenient parsing.

@Serializable Json.encodeToString @SerialName Defaults Polymorphism Lenient parsing
๐Ÿ”Œ

@Serializable Basics

kotlinx.serialization is the official library for converting Kotlin objects to and from formats like JSON. Its defining design choice, and the reason it needs its own compiler plugin: annotating a class @Serializable generates a real serializer at compile time, rather than inspecting the class through reflection at runtime the way Jackson or Gson do. That means serialization works on Kotlin/JS and Kotlin/Native (where JVM reflection doesn't exist at all), and mistakes โ€” a field type serialization can't handle โ€” surface as compile errors instead of runtime surprises.

Making a data class serializable @Serializable
import kotlinx.serialization.Serializable

@Serializable
data class User(
    val name: String,
    val age: Int,
    val email: String?
)
// The compiler plugin generates User.serializer() at compile time โ€”
// no runtime class inspection needed to encode or decode this type
โ„น๏ธ
Every property in a @Serializable class must itself be a serializable type โ€” a primitive, String, another @Serializable class, or a standard collection of those. A property whose type kotlinx.serialization doesn't know how to handle is a compile error, not something you discover the first time you try to encode an instance.
๐Ÿ”„

Json.encodeToString & decodeFromString

The Json format object is the entry point for converting between a Kotlin object and its JSON text representation. Both functions are generic and reified (see Generics) โ€” the type parameter is inferred from context, so there's no separate Class<T> token to pass in the way older Java JSON libraries required.

Round-tripping an object through JSON encodeToString / decodeFromString
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.decodeFromString

@Serializable
data class User(val name: String, val age: Int)

val user = User("Ada", 36)

// encodeToString: T inferred from the argument
val json = Json.encodeToString(user)
println(json)  // {"name":"Ada","age":36}

// decodeFromString: T inferred from the target type
val decoded: User = Json.decodeFromString(json)
println(decoded)  // User(name=Ada, age=36)

// Lists and nested objects work the same way, no extra ceremony
val users = listOf(User("Ada", 36), User("Grace", 85))
val usersJson = Json.encodeToString(users)
println(usersJson)  // [{"name":"Ada","age":36},{"name":"Grace","age":85}]
๐Ÿท

Custom Names: @SerialName

External APIs frequently use JSON key names that aren't valid or idiomatic Kotlin identifiers โ€” snake_case fields, reserved words, or names you simply want to present differently in your own model. @SerialName decouples the Kotlin property name from the JSON key it maps to, so your Kotlin code stays idiomatic while still matching the wire format exactly.

Mapping snake_case JSON to camelCase Kotlin @SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerialName

@Serializable
data class ApiUser(
    val name: String,
    @SerialName("created_at")
    val createdAt: String,
    @SerialName("is_active")
    val isActive: Boolean
)

val json = """{"name":"Ada","created_at":"2024-01-01","is_active":true}"""
val user = Json.decodeFromString<ApiUser>(json)
println(user.createdAt)  // "2024-01-01" โ€” read from the "created_at" JSON key
๐ŸŽ›

Default Values

An ordinary Kotlin default parameter value (see Basics) also governs decoding: if a JSON payload is missing a field entirely, and the corresponding property has a default, decoding succeeds using that default instead of failing. This is what lets a data model evolve โ€” adding a new optional field โ€” without every previously-stored payload suddenly becoming unparseable.

Tolerating missing fields via defaults Defaults
@Serializable
data class Settings(
    val theme: String = "light",
    val notificationsEnabled: Boolean = true
)

// Old payload, predating the notificationsEnabled field entirely
val oldJson = """{"theme":"dark"}"""
val settings = Json.decodeFromString<Settings>(oldJson)
println(settings)  // Settings(theme=dark, notificationsEnabled=true) โ€” default filled in

// Without a default, a missing field is a decoding ERROR, not null:
// SerializationException: Field 'notificationsEnabled' is required
๐Ÿ’ก
Give every field you expect might be added later a sensible default from day one. It costs nothing when the field is always present, and it's what makes a schema change backward-compatible with data serialized before the change shipped.
๐ŸŽญ

Polymorphism

Serializing a value through an interface or abstract base type needs a way to record, in the JSON itself, which concrete subtype it actually was โ€” otherwise decoding wouldn't know what to reconstruct. kotlinx.serialization handles this automatically for a sealed hierarchy (see Classes & Objects): because every subtype is known at compile time, the plugin can generate a type discriminator without any extra annotations.

A sealed hierarchy, serialized with a type tag sealed + polymorphic
@Serializable
sealed class Shape

@Serializable
@SerialName("circle")
data class Circle(val radius: Double) : Shape()

@Serializable
@SerialName("rectangle")
data class Rectangle(val width: Double, val height: Double) : Shape()

val shapes: List<Shape> = listOf(Circle(2.0), Rectangle(3.0, 4.0))
val json = Json.encodeToString(shapes)
println(json)
// [{"type":"circle","radius":2.0},{"type":"rectangle","width":3.0,"height":4.0}]

// Decoding reconstructs the correct concrete subtype using that "type" key
val decoded: List<Shape> = Json.decodeFromString(json)
println(decoded.first() is Circle)  // true
โ„น๏ธ
The "type" discriminator key defaults to the class's fully-qualified name unless @SerialName overrides it โ€” always set it explicitly on each subtype in a serialized hierarchy, since the fully-qualified default breaks the moment you rename or move the class.
๐Ÿ•Š

Lenient Parsing

The default Json instance is strict: unrecognized keys throw, malformed literals throw. A custom-configured Json instance can relax specific rules โ€” ignoring keys your model doesn't declare, coercing malformed values instead of failing, or accepting non-standard JSON โ€” which matters when parsing real-world APIs that don't always produce textbook-clean JSON.

A configured Json instance Json { }
import kotlinx.serialization.json.Json

val lenientJson = Json {
    ignoreUnknownKeys = true   // don't fail on JSON fields your model doesn't declare
    isLenient = true           // accept minor deviations from strict JSON syntax
    coerceInputValues = true   // use a property's default instead of failing on a null/wrong-type value
}

@Serializable
data class User(val name: String)

// "age" isn't declared on User at all โ€” default Json would throw,
// lenientJson silently ignores it instead
val json = """{"name":"Ada","age":36,"extra":"field"}"""
val user = lenientJson.decodeFromString<User>(json)
println(user)  // User(name=Ada)
โš ๏ธ
Reach for a lenient configuration deliberately, for a specific external API known to send extra or occasionally malformed fields โ€” not as a blanket default across a codebase. The strict defaults exist because silently ignoring unexpected data is exactly how a real schema mismatch (a renamed field, a breaking API change) goes unnoticed until it causes a harder-to-diagnose bug somewhere downstream.
๐Ÿ“‹

Quick Reference

Concept Syntax Notes
Mark a class serializable@Serializable data class X(...)Generates a serializer at compile time
Object โ†’ JSONJson.encodeToString(obj)Type inferred via reified generics
JSON โ†’ objectJson.decodeFromString<T>(json)No Class<T> token needed
Custom JSON key@SerialName("json_key")Decouples Kotlin property name from wire format
Tolerate a missing fieldval x: T = defaultOrdinary Kotlin default; used when the JSON omits the field
Polymorphic hierarchy@Serializable sealed class BaseAutomatic type discriminator for known subtypes
Subtype's discriminator value@SerialName("circle") on the subtypeAlways set explicitly; default is the FQN
Ignore unrecognized fieldsJson { ignoreUnknownKeys = true }Default Json throws on unknown keys
Accept minor syntax deviationsJson { isLenient = true }For real-world, not-quite-strict JSON
Fall back to defaults on bad valuesJson { coerceInputValues = true }Use sparingly; can mask real schema issues