Explain exactly how a @Contextual property is resolved at encode time, and what failure modes and edge cases (nullability, generics, missing registration) you should anticipate.
answer
- ContextualSerializer -> module.getContextual(kClass) -> delegate or throw
- Nullable: register non-null serializer, framework wraps with .nullable
- Generics: contextual(Class::class) { args -> ... } provider overload
- Module + module conflict throws; overwriteWith to override
- Default EmptySerializersModule => forgotten wiring fails at runtime
basics
~10 sAt encode time the ContextualSerializer asks the format's module for a serializer matching the property's class. If none is found it throws. Nullable types and generic types need extra care so the lookup matches.
solid answer
~50 sThe plugin emits a ContextualSerializer<T> for each @Contextual slot. When encoding runs, that serializer calls the active SerializersModule's getContextual(kClass) (effectively) and delegates to the returned KSerializer; if the result is null it throws SerializationException naming the class. Edge cases: (1) Nullability — @Contextual val d: Date? wraps the contextual serializer in a nullable serializer, so you register the non-null Date serializer and the framework handles null. (2) Generics — for Box<T> you must register via the provider overload contextual(Box::class) { args -> ... } because the lookup needs to build a serializer from the type-argument serializers. (3) Missing registration — a runtime SerializationException, often only hit on a rare code path, so test coverage matters. (4) Module composition — combining modules with overlapping contextual registrations for the same class throws on plus/overwrite unless you use overwriteWith. (5) Encode/decode must share the module, or decoding fails. Default formats from kotlinx have an EmptySerializersModule, so forgetting to set serializersModule is a common cause.
code
kotlin · 7 linesval module = SerializersModule {
contextual(DateAsLongSerializer) // non-null; covers Date?
contextual(Box::class) { args -> BoxSerializer(args[0]) } // generic
}
val base = SerializersModule { contextual(OtherDateSerializer) }
val merged = base.overwriteWith(module) // module wins on conflicts
val json = Json { serializersModule = merged }go deeper
Knows a missing registration throws at runtime but not the nullable/generic/composition nuances.
Explains the ContextualSerializer-to-module delegation and handles nullable correctly.
Covers generics via provider overload, plus/overwriteWith conflict semantics, and encode/decode module symmetry.
Designs robust module composition for large codebases, enforces test coverage of contextual paths, and reasons about late-binding risk in shared serialization infrastructure.
## The resolution path 1. The compiler plugin sees `@Contextual val ts: Date` and, in the generated serializer for the enclosing class, uses a **ContextualSerializer** for that element instead of a concrete one. 2. At encode time, the format (e.g. `Json`) carries a `serializersModule`. The ContextualSerializer queries it for `Date::class`. 3. If found, it delegates `serialize`/`deserialize` to that `KSerializer<Date>`. If not found, it throws `SerializationException: Serializer for class 'Date' is not found. Please register it in the SerializersModule.` Resolution is therefore **dynamic and per-call**, governed entirely by the module attached to the format instance. ## Edge case: nullability ```kotlin @Serializable data class E(@Contextual val ts: Date?) ``` You still register the **non-null** `KSerializer<Date>`. The framework composes a nullable wrapper (`.nullable`) around the contextual lookup, so `null` is handled by the format and a present value goes through your serializer. Registering a serializer for `Date?` is not what you do. ## Edge case: generic / parameterized types A contextual type with type parameters can't be served by the simple `contextual(serializer)` overload because the concrete serializer depends on the type arguments. Use the provider overload: ```kotlin val module = SerializersModule { contextual(Box::class) { typeArgSerializers -> BoxSerializer(typeArgSerializers[0]) } } ``` The lambda receives the already-resolved serializers for `T`, letting you assemble the right `KSerializer<Box<T>>` on demand. ## Edge case: missing registration Because the failure is a **runtime** `SerializationException`, it may only surface on a code path that serializes that type rarely. Mitigations: write tests that exercise encode/decode of every contextual type, and consider centralizing module construction so registration isn't forgotten. ## Edge case: module composition conflicts Combining modules: ```kotlin val combined = moduleA + moduleB ``` If both register a contextual serializer for the **same** class, `plus` throws an `IllegalArgumentException` about a conflicting registration. To intentionally override, use `overwriteWith`: ```kotlin val combined = moduleA.overwriteWith(moduleB) ``` ## Edge case: empty default module Formats default to `EmptySerializersModule()`. If you create `Json` without setting `serializersModule`, every `@Contextual` lookup fails. Always wire the module into the format you actually use. ## Encode/decode symmetry The decoding side must use a format with the same registration, and the wire shape implied by the serializer's `descriptor` must match what was written. Asymmetric modules are a classic cause of 'works on encode, fails on decode'. ## Mental model Think of `@Contextual` as a late-bound virtual call: the call site is fixed at compile time, but the target (`KSerializer`) is looked up in a runtime registry (`SerializersModule`) keyed by `KClass`.
- Why does moduleA + moduleB sometimes throw, and how do you intentionally override?plus throws when both register a serializer for the same class (conflicting contextual registration). Use moduleA.overwriteWith(moduleB) to let the right-hand module win instead of conflicting.
- For @Contextual val d: Date?, what serializer do you register?The non-null KSerializer<Date>. The framework wraps the contextual lookup with nullable handling, so null is encoded/decoded automatically.
- Encoding works but decoding throws 'serializer not found'. Likely cause?The decode-side format uses a different (or empty) SerializersModule without the contextual registration. Encode and decode must share the module.
saying these in an interview costs you the question
- Tries to register a serializer for Date? instead of Date
- Uses contextual(serializer) for a generic type and is confused why type args aren't handled
- Assumes module + module always merges silently
- Forgets default formats use EmptySerializersModule
- Treats the missing-serializer failure as compile-time