Skip to content

Added Unknown keys feature - #3189

Open
inemtsev wants to merge 12 commits into
Kotlin:devfrom
inemtsev:unknown-keys
Open

Added Unknown keys feature#3189
inemtsev wants to merge 12 commits into
Kotlin:devfrom
inemtsev:unknown-keys

Conversation

@inemtsev

@inemtsev inemtsev commented Apr 30, 2026

Copy link
Copy Markdown

Details

This change introduces a JSON-only, runtime-level feature for collecting additional properties into a dedicated bucket property annotated with @JsonExtraKeys.

Supported behavior:

  • unknown keys are captured into Map<String, V> where V is any @Serializable type or a contextually serializable type like JsonElement, primitives, enums, classes, and polymorphic / sealed hierarchies are supported
  • captured entries are emitted back as top-level sibling fields on encode
  • capture takes precedence over ignoreUnknownKeys / @JsonIgnoreUnknownKeys
  • polymorphic discriminators are excluded from capture
  • a JsonEncodingException is thrown on encode if the bucket map
    contains a key that would collide with either a declared property's
    JSON name (including @JsonNames aliases and naming-strategy translations)
    or the active class discriminator

Validation rules:

  • only one @JsonExtraKeys property per class
  • property type must be Map<String, V> with the standard kotlinx.serialization Map serializer; the key serializer must be the standard String serializer (inline value classes wrapping String are rejected because the encode path casts each entry key to String)
  • @JsonNames is rejected on the bucket property

Implementation notes:

  • encode side introduces two small wrappers (JsonExtraKeysSpreadingEncoder for streaming, JsonExtraKeysSpreadingTreeEncoder for tree) that drive the user's MapSerializer through the alternating-index encodeSerializableElement protocol; values are routed through the parent encoder's encodeSerializableValue so polymorphic-discriminator setup and the JsonElement short-circuit both apply
  • non-standard hand-rolled KSerializer<Map<String, V>> that doesn't follow the alternating key/value index protocol is unsupported by design since the wrapper extends AbstractEncoder, whose default encodeValue throws SerializationException on any unexpected primitive call

Ticket: #1978

@hrach

hrach commented Apr 30, 2026

Copy link
Copy Markdown

Just a few days ago, I was thinking that I'd utilize this feature.

My use case: parsing open-api specs. Additional props may be added to the object and my generator's plugin system may want to read that (those x- prefixed props):

      "UnifiedApiResponseAdditionsReturnedTypeResponseDto" : {
        "properties" : {
          "error" : {
            "type" : "string"
          },
          "flashes" : {
            "items" : {
              "$ref" : "#/components/schemas/FlashMessage"
            },
            "type" : "array"
          },
          "result" : {
            "$ref" : "#/components/schemas/AdditionsReturnedTypeResponseDto"
          },
          "traceId" : {
            "type" : "string"
          }
        },
        "required" : [ "flashes" ],
        "type" : "object",
        "x-unified-response" : true,
        "x-unified-response-result-required" : true
      }

@inemtsev

Copy link
Copy Markdown
Author

Just a few days ago, I was thinking that I'd utilize this feature.

My use case: parsing open-api specs. Additional props may be added to the object and my generator's plugin system may want to read that (those x- prefixed props):

      "UnifiedApiResponseAdditionsReturnedTypeResponseDto" : {
        "properties" : {
          "error" : {
            "type" : "string"
          },
          "flashes" : {
            "items" : {
              "$ref" : "#/components/schemas/FlashMessage"
            },
            "type" : "array"
          },
          "result" : {
            "$ref" : "#/components/schemas/AdditionsReturnedTypeResponseDto"
          },
          "traceId" : {
            "type" : "string"
          }
        },
        "required" : [ "flashes" ],
        "type" : "object",
        "x-unified-response" : true,
        "x-unified-response-result-required" : true
      }

Yes, I think this feature would be useful in your case :)

@inemtsev

Copy link
Copy Markdown
Author

@sandwwraith @pdvrieze Please take a look

@pdvrieze pdvrieze left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've had a look at it. The implementation is correct, but it adds a degree of complexity. There are some bits where this complexity can be reduced.

A key consideration is that the collection serializers actually support incremental parsing (updating). This would allow you to just handle/parse unknown keys incrementally (rather than having to collect them first). At that point you can just return the index of the "other attributes" element, and in the relevant (map) decode function handle parsing it.

The one gotcha is handling the class discriminator that you need to exclude from returning the index.

As optimization, the schemaCache already has code to create a map from name to index. As part of initialization it loops through all elements and annotations to determine the effective name(s) - supporting @JsonNames. It would certainly be more efficient to check for the @JsonExtraKeys element at the same time. (I know that this is a bit against the idea of SchemaCache, but the design is more extensible than it needs to be for an internal type (keep in mind multi-threading issues though - don't make the value mutable))

Comment on lines +209 to +231
for (i in 0 until elementsCount) {
if (getElementAnnotations(i).any { it is JsonExtraKeys }) {
if (foundIndex == -1) {
foundIndex = i
} else {
val list = duplicates
if (list == null) {
duplicates = mutableListOf(getElementName(foundIndex), getElementName(i))
} else {
list.add(getElementName(i))
}
}
}
}
duplicates?.let {
throw SerializationException(
"Class '$serialName' has more than one property annotated with @JsonExtraKeys: " +
it.joinToString(", ") { name -> "'$name'" } +
". At most one such property is allowed per class."
)
}
if (foundIndex == -1) return -1
validateJsonExtraKeysProperty(foundIndex)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some of this code is doing more than is needed on the "happy path". I am wondering if a configuration option to disable expensive validation would be worthwhile (so it for example doesn't check for duplicates, and thus may start from the last index instead of the first).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validateJsonExtraKeysProperty() run exactly once per descriptor per Json instance (not once per encode/decode or anything like that), so I don't think this is worth trying to optimize.

Comment thread formats/json/commonMain/src/kotlinx/serialization/json/internal/JsonNamesMap.kt Outdated
Comment thread formats/json/commonMain/src/kotlinx/serialization/json/internal/JsonNamesMap.kt Outdated
@inemtsev

inemtsev commented May 4, 2026

Copy link
Copy Markdown
Author

@pdvrieze @sandwwraith I've made some significant changes as per suggestions, please take a look when you have a moment

@inemtsev
inemtsev changed the base branch from master to dev May 4, 2026 08:05
@Flavien

Flavien commented May 9, 2026

Copy link
Copy Markdown

Thanks for this PR, IMO this is a must-have feature of any JSON library as this is the only way to ensure safe round-tripping.

@inemtsev

Copy link
Copy Markdown
Author

@sandwwraith @fzhinkin Do you think we can try to get this into the next release (maybe under experimental?) This would be very handy for some of our migrations.

@sandwwraith sandwwraith left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've looked through the implementation and have some general comments:

  1. Since this feature involves some logic on the hot parser path, it would be wise to put it under the feature flag, as it was done with useAlternativeNames.
  2. You need to have benchmarks with both states of this flag to verify that it doesn't slow down things a lot when enabled by default. Take a look at TwitterFeedBenchmark or CoerceInputValuesBenchmark for examples
  3. If this flag would be implemented, there would be no need to care for weird try-catches around broken descriptors in SerialDescriptor.jsonExtraKeysIndex
  4. Why Map<String, T> was chose instead of a simple JsonObject? It seems highly inlikely to me that someone would not know what keys are there, but would know their shape. Using JsonObject for the unknown keys is the most straightforward way which also would likely simplify implementation.
  5. Writing extra keys back. I understand this is a nice feature to have, but I'm not sure it is mandatory. It makes a lot of complications in encoding to flatten the keys. Again, if unknown keys holder is strictly a JsonObject, we could simply dump its .toString() onto the current stream and go on our way. Please investigate this approach, otherwise I doubt the benefits of having this functionality.

@inemtsev

inemtsev commented Jul 2, 2026

Copy link
Copy Markdown
Author

I've looked through the implementation and have some general comments:

  1. Since this feature involves some logic on the hot parser path, it would be wise to put it under the feature flag, as it was done with useAlternativeNames.
  2. You need to have benchmarks with both states of this flag to verify that it doesn't slow down things a lot when enabled by default. Take a look at TwitterFeedBenchmark or CoerceInputValuesBenchmark for examples
  3. If this flag would be implemented, there would be no need to care for weird try-catches around broken descriptors in SerialDescriptor.jsonExtraKeysIndex
  4. Why Map<String, T> was chose instead of a simple JsonObject? It seems highly inlikely to me that someone would not know what keys are there, but would know their shape. Using JsonObject for the unknown keys is the most straightforward way which also would likely simplify implementation.
  5. Writing extra keys back. I understand this is a nice feature to have, but I'm not sure it is mandatory. It makes a lot of complications in encoding to flatten the keys. Again, if unknown keys holder is strictly a JsonObject, we could simply dump its .toString() onto the current stream and go on our way. Please investigate this approach, otherwise I doubt the benefits of having this functionality.

Fair points. I liked the encoding ability because if the user decided to serialize the deserialized object again (for logging or something) then the "extras" are spread back into JSON. For now, I will skip encoding this and document.

@pdvrieze

pdvrieze commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

5. Writing extra keys back. I understand this is a nice feature to have, but I'm not sure it is mandatory. It makes a lot of complications in encoding to flatten the keys. Again, if unknown keys holder is strictly a JsonObject, we could simply dump its .toString() onto the current stream and go on our way. Please investigate this approach, otherwise I doubt the benefits of having this functionality

Comparing this with XML (where the feature has been available for quite some time now) I would say that the ability to write back the additional keys would be relevant. In many ways, the extra entries would be opaque, so JsonObject would be a better representation although Map<String, JsonElement> would be valid too.

The biggest question is what to do when the unknownKeys map is updated/manipulated such that it contains overlapping keys. This would be an invalid state of the to-be-serialized object, but there is still the decision of how to handle it:

  • Make it undefined behaviour (the format ignores the possibility, so for encoding to string would just have duplicate keys, for tree serialization the last value sticks)
  • Ignore all but the first values (if unknown keys are serialized first, they "win")
  • First write all "normal" keys and ignore all duplicate keys in the unknown keys
  • The above too, but keep last, not first
  • Throw exceptions on duplicate keys (either first or collecting all)

The undefined behaviour option is cheapest (it would be roughly dumping toString() to the end of the object), and duplicate keys are technically allowed in Json (although it is also a security breach waiting to happen).

The other options do require either keeping track of written keys or looking up the key on the descriptor. There I would be inclined to go for the exception option as the others are surprising, especially if it ends up depending on the order of the elements in the structure (I would go to serialize the unknown keys after all "regular" keys). This can be implemented by recording the unknown keys element if it occurs (and is not empty), and on the endStructure writing the values (if needed).

@Flavien

Flavien commented Jul 2, 2026

Copy link
Copy Markdown

Yes I agree the ability to write back the unknown keys is necessary for this feature to be useful. And I agree it makes more sense to store the unrecognised keys in a JsonObject.

@inemtsev

inemtsev commented Jul 2, 2026

Copy link
Copy Markdown
Author

Yes I agree the ability to write back the unknown keys is necessary for this feature to be useful. And I agree it makes more sense to store the unrecognised keys in a JsonObject.

Upon more thought, this does strike a good middle-ground. Will update PR shortly.
@pdvrieze For when unknownKeys map is updated/manipulated, I will just throw for now. This is a very unlikely case anyway.

@pdvrieze

pdvrieze commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

@inemtsev Exceptions on duplicate keys is a good solution. The main reason to avoid it is if you want to avoid the check for a duplicate, but given the potential for duplicate keys to cause security challenges (inconsistent values used with different checks/systems) it is probably worthwhile to check for duplicates (and throw).

@inemtsev

inemtsev commented Jul 3, 2026

Copy link
Copy Markdown
Author
  • I added a flag useExtraKeys and changed unknown keys to bucket into JsonObject.
  • I tried removing the try-catch once the flag existed, but 37 existing tests fail, because unlike useAlternativeNames (which only hashes descriptors on the unknown-key slow path), the bucket lookup must run eagerly for every class descriptor, so removal breaks apps with partially-customized serializers that never use @JsonExtraKeys.

Benchmarks

JMH, JDK 21 (arm64 macOS). Micro: JsonExtraKeysBenchmark (10-field object, ops/µs).
Macro: TwitterFeedBenchmark (ops/s). Higher is better.

Hot path — classes without @JsonExtraKeys

Scenario master this PR, useExtraKeys = false (default) this PR, useExtraKeys = true
decode 10-field object 3.33 ± 0.12 3.51 ± 0.40 3.39 ± 0.31
encode 10-field object 10.13 ± 0.26 10.27 ± 0.37 9.84 ± 0.42
decode TwitterFeed (macro) 1562 ± 12 1596 ± 47 — parity 1449 ± 10 (−7%)
encode TwitterFeed (macro) 3221 ± 86 3223 ± 94 — parity 3046 ± 113 (−5%)

Feature cost — classes with a @JsonExtraKeys bucket (useExtraKeys = true)

Scenario Map<String, JsonElement> bucket JsonObject bucket Reference
decode, input has no unknown keys 3.62 ± 0.10 3.61 ± 0.33 ≥ plain decode: declaring a bucket is free on clean input
decode, capture 3 unknown keys 1.76 ± 0.16 1.64 ± 0.12 2.54 ± 0.10 with ignoreUnknownKeys (keys dropped)
encode, write back 3 captured keys 4.64 ± 0.10 4.67 ± 0.16 10.27 plain encode (10 keys vs 13 keys written)

Summary

Looks like with flag off the cost is basically free. But, enabling the flag costs about 5-7%. Probably keeping it false, unless needed is ideal?

@inemtsev

inemtsev commented Jul 4, 2026

Copy link
Copy Markdown
Author

@sandwwraith I pushed changes listed above.

I also discovered an optimization that would help operations like this, but I will open another PR for that:
#3215

inemtsev added 11 commits August 6, 2026 19:21
…JsonExtraKeys is used, now near 0 allocation
JsonExtraKeysBenchmark covers the plain hot path under both useExtraKeys
states, bucket declaration on clean input, capture, and write-back.
TwitterFeedBenchmark gains enabled-state variants for macro payloads;
the stock decodeTwitter/encodeTwitter measure the default (disabled) state.
…k in decodeObjectIndex

The per-key call into extraKeysIndexFor cost ~2% on TwitterFeed decode
against the current dev base. Branching on the flag directly and going
straight to decodeObjectIndexNoBucket restores parity (verified with
paired sequential JMH runs vs dev).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants