Skip to content

[Bug] Adjacently-tagged enum (@CodedAt + @ContentAt) with default string tag decodes associated values from the wrong container #163

Description

@Kaeldehta

Describe the bug

For an adjacently-tagged enum using @CodedAt(...) + @ContentAt(...) without an explicit identifier type (i.e. default string tagging by case name), the generated Decodable and Encodable are asymmetric:

  • The encoder correctly nests associated values under the content key (container.superEncoder(forKey:) → nested container).
  • The decoder declares contentDecoder = try container.superDecoder(forKey:) but never uses it — it reads each case's associated values from the outer (discriminator) container instead of a nested container built from contentDecoder.

As a result the type cannot decode what it just encoded, and any real nested payload fails with keyNotFound.

This is a regression: it works correctly in 1.5.0 and broke in 1.6.0. Git-bisect points to commit 8e3a24f6c (PR #144, "added support for specifying arbitrary primitive enum case values"). It is still present on main. The correct code is only generated when an explicit identifier type is given (e.g. @CodedAs<Int>); the default (identifierType == nil) path is the broken one.

To Reproduce

import Foundation
import MetaCodable

@Codable
@CodedAt("type")
@ContentAt("content")
enum Command: Equatable {
    case load(key: String)
    case store(key: String, value: Int)
}

// Round-trip fails:
let original = Command.store(key: "my_key", value: 42)
let data = try JSONEncoder().encode(original)
print(String(decoding: data, as: UTF8.self))
// {"type":"store","content":{"key":"my_key","value":42}}

let decoded = try JSONDecoder().decode(Command.self, from: data)
// ❌ DecodingError.keyNotFound: Key 'key' not found

Generated decoder (1.6.1) — note contentDecoder is created but the case reads from container:

let container = try decoder.container(keyedBy: CodingKeys.self)
typeContainer = container
let contentDecoder = try container.superDecoder(forKey: CodingKeys.content) // ← unused
// ...
case "store":
    key = try container.decode(String.self, forKey: CodingKeys.key)     // ← wrong container
    value = try container.decode(Int.self, forKey: CodingKeys.value)    // ← wrong container

Expected behavior

Each case should decode associated values from a nested container derived from contentDecoder, matching the encoder and the explicit-identifier-type path — i.e. as generated in 1.5.0:

case "store":
    let container = try contentDecoder.container(keyedBy: CodingKeys.self)
    key = try container.decode(String.self, forKey: CodingKeys.key)
    value = try container.decode(Int.self, forKey: CodingKeys.value)

The fix belongs in InternallyTaggedEnumSwitcher's adjacent-tagging decoder (AdjacentlyTaggableSwitcher.swift), in the identifierType == nil branch, so the content decoder is threaded into per-case decoding.

Note: the existing snapshot test in Tests/MetaCodableTests/ContentAtTests.swift currently encodes the broken decoder output, and the functional test contentAtFromJSON decodes root-level keys (comment: "The decoding expects key/value at root level, not in content") — inconsistent with what the encoder emits — so there is no real round-trip test covering this case.

Environment:

  • OS: macOS 26.4 (build 25E246)
  • Version: MetaCodable 1.6.0 and 1.6.1 (also main); regression from 1.6.0 (works in 1.5.0)
  • Xcode: 26.4.1 (build 17E202)
  • Swift: 6.3.1 (swiftlang-6.3.1.1.2)

Additional context

Bisected: 8e3a24f6c^ generates correct code; 8e3a24f6c (PR #144) generates the broken code. Workaround: pin to 1.5.0.

This was generated with the help of Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions