From a5cddbecf10b3e6828164769fa79b4bf70bc8637 Mon Sep 17 00:00:00 2001 From: vincentborko Date: Fri, 24 Jul 2026 16:32:34 +0200 Subject: [PATCH] Route Dictionary through key-aware encoding in type-erased Codable paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Swift Dictionary is represented in Kotlin as a real LinkedHashMap that also conforms to Collection> (hence Sequence) to satisfy Swift's Sequence surface. skip-lib's Codable dispatch has a statically-typed fast path (the reified Dictionary overloads) and a type-erased path (the generic encode/encodeIfPresent overrides plus the codableUnkeyed/SingleValue/ DictionaryKeyed free helpers). The type-erased path only checked `is Sequence<*>`, so a Dictionary reaching it — e.g. an optional dictionary whose encodeIfPresent resolves to the generic overload, or a Dictionary nested as a value inside another Dictionary — was iterated as a flat sequence of raw Tuple2 pairs. Those bare Tuple2 values are neither primitives nor Encodable, producing "skip.lib.Tuple2 cannot be cast to skip.lib.Encodable" and, where it did not crash, silent JSON-shape corruption ({"a":1} serialized as [["a",1]]). Add an explicit `is Dictionary<*,*>` branch ahead of every `is Sequence<*>` check that routes to the existing key-aware encoding, choosing keyed-object vs unkeyed-array form from the runtime key type (Int/String -> object, else array) to mirror the reified overloads. Fixes skiptools/skip-foundation#62 Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/SkipLib/Skip/Codable.kt | 101 +++++++++++++++++++++++++++++--- 1 file changed, 93 insertions(+), 8 deletions(-) diff --git a/Sources/SkipLib/Skip/Codable.kt b/Sources/SkipLib/Skip/Codable.kt index 6e6b907..62991eb 100644 --- a/Sources/SkipLib/Skip/Codable.kt +++ b/Sources/SkipLib/Skip/Codable.kt @@ -26,7 +26,15 @@ abstract class TopLevelEncoder { fun encode(value: T): Output where T: Any { val encoder = encoder() - if (value is Sequence<*>) { + if (value is Dictionary<*, *>) { + // A Dictionary reaching this type-erased path is also a Sequence, so it must be + // routed to key-aware encoding before the generic Sequence branch below (see skiptools/skip-foundation#62). + if (dictionaryEncodesAsObject(value)) { + encodeAsDictionary(value, encoder) + } else { + encodeAsArray(value, encoder) + } + } else if (value is Sequence<*>) { val container = encoder.unkeyedContainer() for (element in value.iterable) { codableUnkeyedEncode(element, container) @@ -55,7 +63,7 @@ abstract class TopLevelEncoder { } } - fun encodeAsArray(value: Dictionary, encoder: Encoder) where K: Any, V: Any { + fun encodeAsArray(value: Dictionary<*, *>, encoder: Encoder) { val container = encoder.unkeyedContainer() for ((dkey, dvalue) in value.storage) { codableUnkeyedEncode(dkey, container) @@ -202,7 +210,16 @@ class KeyedEncodingContainer(container: KeyedEncodingContainerProtocol encode(value: T?, forKey: CodingKey) where T: Any { - if (value is Sequence<*>) { + if (value is Dictionary<*, *>) { + // A Dictionary reaching this type-erased path (e.g. an optional dictionary whose + // encodeIfPresent resolves to the generic overload) is also a Sequence, so it + // must be routed to key-aware encoding before the Sequence branch below (see skiptools/skip-foundation#62). + if (dictionaryEncodesAsObject(value)) { + encodeAsDictionary(value, forKey) + } else { + encodeAsArray(value, forKey) + } + } else if (value is Sequence<*>) { val container = nestedUnkeyedContainer(forKey) container.encode(contentsOf = value) } else { @@ -303,7 +320,7 @@ class KeyedEncodingContainer(container: KeyedEncodingContainerProtocol encodeAsArray(value: Dictionary, forKey: CodingKey) where K: Any, V: Any { + fun encodeAsArray(value: Dictionary<*, *>, forKey: CodingKey) { val container = nestedUnkeyedContainer(forKey) for ((dkey, dvalue) in value.storage) { codableUnkeyedEncode(dkey, container) @@ -407,7 +424,15 @@ class UnkeyedEncodingContainer(container: UnkeyedEncodingContainerProtocol) : Un } override fun encode(value: T) { - if (value is Sequence<*>) { + if (value is Dictionary<*, *>) { + // A Dictionary reaching this type-erased path is also a Sequence, so it must be + // routed to key-aware encoding before the generic Sequence branch below (see skiptools/skip-foundation#62). + if (dictionaryEncodesAsObject(value)) { + encodeAsDictionary(value) + } else { + encodeAsArray(value) + } + } else if (value is Sequence<*>) { val container = nestedUnkeyedContainer() container.encode(contentsOf = value) } else { @@ -444,7 +469,7 @@ class UnkeyedEncodingContainer(container: UnkeyedEncodingContainerProtocol) : Un } } - fun encodeAsArray(value: Dictionary) where K: Any, V: Any { + fun encodeAsArray(value: Dictionary<*, *>) { val container = nestedUnkeyedContainer() for ((dkey, dvalue) in value.storage) { codableUnkeyedEncode(dkey, container) @@ -532,7 +557,15 @@ class SingleValueEncodingContainer(container: SingleValueEncodingContainerProtoc } override fun encode(value: T) { - if (value is Sequence<*>) { + if (value is Dictionary<*, *>) { + // A Dictionary reaching this type-erased path is also a Sequence, so it must be + // routed to key-aware encoding before the generic Sequence branch below (see skiptools/skip-foundation#62). + if (dictionaryEncodesAsObject(value)) { + encodeAsDictionary(value) + } else { + encodeAsArray(value) + } + } else if (value is Sequence<*>) { val container = nestedUnkeyedContainer() container.encode(contentsOf = value) } else { @@ -565,7 +598,7 @@ class SingleValueEncodingContainer(container: SingleValueEncodingContainerProtoc } } - fun encodeAsArray(value: Dictionary) where K: Any, V: Any { + fun encodeAsArray(value: Dictionary<*, *>) { val container = nestedUnkeyedContainer() for ((dkey, dvalue) in value.storage) { codableUnkeyedEncode(dkey, container) @@ -574,6 +607,31 @@ class SingleValueEncodingContainer(container: SingleValueEncodingContainerProtoc } } +// A Dictionary that reaches a type-erased Codable path has lost its static Key type. Swift encodes +// dictionaries keyed by Int or String as keyed objects and all others as unkeyed arrays of +// alternating key/value; recover that decision from the runtime key type. An empty dictionary has no +// key to inspect, so it defaults to the keyed-object form used by the overwhelmingly common +// String/Int-keyed case. +private fun dictionaryEncodesAsObject(value: Dictionary<*, *>): Boolean { + for ((dkey, _) in value.storage) { + return dkey is Int || dkey is String + } + return true +} + +private fun encodeDictionaryAsObject(value: Dictionary<*, *>, container: KeyedEncodingContainerProtocol) { + for ((dkey, dvalue) in value.storage) { + codableDictionaryKeyedEncode(dvalue, dkey, container) + } +} + +private fun encodeDictionaryAsArray(value: Dictionary<*, *>, container: UnkeyedEncodingContainer) { + for ((dkey, dvalue) in value.storage) { + codableUnkeyedEncode(dkey, container) + codableUnkeyedEncode(dvalue, container) + } +} + fun codableDictionaryKeyedEncode(value: T?, forKey: Any?, container: KeyedEncodingContainerProtocol) where T: Any { val key = DictionaryCodingKey(forKey?.toString() ?: "") when (value) { @@ -589,6 +647,15 @@ fun codableDictionaryKeyedEncode(value: T?, forKey: Any?, container: KeyedEn is ULong -> container.encode(value, key) is Float -> container.encode(value, key) is Double -> container.encode(value, key) + is Dictionary<*, *> -> { + // Route a nested Dictionary value to key-aware encoding before the Sequence branch, + // since Dictionary is also a Sequence (see skiptools/skip-foundation#62). + if (dictionaryEncodesAsObject(value)) { + encodeDictionaryAsObject(value, container.nestedContainer(keyedBy = DictionaryCodingKey::class, key)) + } else { + encodeDictionaryAsArray(value, container.nestedUnkeyedContainer(key)) + } + } is Sequence<*> -> { val valueContainer = container.nestedUnkeyedContainer(key) valueContainer.encode(contentsOf = value) @@ -612,6 +679,15 @@ fun codableUnkeyedEncode(value: T?, container: UnkeyedEncodingContainer) whe is ULong -> container.encode(value) is Float -> container.encode(value) is Double -> container.encode(value) + is Dictionary<*, *> -> { + // Route a Dictionary element to key-aware encoding before the Sequence branch, since + // Dictionary is also a Sequence (see skiptools/skip-foundation#62). + if (dictionaryEncodesAsObject(value)) { + encodeDictionaryAsObject(value, container.nestedContainer(keyedBy = DictionaryCodingKey::class)) + } else { + encodeDictionaryAsArray(value, container.nestedUnkeyedContainer()) + } + } is Sequence<*> -> { val nestedContainer = container.nestedUnkeyedContainer() nestedContainer.encode(contentsOf = value) @@ -635,6 +711,15 @@ fun codableSingleValueEncode(value: T?, container: SingleValueEncodingContai is ULong -> container.encode(value) is Float -> container.encode(value) is Double -> container.encode(value) + is Dictionary<*, *> -> { + // Route a Dictionary value to key-aware encoding before the Sequence branch, since + // Dictionary is also a Sequence (see skiptools/skip-foundation#62). + if (dictionaryEncodesAsObject(value)) { + encodeDictionaryAsObject(value, container.nestedContainer(keyedBy = DictionaryCodingKey::class)) + } else { + encodeDictionaryAsArray(value, container.nestedUnkeyedContainer()) + } + } is Sequence<*> -> { val nestedContainer = container.nestedUnkeyedContainer() nestedContainer.encode(contentsOf = value)