Skip to content

Commit 088c813

Browse files
authored
Merge pull request #23 from daangn/feature/elon/IOS-4537-omit-nil-field-on-encode
feat: omit nil optional fields on encode (IOS-4537)
2 parents 4aade81 + 8315fbc commit 088c813

16 files changed

Lines changed: 544 additions & 60 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//
2+
// KeyedEncodingContainer+OptionalDateValue.swift
3+
// KarrotCodableKit
4+
//
5+
// Created by Elon on 6/26/26.
6+
// Copyright © 2026 Danggeun Market Inc. All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
extension KeyedEncodingContainer {
12+
/// Encodes an `OptionalDateValue`, omitting the key entirely when the wrapped value is `nil`.
13+
///
14+
/// This mirrors Apple's default `Codable` behavior for optional properties, where a `nil` value
15+
/// results in the key being skipped rather than encoded as an explicit `null`. It is the encoding-side
16+
/// counterpart to the `decode(_:forKey:)` overload that treats a missing key as `nil`.
17+
public mutating func encode<T>(
18+
_ value: OptionalDateValue<T>,
19+
forKey key: Key
20+
) throws where T.RawValue: Encodable {
21+
guard value.wrappedValue != nil else { return }
22+
try value.encode(to: superEncoder(forKey: key))
23+
}
24+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//
2+
// KeyedEncodingContainer+OptionalLosslessValue.swift
3+
// KarrotCodableKit
4+
//
5+
// Created by Elon on 6/26/26.
6+
// Copyright © 2026 Danggeun Market Inc. All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
extension KeyedEncodingContainer {
12+
/// Encodes an `OptionalLosslessValueCodable`, omitting the key entirely when the wrapped value is `nil`.
13+
///
14+
/// This mirrors Apple's default `Codable` behavior for optional properties, where a `nil` value
15+
/// results in the key being skipped rather than encoded as an explicit `null`. It is the encoding-side
16+
/// counterpart to the `decode(_:forKey:)` overload that treats a missing key as `nil`.
17+
public mutating func encode<T>(
18+
_ value: OptionalLosslessValueCodable<T>,
19+
forKey key: Key
20+
) throws where T: LosslessDecodingStrategy {
21+
guard value.wrappedValue != nil else { return }
22+
try value.encode(to: superEncoder(forKey: key))
23+
}
24+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
//
2+
// KeyedEncodingContainer+LossyOptional.swift
3+
// KarrotCodableKit
4+
//
5+
// Created by Elon on 6/26/26.
6+
// Copyright © 2026 Danggeun Market Inc. All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
extension KeyedEncodingContainer {
12+
/// Encodes a `@LossyOptional` value, omitting the key entirely when the wrapped value is `nil`.
13+
///
14+
/// `LossyOptional` is a `DefaultCodable<DefaultNilStrategy>` alias, so this overload is constrained to
15+
/// `DefaultNilStrategy` to target only the optional case. Other `DefaultCodable` wrappers
16+
/// (`@DefaultFalse`, `@DefaultEmptyArray`, ...) keep encoding their non-optional default value.
17+
///
18+
/// This mirrors Apple's default `Codable` behavior for optional properties, where a `nil` value
19+
/// results in the key being skipped rather than encoded as an explicit `null`. It is the encoding-side
20+
/// counterpart to the `decode(_:forKey:)` overload that treats a missing key as the default `nil`.
21+
public mutating func encode<T>(
22+
_ value: DefaultCodable<DefaultNilStrategy<T>>,
23+
forKey key: Key
24+
) throws where T: Encodable {
25+
guard value.wrappedValue != nil else { return }
26+
try value.encode(to: superEncoder(forKey: key))
27+
}
28+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//
2+
// KeyedEncodingContainer+LossyOptionalPolymorphicValue.swift
3+
// KarrotCodableKit
4+
//
5+
// Created by Elon on 6/26/26.
6+
// Copyright © 2026 Danggeun Market Inc. All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
extension KeyedEncodingContainer {
12+
/// Encodes a `LossyOptionalPolymorphicValue`, omitting the key entirely when the wrapped value is `nil`.
13+
///
14+
/// This mirrors Apple's default `Codable` behavior for optional properties, where a `nil` value
15+
/// results in the key being skipped rather than encoded as an explicit `null`. It is the encoding-side
16+
/// counterpart to the `decode(_:forKey:)` overload that treats a missing key as `nil`.
17+
public mutating func encode<T>(
18+
_ value: LossyOptionalPolymorphicValue<T>,
19+
forKey key: Key
20+
) throws where T: PolymorphicCodableStrategy {
21+
guard value.wrappedValue != nil else { return }
22+
try value.encode(to: superEncoder(forKey: key))
23+
}
24+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//
2+
// KeyedEncodingContainer+OptionalPolymorphicArrayValue.swift
3+
// KarrotCodableKit
4+
//
5+
// Created by Elon on 6/26/26.
6+
// Copyright © 2026 Danggeun Market Inc. All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
extension KeyedEncodingContainer {
12+
/// Encodes an `OptionalPolymorphicArrayValue`, omitting the key entirely when the wrapped array is `nil`.
13+
///
14+
/// This mirrors Apple's default `Codable` behavior for optional properties, where a `nil` value
15+
/// results in the key being skipped rather than encoded as an explicit `null`. It is the encoding-side
16+
/// counterpart to the `decode(_:forKey:)` overload that treats a missing key as `nil`.
17+
public mutating func encode<T>(
18+
_ value: OptionalPolymorphicArrayValue<T>,
19+
forKey key: Key
20+
) throws where T: PolymorphicCodableStrategy {
21+
guard value.wrappedValue != nil else { return }
22+
try value.encode(to: superEncoder(forKey: key))
23+
}
24+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//
2+
// KeyedEncodingContainer+OptionalPolymorphicLossyArrayValue.swift
3+
// KarrotCodableKit
4+
//
5+
// Created by Elon on 6/26/26.
6+
// Copyright © 2026 Danggeun Market Inc. All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
extension KeyedEncodingContainer {
12+
/// Encodes an `OptionalPolymorphicLossyArrayValue`, omitting the key entirely when the wrapped array is `nil`.
13+
///
14+
/// This mirrors Apple's default `Codable` behavior for optional properties, where a `nil` value
15+
/// results in the key being skipped rather than encoded as an explicit `null`. It is the encoding-side
16+
/// counterpart to the `decode(_:forKey:)` overload that treats a missing key as `nil`.
17+
public mutating func encode<T>(
18+
_ value: OptionalPolymorphicLossyArrayValue<T>,
19+
forKey key: Key
20+
) throws where T: PolymorphicCodableStrategy {
21+
guard value.wrappedValue != nil else { return }
22+
try value.encode(to: superEncoder(forKey: key))
23+
}
24+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//
2+
// KeyedEncodingContainer+OptionalPolymorphicValue.swift
3+
// KarrotCodableKit
4+
//
5+
// Created by Elon on 6/26/26.
6+
// Copyright © 2026 Danggeun Market Inc. All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
extension KeyedEncodingContainer {
12+
/// Encodes an `OptionalPolymorphicValue`, omitting the key entirely when the wrapped value is `nil`.
13+
///
14+
/// This mirrors Apple's default `Codable` behavior for optional properties, where a `nil` value
15+
/// results in the key being skipped rather than encoded as an explicit `null`. It is the encoding-side
16+
/// counterpart to the `decode(_:forKey:)` overload that treats a missing key as `nil`.
17+
public mutating func encode<T>(
18+
_ value: OptionalPolymorphicValue<T>,
19+
forKey key: Key
20+
) throws where T: PolymorphicCodableStrategy {
21+
guard value.wrappedValue != nil else { return }
22+
try value.encode(to: superEncoder(forKey: key))
23+
}
24+
}

Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicArrayValue.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import Foundation
3030
/// - Empty arrays are decoded as empty arrays, not `nil`
3131
///
3232
/// Encoding behavior:
33-
/// - If `wrappedValue` is `nil`, encodes as `null`
33+
/// - If `wrappedValue` is `nil`, the key is omitted (a `null` is only produced inside an unkeyed container)
3434
/// - If `wrappedValue` contains an array, each element is encoded using the `PolymorphicType` strategy
3535
///
3636
@propertyWrapper

Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicLossyArrayValue.swift

Lines changed: 30 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88

99
import Foundation
1010

11-
/// A property wrapper that decodes an optional array of polymorphic objects with lossy behavior for individual elements.
11+
/// A property wrapper that decodes an optional array of polymorphic objects with lossy behavior
12+
/// for individual elements.
1213
///
1314
/// This wrapper combines the optionality handling of ``OptionalPolymorphicArrayValue`` with
1415
/// the lossy element decoding of ``PolymorphicLossyArrayValue``.
@@ -20,7 +21,8 @@ import Foundation
2021
/// Comparison with similar wrappers:
2122
/// - ``PolymorphicLossyArrayValue``: For required arrays that default to `[]` when missing or null
2223
/// - ``OptionalPolymorphicArrayValue``: For optional arrays that throw on invalid elements
23-
/// - ``DefaultEmptyPolymorphicArrayValue``: For required arrays that default to `[]` when missing or null, strict on elements
24+
/// - ``DefaultEmptyPolymorphicArrayValue``: For required arrays that default to `[]` when missing or null,
25+
/// strict on elements
2426
///
2527
/// Decoding behavior:
2628
/// - If the key is missing or the value is `null`, `wrappedValue` is set to `nil`
@@ -29,7 +31,7 @@ import Foundation
2931
/// - Empty arrays are decoded as empty arrays, not `nil`
3032
///
3133
/// Encoding behavior:
32-
/// - If `wrappedValue` is `nil`, encodes as `null`
34+
/// - If `wrappedValue` is `nil`, the key is omitted (a `null` is only produced inside an unkeyed container)
3335
/// - If `wrappedValue` contains an array, each element is encoded using the `PolymorphicType` strategy
3436
///
3537
@propertyWrapper
@@ -42,40 +44,40 @@ public struct OptionalPolymorphicLossyArrayValue<PolymorphicType: PolymorphicCod
4244
public let outcome: ResilientDecodingOutcome
4345

4446
#if DEBUG
45-
/// Results of decoding each element in the array (DEBUG only)
46-
let results: [Result<PolymorphicType.ExpectedType, Error>]
47+
/// Results of decoding each element in the array (DEBUG only)
48+
let results: [Result<PolymorphicType.ExpectedType, Error>]
4749
#endif
4850

4951
public init(wrappedValue: [PolymorphicType.ExpectedType]?) {
5052
self.wrappedValue = wrappedValue
5153
outcome = .decodedSuccessfully
5254
#if DEBUG
53-
results = []
55+
results = []
5456
#endif
5557
}
5658

5759
#if DEBUG
58-
init(
59-
wrappedValue: [PolymorphicType.ExpectedType]?,
60-
outcome: ResilientDecodingOutcome,
61-
results: [Result<PolymorphicType.ExpectedType, Error>] = []
62-
) {
63-
self.wrappedValue = wrappedValue
64-
self.outcome = outcome
65-
self.results = results
66-
}
60+
init(
61+
wrappedValue: [PolymorphicType.ExpectedType]?,
62+
outcome: ResilientDecodingOutcome,
63+
results: [Result<PolymorphicType.ExpectedType, Error>] = []
64+
) {
65+
self.wrappedValue = wrappedValue
66+
self.outcome = outcome
67+
self.results = results
68+
}
6769
#else
68-
init(wrappedValue: [PolymorphicType.ExpectedType]?, outcome: ResilientDecodingOutcome) {
69-
self.wrappedValue = wrappedValue
70-
self.outcome = outcome
71-
}
70+
init(wrappedValue: [PolymorphicType.ExpectedType]?, outcome: ResilientDecodingOutcome) {
71+
self.wrappedValue = wrappedValue
72+
self.outcome = outcome
73+
}
7274
#endif
7375

7476
#if DEBUG
75-
/// The projected value providing access to decoding outcome
76-
public var projectedValue: PolymorphicLossyArrayProjectedValue<PolymorphicType.ExpectedType> {
77-
PolymorphicLossyArrayProjectedValue(outcome: outcome, results: results)
78-
}
77+
/// The projected value providing access to decoding outcome
78+
public var projectedValue: PolymorphicLossyArrayProjectedValue<PolymorphicType.ExpectedType> {
79+
PolymorphicLossyArrayProjectedValue(outcome: outcome, results: results)
80+
}
7981
#endif
8082
}
8183

@@ -95,29 +97,29 @@ extension OptionalPolymorphicLossyArrayValue: Decodable {
9597

9698
var elements = [PolymorphicType.ExpectedType]()
9799
#if DEBUG
98-
var results = [Result<PolymorphicType.ExpectedType, Error>]()
100+
var results = [Result<PolymorphicType.ExpectedType, Error>]()
99101
#endif
100102

101103
while !container.isAtEnd {
102104
do {
103105
let value = try container.decode(PolymorphicValue<PolymorphicType>.self).wrappedValue
104106
elements.append(value)
105107
#if DEBUG
106-
results.append(.success(value))
108+
results.append(.success(value))
107109
#endif
108110
} catch {
109111
// Decoding processing to prevent infinite loops if decoding fails.
110112
_ = try? container.decode(AnyDecodableValue.self)
111113
#if DEBUG
112-
results.append(.failure(error))
114+
results.append(.failure(error))
113115
#endif
114116
}
115117
}
116118

117119
#if DEBUG
118-
self.init(wrappedValue: elements, outcome: .decodedSuccessfully, results: results)
120+
self.init(wrappedValue: elements, outcome: .decodedSuccessfully, results: results)
119121
#else
120-
self.init(wrappedValue: elements, outcome: .decodedSuccessfully)
122+
self.init(wrappedValue: elements, outcome: .decodedSuccessfully)
121123
#endif
122124
}
123125
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
//
2+
// OptionalDateValueOmitNilTests.swift
3+
// KarrotCodableKit
4+
//
5+
// Created by Elon on 6/26/26.
6+
// Copyright © 2026 Danggeun Market Inc. All rights reserved.
7+
//
8+
9+
import Foundation
10+
import Testing
11+
12+
import KarrotCodableKit
13+
14+
struct OptionalDateValueOmitNilTests {
15+
private struct Fixture: Codable {
16+
@OptionalDateValue<ISO8601Strategy> var iso8601: Date?
17+
}
18+
19+
@Test
20+
func encodingNilOmitsKey() throws {
21+
// given
22+
let fixture = Fixture(iso8601: nil)
23+
24+
// when
25+
let encoder = JSONEncoder()
26+
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
27+
let data = try encoder.encode(fixture)
28+
29+
// then - nil value is omitted, matching Apple's default Codable behavior
30+
let jsonString = try #require(String(bytes: data, encoding: .utf8))
31+
#expect(jsonString == "{\n\n}")
32+
}
33+
34+
@Test
35+
func encodingValuePreservesKey() throws {
36+
// given
37+
let fixture = Fixture(iso8601: Date(timeIntervalSince1970: 851042397))
38+
39+
// when
40+
let encoder = JSONEncoder()
41+
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
42+
let data = try encoder.encode(fixture)
43+
44+
// then - a present value is still encoded under its key
45+
let expectResult = #"""
46+
{
47+
"iso8601" : "1996-12-20T00:39:57Z"
48+
}
49+
"""#
50+
let jsonString = try #require(String(bytes: data, encoding: .utf8))
51+
#expect(jsonString == expectResult)
52+
}
53+
54+
@Test
55+
func encodingDecodingNilRoundTrip() throws {
56+
// given
57+
let fixture = Fixture(iso8601: nil)
58+
59+
// when
60+
let data = try JSONEncoder().encode(fixture)
61+
let decoded = try JSONDecoder().decode(Fixture.self, from: data)
62+
63+
// then - nil is restored from the omitted key
64+
#expect(decoded.iso8601 == nil)
65+
}
66+
}

0 commit comments

Comments
 (0)