Skip to content

Route Dictionary through key-aware encoding in type-erased Codable paths - #46

Open
vincentborko wants to merge 1 commit into
skiptools:mainfrom
vincentborko:pr/codable-dictionary-typeerased
Open

Route Dictionary through key-aware encoding in type-erased Codable paths#46
vincentborko wants to merge 1 commit into
skiptools:mainfrom
vincentborko:pr/codable-dictionary-typeerased

Conversation

@vincentborko

@vincentborko vincentborko commented Jul 24, 2026

Copy link
Copy Markdown

Motivation

JSONEncoder().encode(...) on a Codable type with a populated dictionary property can crash with:

skip.lib.ErrorException: java.lang.ClassCastException: skip.lib.Tuple2 cannot be cast to skip.lib.Encodable

Reported as skiptools/skip-foundation#62. A non-optional, statically-typed dictionary property already works, so the failure is easy to miss until real (non-empty) data flows through an optional or nested dictionary.

Root cause

A Swift Dictionary<K, V> is backed by a real LinkedHashMap but also conforms to Collection<Tuple2<K, V>> (hence Sequence<Tuple2>) to provide Swift's Sequence/Collection surface. skip-lib's Codable dispatch has two tiers:

  • a statically-typed fast path — the inline reified encode(value: Dictionary<K, V>, …) overloads, which iterate .storage and preserve keys; and
  • a type-erased path, reached whenever the static Dictionary<K, V> type has been lost — the generic encode<T> / encodeIfPresent<T> overrides on the containers, and the free codableUnkeyedEncode / codableSingleValueEncode / codableDictionaryKeyedEncode helpers.

The type-erased path checked only is Sequence<*> before its fallthrough. Because a Dictionary is a Sequence<Tuple2>, any dictionary reaching this path is iterated as a flat array of raw Tuple2 pairs; each Tuple2 then matches none of the primitive cases and falls through to container.encode(value), which the concrete encoder eventually force-casts to Encodable — throwing on Tuple2. Where it happens not to crash, it silently corrupts shape ({"a":1}[["a",1]]).

Verified on Robolectric, the literal skip-foundation#62 repro reaches this path via an optional dictionary: container.encodeIfPresent(optionalDict, …) resolves to the generic encodeIfPresent<T> → generic encode<T> override, not the reified overload. A nested [String: [String: String]] reaches it through codableDictionaryKeyedEncode for the inner dictionary value.

Fix

Add an explicit is Dictionary<*, *> branch ahead of every is Sequence<*> check in the type-erased dispatch (the TopLevelEncoder / keyed / unkeyed / single-value encode<T> overrides and the three free helpers), routing to the existing key-aware encoding. The keyed-object vs unkeyed-array decision is recovered from the runtime key type (Int/String → object, else array), mirroring the reified overloads. The existing encodeAsArray helpers are relaxed to Dictionary<*, *> so they can be shared.

Testing

  • skip-lib swift test (native): all 174 tests pass.
  • skip-lib has no concrete encoder of its own, so the end-to-end regression test lives in the linked skip-foundation PR (a JSONEncoder round-trip over optional and nested dictionaries). Against unpatched skip-lib it reproduces the Tuple2 cannot be cast to Encodable crash on Robolectric; with this change it passes, producing the correct JSON object shape. Verified by building skip-foundation against this branch as a local dependency.

Companion PR (defense-in-depth + regression test): skiptools/skip-foundation#127


  • I have read and agree to the Contributor License Agreement.
  • swift test runs locally.
  • This change was prepared with AI assistance (Claude) and reviewed by me.

A Swift Dictionary is represented in Kotlin as a real LinkedHashMap that also
conforms to Collection<Tuple2<K,V>> (hence Sequence<Tuple2>) 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<T>/encodeIfPresent<T> 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) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant