skip to content

When decoding untrusted input with kotlinx.serialization, what exceptions can be thrown and how should you handle them at a trust boundary?

level: middleimportance: must knowfreq 50%

answer

  1. SerializationException = base; MissingFieldException = required field absent
  2. init {}/require → IllegalArgumentException out of decode
  3. Catch both; never broad Exception/Throwable
  4. Don't echo e.message (leaks field names/positions)
  5. Size/depth limits at boundary for DoS

basics

~10 s

Bad or malformed input makes decodeFromString throw. Catch SerializationException (and IllegalArgumentException from your own checks), turn it into a safe error response, and never leak the raw message or stack trace to the caller.

solid answer

~40 s

`Json.decodeFromString` throws **`SerializationException`** (the base type) for parsing/format problems; a notable subtype is **`MissingFieldException`** when a required field without a default is absent. Your own `require`/`init {}` invariant failures throw **`IllegalArgumentException`** during construction, which propagates out of decode. At an untrusted boundary, wrap the call (`try/catch` or `runCatching`) and catch **both** `SerializationException` and `IllegalArgumentException`, mapping to a controlled 400-style error. Do not catch broad `Exception`/`Throwable`, and do not echo `e.message` or stack traces to clients — those can leak field names, internal structure, or library internals. Also bound resource use: enforce max request size and avoid decoding unbounded streams, since hostile deeply-nested or huge payloads are a DoS vector the parser alone won't stop. Log details server-side at the right level, return a generic message to the caller.

go deeper

for a junior

Knows decodeFromString can throw and that you should try/catch and not crash; may name only SerializationException.

for a middle

Names SerializationException + MissingFieldException + IllegalArgumentException, catches the right ones, avoids leaking messages.

for a senior

Adds boundary mapping to safe errors, runCatching/cancellation nuance, and DoS size/depth limits as a separate concern.

for a principal

Standardizes error contracts and logging policy across services, balances observability vs information leakage, and owns availability guards.

## The exceptions you will see - **`SerializationException`** — the base exception (package `kotlinx.serialization`) for all encoding/decoding failures: malformed JSON, type mismatch (string where a number is expected), unknown discriminator, unregistered polymorphic type. *Catching this one covers the family.* - **`MissingFieldException`** — a `SerializationException` subtype thrown when a **required** property (no default value, not nullable) is absent from the input. - **`IllegalArgumentException`** — not from the parser but from **your** `require(...)` in `init {}` (invariant validation) running during construction. It propagates out of `decodeFromString` just like a parse error. - (`ignoreUnknownKeys = false`, the default, also surfaces unknown *properties* as `SerializationException`.) ## Handling at the boundary ```kotlin import kotlinx.serialization.SerializationException import kotlinx.serialization.json.Json sealed interface ParseResult<out T> { data class Ok<T>(val value: T) : ParseResult<T> data object BadRequest : ParseResult<Nothing> } inline fun <reified T> parseUntrusted(json: String): ParseResult<T> = try { ParseResult.Ok(Json.decodeFromString<T>(json)) } catch (e: SerializationException) { logger.warn("decode failed", e) // detail stays server-side ParseResult.BadRequest } catch (e: IllegalArgumentException) { logger.warn("invariant failed", e) ParseResult.BadRequest } ``` Using `runCatching { Json.decodeFromString<T>(json) }` is also fine, but be careful: `runCatching` catches *all* `Throwable` including `CancellationException` in coroutines — prefer explicit `try/catch` of the two relevant types in suspend code, or re-throw `CancellationException`. ## Why not catch broad / why not echo messages - Catching `Exception`/`Throwable` can swallow programming errors (NPEs, `OutOfMemoryError`) and `CancellationException`, masking real bugs. - `e.message` from kotlinx.serialization can include **field names, positions, expected types, and discriminator values** — useful internally, but leaking them to an attacker aids reconnaissance. Return a generic, stable error code/message; log specifics server-side. ## Resource / DoS considerations The parser will faithfully attempt to decode whatever you hand it. Hostile inputs that are *huge* or *deeply nested* can exhaust memory/stack. Mitigations live at the boundary, not in the serializer: enforce **max content length**, reject oversized bodies before decoding, and avoid `decodeFromStream` on unbounded sources. This is the same class of guard as validating values — the codegen safety property doesn't cover availability. ## Summary Catch `SerializationException` (covers `MissingFieldException`) and `IllegalArgumentException`, map to a safe client error, keep details in server logs, and add size/depth limits for DoS.

  • Which exception signals a required field is missing, and how do you make it optional safely?
    MissingFieldException (a SerializationException). Give the property a default value or make it nullable; with explicitNulls/coerceInputValues you control absent-vs-null behavior.
  • Why prefer explicit try/catch over runCatching in a suspend function?
    runCatching catches all Throwable including CancellationException, which would swallow coroutine cancellation; explicit catches let cancellation propagate.

saying these in an interview costs you the question

  • Catching Exception/Throwable and swallowing everything
  • Returning e.message or stack trace to the client
  • Forgetting init {} invariant failures throw IllegalArgumentException, not SerializationException
  • Assuming the serializer protects against huge/deeply-nested DoS payloads
  • Using runCatching in coroutines without re-throwing CancellationException

context