When evolving a schema, when should you model an optional field as a nullable type with `= null` versus a non-null type with a concrete default? How do they differ on encode and decode?
answer
- Optional iff it has a default, not iff nullable
- Bare T? (no = null) is still required -> throws
- Concrete default hides 'omitted vs equals default'
- T? = null preserves 'absent/unknown'
- Absent vs explicit null collapse to null (tri-state needs wrapper)
basics
~10 sUse a concrete default when there's a sensible fallback value. Use nullable = null when you need to tell 'not provided' apart from any real value. Both let old payloads decode.
solid answer
~50 sBoth `val x: T = someDefault` and `val x: T? = null` make a key optional on decode (a missing key is fine). The difference is semantic and observable in code: a concrete default substitutes a real value (e.g. `role = "USER"`), so you can't later tell whether the sender meant 'USER' or simply omitted the key. A nullable-with-null default preserves the distinction: `null` means 'absent/unknown'. On encoding, both are omitted when equal to their default under `encodeDefaults = false`; `null` is written as JSON `null` only if `encodeDefaults = true` or `@EncodeDefault(ALWAYS)`. Crucial trap: a nullable type **without** a default (`val x: T?`) is still a *required* key and throws on a missing key — nullability and optionality are independent. For tri-state semantics (present-value / present-null / absent) you may need a wrapper or custom serializer, since plain `T? = null` collapses 'absent' and 'explicit null' to the same result.
code
kotlin · 8 lines@Serializable
data class Patch(
val name: String? = null, // null = 'don't change name'
val active: Boolean = true // concrete fallback
)
Json.decodeFromString<Patch>("{}") // Patch(null, true)
Json.decodeFromString<Patch>("""{"name":"Ada"}""") // Patch("Ada", true)go deeper
Knows both nullable-with-null and concrete defaults let old payloads decode.
Separates nullability from optionality and spots the bare-T? required-key trap.
Chooses per field based on whether 'unset' must be observable and explains encode/decode of null.
Recognizes the tri-state limitation, designs patch/merge contracts, and decides when a wrapper or custom serializer is warranted.
## Two orthogonal axes kotlinx.serialization has two independent concepts people conflate: - **Nullability** (`T` vs `T?`) — can the value be `null`? - **Optionality** (has a default vs not) — is the key allowed to be absent on decode? A key is optional **iff the property has a default**, regardless of nullability: ```kotlin @Serializable data class A(val x: String) // required @Serializable data class B(val x: String = "") // optional, non-null @Serializable data class C(val x: String?) // REQUIRED (no default!) -> missing key throws @Serializable data class D(val x: String? = null) // optional, nullable ``` `C` surprises people: making it nullable does **not** make the key optional. Only the `= null` in `D` does. ## Choosing for evolution - **Concrete default** (`role: String = "USER"`): pick when there is a meaningful fallback and you never need to distinguish 'omitted' from 'equals the default'. Simpler, no null handling. - **Nullable + null default** (`role: String? = null`): pick when 'not provided' must be distinguishable from any concrete value — e.g. optional override fields, patch/merge semantics, or auditing what the sender actually set. ## Encode behavior Under the default `encodeDefaults = false`: - `B` at `""` → key omitted. - `D` at `null` → key omitted (because `null` is its default). If you want the `null` to actually appear (`{"x":null}`), set `encodeDefaults = true` or annotate `@EncodeDefault(EncodeDefault.Mode.ALWAYS)`. ## Decode behavior - Missing key + has default → default used (no exception) for both styles. - Explicit JSON `null` → only valid if the type is nullable; decoding `null` into a non-null `String` throws. ## The tri-state problem Plain `T? = null` cannot distinguish **absent key** from **explicit `"x": null`** — both yield `null`. If your protocol (e.g. JSON Merge Patch) needs all three states, you need a sentinel wrapper or a custom serializer; the built-in mechanism collapses two of them. ## Rule of thumb Default for 'sensible fallback'; nullable+null for 'I must know it was unset'. Never rely on bare `T?` to make a key optional — always add `= null`.
- Does `val x: String?` (no default) allow a missing key?No. Without a default it is a required key; a missing key throws MissingFieldException.
- Can plain `T? = null` distinguish an absent key from an explicit JSON null?No, both decode to null. Distinguishing them needs a wrapper type or custom serializer.
A concrete default is a pre-filled answer; nullable-null is leaving the box deliberately blank so you know it was never filled in.
saying these in an interview costs you the question
- Believing nullable type alone makes a key optional
- Saying absent and explicit-null are distinguishable with plain T? = null
- Decoding JSON null into a non-null property and expecting it to work
- Always reaching for nullable when a concrete default is clearer
- Forgetting null is omitted on encode unless encodeDefaults/ALWAYS