To preserve plain nested values with a recursive Swift enum, implement custom encoding and decoding for each value kind. This walkthrough builds a synthetic Mac app model that round-trips through Foundation objects entirely in memory, without adding enum-case wrappers.

Why does synthesized Codable change the representation?

Suppose a Mac desktop app has a small layout preview containing labels, numeric values, switches, lists and nested groups. A recursive enum can describe those values precisely. The concrete task here is to convert that enum into a plain Foundation object graph and reconstruct it while preserving the supported value kinds.

Automatic conformance does not provide the required representation. Swift’s implemented enum synthesis design places the case name in an outer keyed container, then places associated values inside another container. Unlabeled associated values receive generated keys such as _0. Consequently, a string case acquires structural wrappers instead of becoming a plain string. These rules are documented in Codable synthesis for enums with associated values.

Changing CodingKeys can rename those wrappers, but retaining synthesis retains its structural model. The supported solution is to implement init(from:) and encode(to:) yourself. Apple explicitly documents manual conformance for types whose Swift structure differs from their encoded structure in Encoding and Decoding Custom Types.

Recursion and representation are separate decisions. Swift’s indirect modifier enables recursive enum storage; custom coding methods determine the encoded shape. The language guide explains placing indirect before the enum declaration to apply indirection to its associated-value cases in Enumerations.

What exactly should the plain representation preserve?

Use the following contract for this example. It deliberately supports a small value vocabulary suitable for a synthetic layout preview. Foundation’s documented conversion rules allow strings, numbers, arrays, dictionaries and null objects, require string dictionary keys, and exclude nonfinite numbers. See JSONSerialization.

Enum case Plain value Compatibility requirement
null NSNull Preserve an explicit null value
bool Boolean value Keep distinct from numeric zero and one
number Finite Double value Accept the precision limits of Double
string String Preserve text without numeric conversion
array Array of plain values Preserve element order and empty arrays
object String-keyed dictionary Preserve keys, nested values and empty objects

Compatibility here means preserving those categories and their values within the stated numeric limits. It does not require preserving a particular Foundation number subclass or the order in which dictionary keys appear. An explicit null entry must also survive: an object containing "subtitle": NSNull() has a different model meaning from an object with no subtitle key.

Before adopting this contract, decide whether numbers really can share one Double case. Apple documents Double as a 64-bit floating-point type and provides exact-conversion initializers because ordinary conversions can round. If your model needs exact large integers, choose an explicit numeric policy before converting them. Adding an integer case also requires a decoding rule, because plain numeric values do not carry Swift enum-case identities. See Double.

How do you implement the recursive enum?

Use a current Mac Swift environment with Foundation available. The next three code blocks, in order, form one complete top-level example: the enum, conversion functions and an executable check routine. All sample values are created in memory. Swift is unavailable in the research environment, so the example was inspected but not executed.

The decoder first checks null, then attempts the supported primitive types. Apple’s SingleValueDecodingContainer provides these operations, including decodeNil(). The local helper treats only a type mismatch as a reason to try another primitive; other decoding errors propagate.

import Foundation

indirect enum PlainValue: Codable, Equatable {
    case null
    case bool(Bool)
    case number(Double)
    case string(String)
    case array([PlainValue])
    case object([String: PlainValue])

    init(from decoder: Decoder) throws {
        let scalar = try decoder.singleValueContainer()

        if scalar.decodeNil() {
            self = .null
            return
        }

        func matching<T: Decodable>(_ type: T.Type) throws -> T? {
            do {
                return try scalar.decode(type)
            } catch DecodingError.typeMismatch(_, _) {
                return nil
            }
        }

        if let value = try matching(Bool.self) {
            self = .bool(value)
            return
        }
        if let value = try matching(String.self) {
            self = .string(value)
            return
        }
        if let value = try matching(Double.self) {
            guard value.isFinite else {
                throw DecodingError.dataCorrupted(.init(
                    codingPath: decoder.codingPath,
                    debugDescription: "Expected a finite number."
                ))
            }
            self = .number(value)
            return
        }

        var sequence: UnkeyedDecodingContainer?
        do {
            sequence = try decoder.unkeyedContainer()
        } catch DecodingError.typeMismatch(_, _) {
            sequence = nil
        }

        if var items = sequence {
            var values: [PlainValue] = []
            while !items.isAtEnd {
                values.append(try items.decode(PlainValue.self))
            }
            self = .array(values)
            return
        }

        self = .object(try scalar.decode([String: PlainValue].self))
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()

        switch self {
        case .null:
            try container.encodeNil()
        case .bool(let value):
            try container.encode(value)
        case .number(let value):
            guard value.isFinite else {
                throw EncodingError.invalidValue(value, .init(
                    codingPath: encoder.codingPath,
                    debugDescription: "Expected a finite number."
                ))
            }
            try container.encode(value)
        case .string(let value):
            try container.encode(value)
        case .array(let values):
            try container.encode(values)
        case .object(let values):
            try container.encode(values)
        }
    }
}

Each encoding branch writes its payload directly. The null branch explicitly calls encodeNil(), and the collection branches delegate to the arrays’ and dictionaries’ coding implementations. Apple documents both primitive and generic encoding operations in SingleValueEncodingContainer, and collection conformance in Encoding and Decoding Custom Types.

Array detection is deliberately separate from array-element decoding. Requesting an unkeyed container can throw a type mismatch when the current value is not an array. Once an array is recognized, every element is decoded with ordinary try, so an element failure escapes instead of triggering an unrelated object-decoding attempt. The documented behavior is described in unkeyedContainer() and UnkeyedDecodingContainer.

This implementation targets Foundation’s JSON coding path and the stated value vocabulary. Test any other encoder or decoder against the same contract before using it; conformance to Codable alone does not establish identical behavior across representation formats.

How do you convert to Foundation objects entirely in memory?

Foundation exposes two useful boundaries: JSONEncoder produces Data from an encodable value, and JSONDecoder reconstructs a decodable value from Data. JSONSerialization converts between that intermediate representation and Foundation objects. Combining these documented operations provides the following in-memory bridge. See JSONEncoder, JSONDecoder and JSONSerialization.

func foundationObject(from value: PlainValue) throws -> Any {
    let bytes = try JSONEncoder().encode(value)
    return try JSONSerialization.jsonObject(
        with: bytes,
        options: [.fragmentsAllowed]
    )
}

func plainValue(from object: Any) throws -> PlainValue {
    let bytes = try JSONSerialization.data(
        withJSONObject: object,
        options: [.fragmentsAllowed]
    )
    return try JSONDecoder().decode(PlainValue.self, from: bytes)
}

The intermediate Data remains in memory. These functions perform serialization work; they are not zero-copy casts between the enum and Foundation collections. Keep the conversion at the boundary where your desktop feature actually needs a plain object graph, while retaining the enum for typed model operations.

Both .fragmentsAllowed options matter because a recursive value can have a scalar root. A top-level string, Boolean, number or null needs the same treatment as a nested value. Foundation documents this behavior separately for ReadingOptions.fragmentsAllowed and WritingOptions.fragmentsAllowed.

The reverse function accepts Any because that is the Foundation API’s input type, but its supported domain remains the contract above. Conversion errors propagate to the caller. Do not replace an unsuccessful conversion with an empty object or remove elements that fail to convert; doing so would change the model silently.

Which checks demonstrate compatibility?

A round trip alone can miss the original problem: synthesized enum coding can round-trip its own wrapped structure successfully. Check both the enum equality and the plain shape. The following routine constructs an independent Foundation graph, verifies its expected enum model, checks the output’s actual root keys and exercises each root category.

func runExample() throws {
    let plain: [String: Any] = [
        "title": "Tile preview",
        "enabled": true,
        "spacing": 8.5,
        "subtitle": NSNull(),
        "tiles": [
            ["label": "Circle", "size": 24.0],
            ["label": "Square", "size": 32.0]
        ]
    ]

    let expected: PlainValue = .object([
        "title": .string("Tile preview"),
        "enabled": .bool(true),
        "spacing": .number(8.5),
        "subtitle": .null,
        "tiles": .array([
            .object([
                "label": .string("Circle"),
                "size": .number(24)
            ]),
            .object([
                "label": .string("Square"),
                "size": .number(32)
            ])
        ])
    ])

    let model = try plainValue(from: plain)
    precondition(model == expected)

    let output = try foundationObject(from: model)
    guard let object = output as? [String: Any] else {
        preconditionFailure("Expected a plain object root.")
    }
    precondition(Set(object.keys) == Set(plain.keys))
    precondition(object["title"] as? String == "Tile preview")
    precondition(object["subtitle"] is NSNull)
    precondition((object["tiles"] as? [Any])?.count == 2)

    let restored = try plainValue(from: output)
    precondition(restored == expected)

    let roots: [PlainValue] = [
        .null, .bool(false), .bool(true),
        .number(0), .number(1), .number(8.5),
        .string("1"), .array([]), .object([:]),
        expected
    ]
    for root in roots {
        let graph = try foundationObject(from: root)
        let decoded = try plainValue(from: graph)
        precondition(decoded == root)
    }

    do {
        _ = try foundationObject(from: .number(.infinity))
        preconditionFailure("Expected nonfinite number rejection.")
    } catch EncodingError.invalidValue(_, _) {
        // Expected: this model requires finite numbers.
    }

    print("All compatibility checks passed.")
}

try runExample()

The expected success message is All compatibility checks passed. The root-key check catches an added outer case wrapper. The independent expected model catches wrong case selection, including Boolean versus number and numeric-looking text versus number. The null check confirms that subtitle remains present, while the root checks cover scalar fragments and distinguish empty arrays from empty objects.

For an existing desktop model, adapt this diagnostic sequence to its synthetic values: first verify the expected enum, then inspect the plain shape, then reconstruct the enum, and finally exercise boundary cases. Keep intermediate results named so a failed check identifies which conversion changed the value.

What are the limits of this approach?

The numeric contract is the main limitation. A single Double case cannot promise exact preservation of every numeric Foundation input. Where exact integer conversion matters, validate representability before entering this model or redesign the numeric cases and their decoding rules. Successful serialization is not evidence of exact numeric preservation. Apple’s Double documentation distinguishes rounded conversions from exact conversions.

The example also rejects nonfinite numbers explicitly in both coding directions. This keeps the enum’s boundary aligned with the plain Foundation representation’s documented restrictions. Recursive structure does not imply unlimited nesting capacity, so keep synthetic model trees reasonably sized and let conversion errors reach the calling feature.

For the layout-preview task, the finished result is a typed recursive enum whose custom coding preserves plain strings, Booleans, finite numbers, nulls, arrays and string-keyed objects. Use the shape checks alongside round-trip checks whenever you change the cases or conversion logic; together they make the intended representation concrete and reviewable.

Sources