From c49d009b0e42a128e55edc0c77516cabdffe0e00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Fri, 26 Jun 2026 17:12:28 +0900 Subject: [PATCH 1/8] feat: omit nil OptionalPolymorphicValue field on encode Add a KeyedEncodingContainer.encode(_:forKey:) overload that skips the key when wrappedValue is nil, matching Apple's default Codable behavior (nil optionals are omitted, not encoded as an explicit null). Mirrors the existing decode(_:forKey:) overload, so round-trips restore nil. Claude-Session: https://claude.ai/code/session_01BSXaRrJaLrjWQaiJSMy45Q --- ...ngContainer+OptionalPolymorphicValue.swift | 24 ++++++++ .../OptionalPolymorphicValueTests.swift | 56 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedEncodingContainer+OptionalPolymorphicValue.swift diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedEncodingContainer+OptionalPolymorphicValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedEncodingContainer+OptionalPolymorphicValue.swift new file mode 100644 index 0000000..ebd6e43 --- /dev/null +++ b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedEncodingContainer+OptionalPolymorphicValue.swift @@ -0,0 +1,24 @@ +// +// KeyedEncodingContainer+OptionalPolymorphicValue.swift +// KarrotCodableKit +// +// Created by Elon on 6/26/26. +// Copyright © 2026 Danggeun Market Inc. All rights reserved. +// + +import Foundation + +extension KeyedEncodingContainer { + /// Encodes an `OptionalPolymorphicValue`, omitting the key entirely when the wrapped value is `nil`. + /// + /// This mirrors Apple's default `Codable` behavior for optional properties, where a `nil` value + /// results in the key being skipped rather than encoded as an explicit `null`. It is the encoding-side + /// counterpart to the `decode(_:forKey:)` overload that treats a missing key as `nil`. + public mutating func encode( + _ value: OptionalPolymorphicValue, + forKey key: Key + ) throws where T: PolymorphicCodableStrategy { + guard value.wrappedValue != nil else { return } + try value.encode(to: superEncoder(forKey: key)) + } +} diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/OptionalPolymorphicValueTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/OptionalPolymorphicValueTests.swift index f4427cd..8f779e7 100644 --- a/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/OptionalPolymorphicValueTests.swift +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/OptionalPolymorphicValueTests.swift @@ -68,4 +68,60 @@ struct OptionalPolymorphicValueTests { _ = try JSONDecoder().decode(OptionalDummyResponse.self, from: Data(jsonData.utf8)) } } + + @Test + func encodingOptionalPolymorphicValueOmitsNilFields() throws { + // given + let response = OptionalDummyResponse( + notice1: DummyCallout( + type: .callout, + title: nil, + description: "test", + icon: "test_icon" + ), + notice2: nil, + notice3: nil + ) + + // when + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(response) + + // then - nil fields (notice2, notice3) are omitted, matching Apple's default Codable behavior + let expectResult = #""" + { + "notice1" : { + "description" : "test", + "icon" : "test_icon", + "type" : "callout" + } + } + """# + let jsonString = try #require(String(bytes: data, encoding: .utf8)) + #expect(jsonString == expectResult) + } + + @Test + func encodingDecodingOptionalPolymorphicValueRoundTrip() throws { + // given + let response = OptionalDummyResponse(notice1: nil, notice2: nil, notice3: nil) + + // when - encode (all nil -> empty object, no explicit null) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(response) + + // then - empty object + let jsonString = try #require(String(bytes: data, encoding: .utf8)) + #expect(jsonString == "{\n\n}") + + // when - decode back + let decoded = try JSONDecoder().decode(OptionalDummyResponse.self, from: data) + + // then - nil values are restored (round-trip) + #expect(decoded.notice1 == nil) + #expect(decoded.notice2 == nil) + #expect(decoded.notice3 == nil) + } } From 4481658fb5c477d942bbbea267006a55753e6332 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Fri, 26 Jun 2026 17:12:28 +0900 Subject: [PATCH 2/8] feat: omit nil LossyOptionalPolymorphicValue field on encode Add the nil-omission encoding overload for the lossy variant, consistent with OptionalPolymorphicValue. Claude-Session: https://claude.ai/code/session_01BSXaRrJaLrjWQaiJSMy45Q --- ...tainer+LossyOptionalPolymorphicValue.swift | 24 +++++++++++++++++++ .../LossyOptionalPolymorphicValueTests.swift | 18 +++++--------- 2 files changed, 30 insertions(+), 12 deletions(-) create mode 100644 Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedEncodingContainer+LossyOptionalPolymorphicValue.swift diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedEncodingContainer+LossyOptionalPolymorphicValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedEncodingContainer+LossyOptionalPolymorphicValue.swift new file mode 100644 index 0000000..ca5e9de --- /dev/null +++ b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedEncodingContainer+LossyOptionalPolymorphicValue.swift @@ -0,0 +1,24 @@ +// +// KeyedEncodingContainer+LossyOptionalPolymorphicValue.swift +// KarrotCodableKit +// +// Created by Elon on 6/26/26. +// Copyright © 2026 Danggeun Market Inc. All rights reserved. +// + +import Foundation + +extension KeyedEncodingContainer { + /// Encodes a `LossyOptionalPolymorphicValue`, omitting the key entirely when the wrapped value is `nil`. + /// + /// This mirrors Apple's default `Codable` behavior for optional properties, where a `nil` value + /// results in the key being skipped rather than encoded as an explicit `null`. It is the encoding-side + /// counterpart to the `decode(_:forKey:)` overload that treats a missing key as `nil`. + public mutating func encode( + _ value: LossyOptionalPolymorphicValue, + forKey key: Key + ) throws where T: PolymorphicCodableStrategy { + guard value.wrappedValue != nil else { return } + try value.encode(to: superEncoder(forKey: key)) + } +} diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/LossyOptionalPolymorphicValueTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/LossyOptionalPolymorphicValueTests.swift index 6652d12..469649d 100644 --- a/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/LossyOptionalPolymorphicValueTests.swift +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/LossyOptionalPolymorphicValueTests.swift @@ -29,8 +29,7 @@ final class LossyOptionalPolymorphicValueTests: XCTestCase { "description" : "test", "icon" : "test_icon", "type" : "callout" - }, - "notice2" : null + } } """# @@ -39,8 +38,8 @@ final class LossyOptionalPolymorphicValueTests: XCTestCase { encoder.outputFormatting = [.prettyPrinted, .sortedKeys] let data = try encoder.encode(response) - // then - let jsonString = String(decoding: data, as: UTF8.self) + // then - notice2 (nil) is omitted, matching Apple's default Codable behavior + let jsonString = try XCTUnwrap(String(bytes: data, encoding: .utf8)) XCTAssertEqual(jsonString, expectResult) } @@ -85,14 +84,9 @@ final class LossyOptionalPolymorphicValueTests: XCTestCase { encoder.outputFormatting = [.prettyPrinted, .sortedKeys] let data = try encoder.encode(result) - // then - let expectResult = #""" - { - "notice1" : null, - "notice2" : null - } - """# - let jsonString = String(decoding: data, as: UTF8.self) + // then - all nil values are omitted, producing an empty object + let expectResult = "{\n\n}" + let jsonString = try XCTUnwrap(String(bytes: data, encoding: .utf8)) XCTAssertEqual(jsonString, expectResult) } } From 725245ded13cba54d277d213067c1c19d7d7df93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Fri, 26 Jun 2026 17:12:40 +0900 Subject: [PATCH 3/8] feat: omit nil OptionalPolymorphicArrayValue field on encode Add the nil-omission encoding overload for the optional array wrapper. An empty array ([]) is non-nil and stays as []; only nil omits the key. Also update the now-stale 'encodes as null' doc. Claude-Session: https://claude.ai/code/session_01BSXaRrJaLrjWQaiJSMy45Q --- ...tainer+OptionalPolymorphicArrayValue.swift | 24 +++++++++++ .../OptionalPolymorphicArrayValue.swift | 2 +- .../OptionalPolymorphicArrayValueTests.swift | 42 ++++++++++++------- 3 files changed, 53 insertions(+), 15 deletions(-) create mode 100644 Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedEncodingContainer+OptionalPolymorphicArrayValue.swift diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedEncodingContainer+OptionalPolymorphicArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedEncodingContainer+OptionalPolymorphicArrayValue.swift new file mode 100644 index 0000000..a259a47 --- /dev/null +++ b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedEncodingContainer+OptionalPolymorphicArrayValue.swift @@ -0,0 +1,24 @@ +// +// KeyedEncodingContainer+OptionalPolymorphicArrayValue.swift +// KarrotCodableKit +// +// Created by Elon on 6/26/26. +// Copyright © 2026 Danggeun Market Inc. All rights reserved. +// + +import Foundation + +extension KeyedEncodingContainer { + /// Encodes an `OptionalPolymorphicArrayValue`, omitting the key entirely when the wrapped array is `nil`. + /// + /// This mirrors Apple's default `Codable` behavior for optional properties, where a `nil` value + /// results in the key being skipped rather than encoded as an explicit `null`. It is the encoding-side + /// counterpart to the `decode(_:forKey:)` overload that treats a missing key as `nil`. + public mutating func encode( + _ value: OptionalPolymorphicArrayValue, + forKey key: Key + ) throws where T: PolymorphicCodableStrategy { + guard value.wrappedValue != nil else { return } + try value.encode(to: superEncoder(forKey: key)) + } +} diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicArrayValue.swift index 8a35a02..3e4723a 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicArrayValue.swift @@ -30,7 +30,7 @@ import Foundation /// - Empty arrays are decoded as empty arrays, not `nil` /// /// Encoding behavior: -/// - If `wrappedValue` is `nil`, encodes as `null` +/// - If `wrappedValue` is `nil`, the key is omitted (a `null` is only produced inside an unkeyed container) /// - If `wrappedValue` contains an array, each element is encoded using the `PolymorphicType` strategy /// @propertyWrapper diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicArrayValueTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicArrayValueTests.swift index 977daf6..669f638 100644 --- a/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicArrayValueTests.swift +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicArrayValueTests.swift @@ -63,7 +63,7 @@ struct OptionalPolymorphicArrayValueTests { title: nil, description: "test", icon: "test_icon" - ), + ) ], notices2: nil ) @@ -76,8 +76,7 @@ struct OptionalPolymorphicArrayValueTests { "icon" : "test_icon", "type" : "callout" } - ], - "notices2" : null + ] } """# @@ -86,8 +85,8 @@ struct OptionalPolymorphicArrayValueTests { encoder.outputFormatting = [.prettyPrinted, .sortedKeys] let data = try encoder.encode(response) - // then - let jsonString = String(decoding: data, as: UTF8.self) + // then - notices2 (nil) is omitted, matching Apple's default Codable behavior + let jsonString = try #require(String(bytes: data, encoding: .utf8)) #expect(jsonString == expectResult) } @@ -169,7 +168,7 @@ struct OptionalPolymorphicArrayValueTests { { "notices1" : { "description" : "test", - "icon" : "test_icon", + "icon" : "test_icon", "type" : "callout" } } @@ -194,14 +193,9 @@ struct OptionalPolymorphicArrayValueTests { encoder.outputFormatting = [.prettyPrinted, .sortedKeys] let data = try encoder.encode(response) - // then - verify encoded JSON - let expectResult = #""" - { - "notices1" : null, - "notices2" : null - } - """# - let jsonString = String(decoding: data, as: UTF8.self) + // then - verify encoded JSON (all nil values are omitted, producing an empty object) + let expectResult = "{\n\n}" + let jsonString = try #require(String(bytes: data, encoding: .utf8)) #expect(jsonString == expectResult) // when - decode back @@ -211,4 +205,24 @@ struct OptionalPolymorphicArrayValueTests { #expect(decodedResponse.notices1 == nil) #expect(decodedResponse.notices2 == nil) } + + @Test + func encodingEmptyArrayIsKeptNotOmitted() throws { + // given - an empty array ([]) is non-nil and must be kept, not omitted like nil + let response = OptionalPolymorphicArrayDummyResponse(notices1: [], notices2: nil) + + // when + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let data = try encoder.encode(response) + + // then - notices1 stays as [], only notices2 (nil) is omitted + let jsonString = try #require(String(bytes: data, encoding: .utf8)) + #expect(jsonString == #"{"notices1":[]}"#) + + // round-trip - [] preserved, nil restored + let decoded = try JSONDecoder().decode(OptionalPolymorphicArrayDummyResponse.self, from: data) + #expect(decoded.notices1?.isEmpty == true) + #expect(decoded.notices2 == nil) + } } From 2264d1955ab108d1d1c3b747b46397a8d7380414 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Fri, 26 Jun 2026 17:12:40 +0900 Subject: [PATCH 4/8] feat: omit nil OptionalPolymorphicLossyArrayValue field on encode Add the nil-omission encoding overload for the optional lossy array wrapper. Empty arrays are preserved; only nil omits the key. Also update the stale doc and wrap two over-long doc lines. Claude-Session: https://claude.ai/code/session_01BSXaRrJaLrjWQaiJSMy45Q --- ...r+OptionalPolymorphicLossyArrayValue.swift | 24 ++++++++ .../OptionalPolymorphicLossyArrayValue.swift | 58 ++++++++++--------- ...ionalPolymorphicLossyArrayValueTests.swift | 33 +++++++++-- 3 files changed, 82 insertions(+), 33 deletions(-) create mode 100644 Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedEncodingContainer+OptionalPolymorphicLossyArrayValue.swift diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedEncodingContainer+OptionalPolymorphicLossyArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedEncodingContainer+OptionalPolymorphicLossyArrayValue.swift new file mode 100644 index 0000000..59d6249 --- /dev/null +++ b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedEncodingContainer+OptionalPolymorphicLossyArrayValue.swift @@ -0,0 +1,24 @@ +// +// KeyedEncodingContainer+OptionalPolymorphicLossyArrayValue.swift +// KarrotCodableKit +// +// Created by Elon on 6/26/26. +// Copyright © 2026 Danggeun Market Inc. All rights reserved. +// + +import Foundation + +extension KeyedEncodingContainer { + /// Encodes an `OptionalPolymorphicLossyArrayValue`, omitting the key entirely when the wrapped array is `nil`. + /// + /// This mirrors Apple's default `Codable` behavior for optional properties, where a `nil` value + /// results in the key being skipped rather than encoded as an explicit `null`. It is the encoding-side + /// counterpart to the `decode(_:forKey:)` overload that treats a missing key as `nil`. + public mutating func encode( + _ value: OptionalPolymorphicLossyArrayValue, + forKey key: Key + ) throws where T: PolymorphicCodableStrategy { + guard value.wrappedValue != nil else { return } + try value.encode(to: superEncoder(forKey: key)) + } +} diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicLossyArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicLossyArrayValue.swift index 0588cb3..f1ecd87 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicLossyArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicLossyArrayValue.swift @@ -8,7 +8,8 @@ import Foundation -/// A property wrapper that decodes an optional array of polymorphic objects with lossy behavior for individual elements. +/// A property wrapper that decodes an optional array of polymorphic objects with lossy behavior +/// for individual elements. /// /// This wrapper combines the optionality handling of ``OptionalPolymorphicArrayValue`` with /// the lossy element decoding of ``PolymorphicLossyArrayValue``. @@ -20,7 +21,8 @@ import Foundation /// Comparison with similar wrappers: /// - ``PolymorphicLossyArrayValue``: For required arrays that default to `[]` when missing or null /// - ``OptionalPolymorphicArrayValue``: For optional arrays that throw on invalid elements -/// - ``DefaultEmptyPolymorphicArrayValue``: For required arrays that default to `[]` when missing or null, strict on elements +/// - ``DefaultEmptyPolymorphicArrayValue``: For required arrays that default to `[]` when missing or null, +/// strict on elements /// /// Decoding behavior: /// - If the key is missing or the value is `null`, `wrappedValue` is set to `nil` @@ -29,7 +31,7 @@ import Foundation /// - Empty arrays are decoded as empty arrays, not `nil` /// /// Encoding behavior: -/// - If `wrappedValue` is `nil`, encodes as `null` +/// - If `wrappedValue` is `nil`, the key is omitted (a `null` is only produced inside an unkeyed container) /// - If `wrappedValue` contains an array, each element is encoded using the `PolymorphicType` strategy /// @propertyWrapper @@ -42,40 +44,40 @@ public struct OptionalPolymorphicLossyArrayValue] + /// Results of decoding each element in the array (DEBUG only) + let results: [Result] #endif public init(wrappedValue: [PolymorphicType.ExpectedType]?) { self.wrappedValue = wrappedValue outcome = .decodedSuccessfully #if DEBUG - results = [] + results = [] #endif } #if DEBUG - init( - wrappedValue: [PolymorphicType.ExpectedType]?, - outcome: ResilientDecodingOutcome, - results: [Result] = [] - ) { - self.wrappedValue = wrappedValue - self.outcome = outcome - self.results = results - } + init( + wrappedValue: [PolymorphicType.ExpectedType]?, + outcome: ResilientDecodingOutcome, + results: [Result] = [] + ) { + self.wrappedValue = wrappedValue + self.outcome = outcome + self.results = results + } #else - init(wrappedValue: [PolymorphicType.ExpectedType]?, outcome: ResilientDecodingOutcome) { - self.wrappedValue = wrappedValue - self.outcome = outcome - } + init(wrappedValue: [PolymorphicType.ExpectedType]?, outcome: ResilientDecodingOutcome) { + self.wrappedValue = wrappedValue + self.outcome = outcome + } #endif #if DEBUG - /// The projected value providing access to decoding outcome - public var projectedValue: PolymorphicLossyArrayProjectedValue { - PolymorphicLossyArrayProjectedValue(outcome: outcome, results: results) - } + /// The projected value providing access to decoding outcome + public var projectedValue: PolymorphicLossyArrayProjectedValue { + PolymorphicLossyArrayProjectedValue(outcome: outcome, results: results) + } #endif } @@ -95,7 +97,7 @@ extension OptionalPolymorphicLossyArrayValue: Decodable { var elements = [PolymorphicType.ExpectedType]() #if DEBUG - var results = [Result]() + var results = [Result]() #endif while !container.isAtEnd { @@ -103,21 +105,21 @@ extension OptionalPolymorphicLossyArrayValue: Decodable { let value = try container.decode(PolymorphicValue.self).wrappedValue elements.append(value) #if DEBUG - results.append(.success(value)) + results.append(.success(value)) #endif } catch { // Decoding processing to prevent infinite loops if decoding fails. _ = try? container.decode(AnyDecodableValue.self) #if DEBUG - results.append(.failure(error)) + results.append(.failure(error)) #endif } } #if DEBUG - self.init(wrappedValue: elements, outcome: .decodedSuccessfully, results: results) + self.init(wrappedValue: elements, outcome: .decodedSuccessfully, results: results) #else - self.init(wrappedValue: elements, outcome: .decodedSuccessfully) + self.init(wrappedValue: elements, outcome: .decodedSuccessfully) #endif } } diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicLossyArrayValueTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicLossyArrayValueTests.swift index 42f17ce..d6a1b1b 100644 --- a/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicLossyArrayValueTests.swift +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicLossyArrayValueTests.swift @@ -196,7 +196,7 @@ struct OptionalPolymorphicLossyArrayValueTests { title: nil, description: "test", icon: "test_icon" - ), + ) ], notices2: nil ) @@ -209,8 +209,7 @@ struct OptionalPolymorphicLossyArrayValueTests { "icon" : "test_icon", "type" : "callout" } - ], - "notices2" : null + ] } """# @@ -219,8 +218,8 @@ struct OptionalPolymorphicLossyArrayValueTests { encoder.outputFormatting = [.prettyPrinted, .sortedKeys] let data = try encoder.encode(response) - // then - let jsonString = String(decoding: data, as: UTF8.self) + // then - notices2 (nil) is omitted, matching Apple's default Codable behavior + let jsonString = try #require(String(bytes: data, encoding: .utf8)) #expect(jsonString == expectResult) } @@ -237,6 +236,10 @@ struct OptionalPolymorphicLossyArrayValueTests { encoder.outputFormatting = [.prettyPrinted, .sortedKeys] let data = try encoder.encode(response) + // then - all nil values are omitted, producing an empty object + let jsonString = try #require(String(bytes: data, encoding: .utf8)) + #expect(jsonString == "{\n\n}") + // when - decode back let decodedResponse = try JSONDecoder().decode( OptionalPolymorphicLossyArrayDummyResponse.self, @@ -247,4 +250,24 @@ struct OptionalPolymorphicLossyArrayValueTests { #expect(decodedResponse.notices1 == nil) #expect(decodedResponse.notices2 == nil) } + + @Test + func encodingEmptyArrayIsKeptNotOmitted() throws { + // given - an empty array ([]) is non-nil and must be kept, not omitted like nil + let response = OptionalPolymorphicLossyArrayDummyResponse(notices1: [], notices2: nil) + + // when + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let data = try encoder.encode(response) + + // then - notices1 stays as [], only notices2 (nil) is omitted + let jsonString = try #require(String(bytes: data, encoding: .utf8)) + #expect(jsonString == #"{"notices1":[]}"#) + + // round-trip - [] preserved, nil restored + let decoded = try JSONDecoder().decode(OptionalPolymorphicLossyArrayDummyResponse.self, from: data) + #expect(decoded.notices1?.isEmpty == true) + #expect(decoded.notices2 == nil) + } } From b5885a2d98cfb1c18615ae6a7289b43bcd3e0188 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Fri, 26 Jun 2026 17:12:54 +0900 Subject: [PATCH 5/8] feat: omit nil OptionalDateValue field on encode Add the nil-omission encoding overload for OptionalDateValue (BetterCodable), consistent with the polymorphic optional wrappers. Claude-Session: https://claude.ai/code/session_01BSXaRrJaLrjWQaiJSMy45Q --- ...dEncodingContainer+OptionalDateValue.swift | 24 +++++++ .../OptionalDateValueOmitNilTests.swift | 66 +++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 Sources/KarrotCodableKit/BetterCodable/DateValue/KeyedEncodingContainer+OptionalDateValue.swift create mode 100644 Tests/KarrotCodableKitTests/BetterCodable/DateValue/OptionalDateValueOmitNilTests.swift diff --git a/Sources/KarrotCodableKit/BetterCodable/DateValue/KeyedEncodingContainer+OptionalDateValue.swift b/Sources/KarrotCodableKit/BetterCodable/DateValue/KeyedEncodingContainer+OptionalDateValue.swift new file mode 100644 index 0000000..72ba8fd --- /dev/null +++ b/Sources/KarrotCodableKit/BetterCodable/DateValue/KeyedEncodingContainer+OptionalDateValue.swift @@ -0,0 +1,24 @@ +// +// KeyedEncodingContainer+OptionalDateValue.swift +// KarrotCodableKit +// +// Created by Elon on 6/26/26. +// Copyright © 2026 Danggeun Market Inc. All rights reserved. +// + +import Foundation + +extension KeyedEncodingContainer { + /// Encodes an `OptionalDateValue`, omitting the key entirely when the wrapped value is `nil`. + /// + /// This mirrors Apple's default `Codable` behavior for optional properties, where a `nil` value + /// results in the key being skipped rather than encoded as an explicit `null`. It is the encoding-side + /// counterpart to the `decode(_:forKey:)` overload that treats a missing key as `nil`. + public mutating func encode( + _ value: OptionalDateValue, + forKey key: Key + ) throws where T.RawValue: Encodable { + guard value.wrappedValue != nil else { return } + try value.encode(to: superEncoder(forKey: key)) + } +} diff --git a/Tests/KarrotCodableKitTests/BetterCodable/DateValue/OptionalDateValueOmitNilTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/DateValue/OptionalDateValueOmitNilTests.swift new file mode 100644 index 0000000..5c5f951 --- /dev/null +++ b/Tests/KarrotCodableKitTests/BetterCodable/DateValue/OptionalDateValueOmitNilTests.swift @@ -0,0 +1,66 @@ +// +// OptionalDateValueOmitNilTests.swift +// KarrotCodableKit +// +// Created by Elon on 6/26/26. +// Copyright © 2026 Danggeun Market Inc. All rights reserved. +// + +import Foundation +import Testing + +import KarrotCodableKit + +struct OptionalDateValueOmitNilTests { + private struct Fixture: Codable { + @OptionalDateValue var iso8601: Date? + } + + @Test + func encodingNilOmitsKey() throws { + // given + let fixture = Fixture(iso8601: nil) + + // when + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(fixture) + + // then - nil value is omitted, matching Apple's default Codable behavior + let jsonString = try #require(String(bytes: data, encoding: .utf8)) + #expect(jsonString == "{\n\n}") + } + + @Test + func encodingValuePreservesKey() throws { + // given + let fixture = Fixture(iso8601: Date(timeIntervalSince1970: 851042397)) + + // when + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(fixture) + + // then - a present value is still encoded under its key + let expectResult = #""" + { + "iso8601" : "1996-12-20T00:39:57Z" + } + """# + let jsonString = try #require(String(bytes: data, encoding: .utf8)) + #expect(jsonString == expectResult) + } + + @Test + func encodingDecodingNilRoundTrip() throws { + // given + let fixture = Fixture(iso8601: nil) + + // when + let data = try JSONEncoder().encode(fixture) + let decoded = try JSONDecoder().decode(Fixture.self, from: data) + + // then - nil is restored from the omitted key + #expect(decoded.iso8601 == nil) + } +} From f6ef6390e621d8f6187b224472495462c8f9e213 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Fri, 26 Jun 2026 17:12:54 +0900 Subject: [PATCH 6/8] feat: omit nil OptionalLosslessValue field on encode Add the nil-omission encoding overload for OptionalLosslessValue (BetterCodable). Claude-Session: https://claude.ai/code/session_01BSXaRrJaLrjWQaiJSMy45Q --- ...odingContainer+OptionalLosslessValue.swift | 24 +++++++ .../OptionalLosslessValueOmitNilTests.swift | 68 +++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 Sources/KarrotCodableKit/BetterCodable/LosslessValue/KeyedEncodingContainer+OptionalLosslessValue.swift create mode 100644 Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/OptionalLosslessValueOmitNilTests.swift diff --git a/Sources/KarrotCodableKit/BetterCodable/LosslessValue/KeyedEncodingContainer+OptionalLosslessValue.swift b/Sources/KarrotCodableKit/BetterCodable/LosslessValue/KeyedEncodingContainer+OptionalLosslessValue.swift new file mode 100644 index 0000000..726b3a6 --- /dev/null +++ b/Sources/KarrotCodableKit/BetterCodable/LosslessValue/KeyedEncodingContainer+OptionalLosslessValue.swift @@ -0,0 +1,24 @@ +// +// KeyedEncodingContainer+OptionalLosslessValue.swift +// KarrotCodableKit +// +// Created by Elon on 6/26/26. +// Copyright © 2026 Danggeun Market Inc. All rights reserved. +// + +import Foundation + +extension KeyedEncodingContainer { + /// Encodes an `OptionalLosslessValueCodable`, omitting the key entirely when the wrapped value is `nil`. + /// + /// This mirrors Apple's default `Codable` behavior for optional properties, where a `nil` value + /// results in the key being skipped rather than encoded as an explicit `null`. It is the encoding-side + /// counterpart to the `decode(_:forKey:)` overload that treats a missing key as `nil`. + public mutating func encode( + _ value: OptionalLosslessValueCodable, + forKey key: Key + ) throws where T: LosslessDecodingStrategy { + guard value.wrappedValue != nil else { return } + try value.encode(to: superEncoder(forKey: key)) + } +} diff --git a/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/OptionalLosslessValueOmitNilTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/OptionalLosslessValueOmitNilTests.swift new file mode 100644 index 0000000..65d89bc --- /dev/null +++ b/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/OptionalLosslessValueOmitNilTests.swift @@ -0,0 +1,68 @@ +// +// OptionalLosslessValueOmitNilTests.swift +// KarrotCodableKit +// +// Created by Elon on 6/26/26. +// Copyright © 2026 Danggeun Market Inc. All rights reserved. +// + +import Foundation +import Testing + +import KarrotCodableKit + +struct OptionalLosslessValueOmitNilTests { + private struct Fixture: Codable { + @OptionalLosslessValue var optionalString: String? + @OptionalLosslessValue var optionalInt: Int? + } + + @Test + func encodingNilOmitsKey() throws { + // given + let fixture = Fixture(optionalString: nil, optionalInt: nil) + + // when + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(fixture) + + // then - all nil values are omitted, producing an empty object + let jsonString = try #require(String(bytes: data, encoding: .utf8)) + #expect(jsonString == "{\n\n}") + } + + @Test + func encodingPartialNilOmitsOnlyNilKeys() throws { + // given + let fixture = Fixture(optionalString: "hello", optionalInt: nil) + + // when + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(fixture) + + // then - only optionalInt (nil) is omitted + let expectResult = #""" + { + "optionalString" : "hello" + } + """# + let jsonString = try #require(String(bytes: data, encoding: .utf8)) + #expect(jsonString == expectResult) + } + + @Test + func encodingDecodingNilRoundTrip() throws { + // given + let fixture = Fixture(optionalString: nil, optionalInt: nil) + + // when + let data = try JSONEncoder().encode(fixture) + let decoded = try JSONDecoder().decode(Fixture.self, from: data) + + // then - nil values are restored from the omitted keys + #expect(decoded.optionalString == nil) + #expect(decoded.optionalInt == nil) + } +} From 59e0f9f41fadf52ded4604a4fa658f00fb3aed94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Fri, 26 Jun 2026 17:12:55 +0900 Subject: [PATCH 7/8] feat: omit nil LossyOptional field on encode Add the nil-omission encoding overload for LossyOptional. Constrained to DefaultCodable so other DefaultCodable wrappers (DefaultFalse, DefaultEmptyArray, ...) keep encoding their non-optional default value. Claude-Session: https://claude.ai/code/session_01BSXaRrJaLrjWQaiJSMy45Q --- ...KeyedEncodingContainer+LossyOptional.swift | 28 ++++++++ .../LossyOptionalOmitNilTests.swift | 68 +++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 Sources/KarrotCodableKit/BetterCodable/LossyValue/KeyedEncodingContainer+LossyOptional.swift create mode 100644 Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyOptionalOmitNilTests.swift diff --git a/Sources/KarrotCodableKit/BetterCodable/LossyValue/KeyedEncodingContainer+LossyOptional.swift b/Sources/KarrotCodableKit/BetterCodable/LossyValue/KeyedEncodingContainer+LossyOptional.swift new file mode 100644 index 0000000..5ad2491 --- /dev/null +++ b/Sources/KarrotCodableKit/BetterCodable/LossyValue/KeyedEncodingContainer+LossyOptional.swift @@ -0,0 +1,28 @@ +// +// KeyedEncodingContainer+LossyOptional.swift +// KarrotCodableKit +// +// Created by Elon on 6/26/26. +// Copyright © 2026 Danggeun Market Inc. All rights reserved. +// + +import Foundation + +extension KeyedEncodingContainer { + /// Encodes a `@LossyOptional` value, omitting the key entirely when the wrapped value is `nil`. + /// + /// `LossyOptional` is a `DefaultCodable` alias, so this overload is constrained to + /// `DefaultNilStrategy` to target only the optional case. Other `DefaultCodable` wrappers + /// (`@DefaultFalse`, `@DefaultEmptyArray`, ...) keep encoding their non-optional default value. + /// + /// This mirrors Apple's default `Codable` behavior for optional properties, where a `nil` value + /// results in the key being skipped rather than encoded as an explicit `null`. It is the encoding-side + /// counterpart to the `decode(_:forKey:)` overload that treats a missing key as the default `nil`. + public mutating func encode( + _ value: DefaultCodable>, + forKey key: Key + ) throws where T: Encodable { + guard value.wrappedValue != nil else { return } + try value.encode(to: superEncoder(forKey: key)) + } +} diff --git a/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyOptionalOmitNilTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyOptionalOmitNilTests.swift new file mode 100644 index 0000000..eea9506 --- /dev/null +++ b/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyOptionalOmitNilTests.swift @@ -0,0 +1,68 @@ +// +// LossyOptionalOmitNilTests.swift +// KarrotCodableKit +// +// Created by Elon on 6/26/26. +// Copyright © 2026 Danggeun Market Inc. All rights reserved. +// + +import Foundation +import Testing + +import KarrotCodableKit + +struct LossyOptionalOmitNilTests { + private struct Fixture: Codable { + @LossyOptional var url: URL? + @LossyOptional var name: String? + } + + @Test + func encodingNilOmitsKey() throws { + // given + let fixture = Fixture(url: nil, name: nil) + + // when + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(fixture) + + // then - all nil values are omitted, producing an empty object + let json = try #require(String(bytes: data, encoding: .utf8)) + #expect(json == "{\n\n}") + } + + @Test + func encodingPartialNilOmitsOnlyNilKeys() throws { + // given + let fixture = Fixture(url: nil, name: "hello") + + // when + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(fixture) + + // then - only url (nil) is omitted + let expectResult = #""" + { + "name" : "hello" + } + """# + let json = try #require(String(bytes: data, encoding: .utf8)) + #expect(json == expectResult) + } + + @Test + func encodingDecodingNilRoundTrip() throws { + // given + let fixture = Fixture(url: nil, name: nil) + + // when + let data = try JSONEncoder().encode(fixture) + let decoded = try JSONDecoder().decode(Fixture.self, from: data) + + // then - nil values are restored from the omitted keys + #expect(decoded.url == nil) + #expect(decoded.name == nil) + } +} From 8315fbc5b3a0efcc356f3447f72478763b62724c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Fri, 26 Jun 2026 17:34:04 +0900 Subject: [PATCH 8/8] test: add unkeyed nil -> null regression for OptionalPolymorphicArrayValue In an unkeyed container (array element) there is no key to omit, so a nil wrapper is encoded as an explicit null, matching Apple's [T?] behavior. This locks down the wrapper's encode(to:) path that the keyed encode(_:forKey:) overload does not exercise. Claude-Session: https://claude.ai/code/session_01BSXaRrJaLrjWQaiJSMy45Q --- .../OptionalPolymorphicArrayValueTests.swift | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicArrayValueTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicArrayValueTests.swift index 669f638..213ea8c 100644 --- a/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicArrayValueTests.swift +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicArrayValueTests.swift @@ -225,4 +225,25 @@ struct OptionalPolymorphicArrayValueTests { #expect(decoded.notices1?.isEmpty == true) #expect(decoded.notices2 == nil) } + + @Test + func encodingNilInUnkeyedContextProducesNull() throws { + // given - in an unkeyed container (array element) there is no key to omit, + // so a nil wrapper is encoded as an explicit null (matching Apple's `[T?]` behavior) + let elements: [DummyNotice.OptionalPolymorphicArray] = [ + DummyNotice.OptionalPolymorphicArray(wrappedValue: nil) + ] + + // when + let data = try JSONEncoder().encode(elements) + + // then - the nil element is encoded as null, not omitted + let jsonString = try #require(String(bytes: data, encoding: .utf8)) + #expect(jsonString == "[null]") + + // round-trip - [null] decodes back to a single nil-wrapped element + let decoded = try JSONDecoder().decode([DummyNotice.OptionalPolymorphicArray].self, from: data) + #expect(decoded.count == 1) + #expect(decoded[0].wrappedValue == nil) + } }