You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
What is your use-case and why do you need this feature?
I work on an Android app whose entire UI is driven by a JSON payload from the backend. A single screen response is a few hundred KB, deeply nested, and touches dozens of distinct @Serializable types. Parsing it is one of the more expensive things the app does on a cold start.
Two things I keep wanting and cannot get without a lot of ceremony:
The first is per-type parse cost. I want to know which types actually dominate a decodeFromString call — not the total, which I already have, but the breakdown. If a Component holds a GradientData which holds a List<Color>, I want a timing around each of those, attributed to the type, so I can go and fix the expensive one. Today the only honest way to get that is to hand-write a wrapping serializer for every type I am curious about, which is exactly the set of types I don't know yet.
The second is failure attribution. When one of our own serializers throws, I want the type and the path to land in our metrics pipeline. 1.11.0 making JsonDecodingException public with path and offset on it helps a lot here, and I'm grateful for it, but it only covers exceptions Json itself raises — a NumberFormatException out of a custom serializer still arrives with no context (which I realise is #2165).
Both of these are the same shape of problem: I want to do something uniform to every serializer used in one decode, including generated ones and ones from libraries I don't own, without changing what any of them produce.
The constraint that rules out the obvious answer is that this has to stay on the streaming path. Going through JsonElement first is precisely the cost I am trying to measure, and at our payload sizes it isn't affordable anyway.
What I've tried, and where each one stops
I don't want to claim this is impossible today, because it isn't. It's just expensive in a way I think the library could make cheap.
@KeepGeneratedSerializer with @Serializable(with = ...) — this is the resolution of #793 / #1169 and it does the job, on the streaming path, with no JsonElement. But it's per type. It needs source access to every class and an annotation on each one, and a decent fraction of the types in our payload live in modules we don't own.
SerializersModule.contextual — doesn't override plugin-generated serializers, which is by design and documented. Making it apply means marking every use site @Contextual, or listing types in @file:UseContextualSerialization, so it lands back in the same place: I have to enumerate the types up front.
JsonTransformingSerializer — materialises a JsonElement, so it's out for the reason above.
A format that delegates to Json and substitutes serializers on the way through — this is the approach @pdvrieze sketched in #2699, and it's the one that actually does what I want. Because generated serializers route every nested value through decodeSerializableElement / decodeSerializableValue on whatever decoder they're handed, wrapping the decoder gets you recursion for free and never leaves the streaming path.
What puts me off shipping it is the cost of the wrapper rather than the idea. It means hand-delegating the whole Decoder and CompositeDecoder surface and remembering to re-wrap on beginStructure and decodeInline; it means also implementing JsonDecoder, or every serializer that does decoder as JsonDecoder breaks underneath me; and Decoder isn't guaranteed stable for implementation by user code, so I'd be signing up to re-check that wrapper on every library upgrade for the lifetime of the app.
So what I'm asking for isn't a new capability so much as a supported way to do something the library evidently already permits in principle.
Describe the solution you'd like
A small opt-in hook at the point where a serializer is about to be used — the decodeSerializableValue / decodeSerializableElement boundary — so that interception is recursive by construction and the streaming path is left alone. Something along the lines of:
interfaceSerializerInterceptor {
fun <T> intercept(serializer:KSerializer<T>): KSerializer<T>
}
registered on the format:
val json =Json {
interceptors +=TimingInterceptor()
}
The properties that matter to me: it applies to generated serializers without any change to generated code or to the classes themselves; it applies recursively to properties, collection elements and map keys/values; it composes if more than one is registered; it costs nothing when none is; the wrapped result is cacheable per serializer rather than allocated per value; and it doesn't touch serializer resolution, only what the resolved serializer gets wrapped in.
I've deliberately written that as a sketch rather than a proposal, because where the hook belongs is the part I'd most like your opinion on.
One thing I am explicitly not asking for
An interceptor is an obvious place to catch an exception and substitute a fallback, and I want to be clear that I'm not asking for that. Decoder's own documentation says catching SerializationException from a decode* method is not allowed and leaves the decoder in an arbitrary state, and #2238 was closed on exactly that ground. Recovery needs a separate primitive — some way to skip or restore the current value — and that's a different discussion with different risks. Observation-only interceptors are useful on their own and don't need any exception-safety guarantee, so I'd rather keep the two apart.
Wrapping a type's serializer with another without changing the actual type #2699 is the closest open issue. It's about the ergonomics of substituting the serializer for a specific type, where this is about a substitution point that applies to everything in a decode. If you'd rather keep the discussion in one place I'm happy for this to be folded in there.
Should substitution sit at serializer resolution, on Decoder/CompositeDecoder, or purely as format-level configuration?
Is there appetite for this in core at all? The alternative I'd be just as happy with is a stable, extendable DelegatingDecoder / DelegatingEncoder in the library, so the Wrapping a type's serializer with another without changing the actual type #2699 approach stops depending on API that isn't meant to be implemented downstream. That's a much smaller change and it solves my problem too.
If it does go in core, where should caching of wrapped serializers live?
Happy to implement whichever direction you prefer — I'd just rather agree the shape first than send a PR you'd have to redesign.
What is your use-case and why do you need this feature?
I work on an Android app whose entire UI is driven by a JSON payload from the backend. A single screen response is a few hundred KB, deeply nested, and touches dozens of distinct
@Serializabletypes. Parsing it is one of the more expensive things the app does on a cold start.Two things I keep wanting and cannot get without a lot of ceremony:
The first is per-type parse cost. I want to know which types actually dominate a
decodeFromStringcall — not the total, which I already have, but the breakdown. If aComponentholds aGradientDatawhich holds aList<Color>, I want a timing around each of those, attributed to the type, so I can go and fix the expensive one. Today the only honest way to get that is to hand-write a wrapping serializer for every type I am curious about, which is exactly the set of types I don't know yet.The second is failure attribution. When one of our own serializers throws, I want the type and the path to land in our metrics pipeline. 1.11.0 making
JsonDecodingExceptionpublic withpathandoffseton it helps a lot here, and I'm grateful for it, but it only covers exceptions Json itself raises — aNumberFormatExceptionout of a custom serializer still arrives with no context (which I realise is #2165).Both of these are the same shape of problem: I want to do something uniform to every serializer used in one decode, including generated ones and ones from libraries I don't own, without changing what any of them produce.
The constraint that rules out the obvious answer is that this has to stay on the streaming path. Going through
JsonElementfirst is precisely the cost I am trying to measure, and at our payload sizes it isn't affordable anyway.What I've tried, and where each one stops
I don't want to claim this is impossible today, because it isn't. It's just expensive in a way I think the library could make cheap.
@KeepGeneratedSerializerwith@Serializable(with = ...)— this is the resolution of #793 / #1169 and it does the job, on the streaming path, with noJsonElement. But it's per type. It needs source access to every class and an annotation on each one, and a decent fraction of the types in our payload live in modules we don't own.SerializersModule.contextual— doesn't override plugin-generated serializers, which is by design and documented. Making it apply means marking every use site@Contextual, or listing types in@file:UseContextualSerialization, so it lands back in the same place: I have to enumerate the types up front.JsonTransformingSerializer— materialises aJsonElement, so it's out for the reason above.A format that delegates to
Jsonand substitutes serializers on the way through — this is the approach @pdvrieze sketched in #2699, and it's the one that actually does what I want. Because generated serializers route every nested value throughdecodeSerializableElement/decodeSerializableValueon whatever decoder they're handed, wrapping the decoder gets you recursion for free and never leaves the streaming path.What puts me off shipping it is the cost of the wrapper rather than the idea. It means hand-delegating the whole
DecoderandCompositeDecodersurface and remembering to re-wrap onbeginStructureanddecodeInline; it means also implementingJsonDecoder, or every serializer that doesdecoder as JsonDecoderbreaks underneath me; andDecoderisn't guaranteed stable for implementation by user code, so I'd be signing up to re-check that wrapper on every library upgrade for the lifetime of the app.So what I'm asking for isn't a new capability so much as a supported way to do something the library evidently already permits in principle.
Describe the solution you'd like
A small opt-in hook at the point where a serializer is about to be used — the
decodeSerializableValue/decodeSerializableElementboundary — so that interception is recursive by construction and the streaming path is left alone. Something along the lines of:registered on the format:
The properties that matter to me: it applies to generated serializers without any change to generated code or to the classes themselves; it applies recursively to properties, collection elements and map keys/values; it composes if more than one is registered; it costs nothing when none is; the wrapped result is cacheable per serializer rather than allocated per value; and it doesn't touch serializer resolution, only what the resolved serializer gets wrapped in.
I've deliberately written that as a sketch rather than a proposal, because where the hook belongs is the part I'd most like your opinion on.
One thing I am explicitly not asking for
An interceptor is an obvious place to catch an exception and substitute a fallback, and I want to be clear that I'm not asking for that.
Decoder's own documentation says catchingSerializationExceptionfrom adecode*method is not allowed and leaves the decoder in an arbitrary state, and #2238 was closed on exactly that ground. Recovery needs a separate primitive — some way to skip or restore the current value — and that's a different discussion with different risks. Observation-only interceptors are useful on their own and don't need any exception-safety guarantee, so I'd rather keep the two apart.Related issues
@KeepGeneratedSerializer. That covers per-type wrapping; this is about wrapping scoped to an operation, applied recursively, without per-type opt-in.Questions before I write anything
Decoder/CompositeDecoder, or purely as format-level configuration?DelegatingDecoder/DelegatingEncoderin the library, so the Wrapping a type's serializer with another without changing the actual type #2699 approach stops depending on API that isn't meant to be implemented downstream. That's a much smaller change and it solves my problem too.Happy to implement whichever direction you prefer — I'd just rather agree the shape first than send a PR you'd have to redesign.