Skip to content

Commit bb22fc2

Browse files
authored
Merge pull request #33 from daangn/feature/elon/IOS-5184-add-polymorphic-matching-types-providing
feat(polymorphic): expose the matching types of a generated strategy
2 parents 591649f + 3d26a6c commit bb22fc2

7 files changed

Lines changed: 306 additions & 16 deletions

File tree

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,34 @@ struct UndefinedViewItem: ViewItem {
209209
}
210210
```
211211

212+
#### Enumerating a polymorphic family
213+
214+
The generated strategy conforms to `PolymorphicMatchingTypesProviding`, which exposes the family as values. `decode(from:)` reads the very same properties, so the exposed list cannot drift away from what decoding resolves.
215+
216+
```swift
217+
ViewItemCodableStrategy.matchingTypes // [ImageViewItem.self, TextViewItem.self]
218+
ViewItemCodableStrategy.fallbackType // UndefinedViewItem.self
219+
```
220+
221+
Constrain a generic parameter to the protocol when a caller must be handed the production strategy rather than a list assembled by hand — for example, to check that every declared type has a registered handler:
222+
223+
```swift
224+
func assertEveryTypeHasHandler<Strategy: PolymorphicMatchingTypesProviding>(
225+
declaredIn _: Strategy.Type,
226+
registeredIdentifiers: Set<String>,
227+
) {
228+
let declared = Set(Strategy.matchingTypes.map { $0.polymorphicIdentifier })
229+
#expect(declared.subtracting(registeredIdentifiers).isEmpty)
230+
}
231+
232+
assertEveryTypeHasHandler(
233+
declaredIn: ViewItemCodableStrategy.self,
234+
registeredIdentifiers: Set(handlers.keys),
235+
)
236+
```
237+
238+
`PolymorphicMatchingTypesProviding` refines `PolymorphicCodableStrategy` rather than adding requirements to it, so hand-written strategies keep working unchanged and adopt it only when they need to be enumerated.
239+
212240
### PolymorphicEnumCodable
213241

214242
`PolymorphicEnumCodable` provides a convenient way to handle polymorphic types directly in Swift enums. Unlike `PolymorphicCodable` which works with protocol-conforming types, this macro allows you to define an enum where each case contains an associated value of a different type, and enables seamless JSON encoding and decoding.

Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicCodableStrategy.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ import Foundation
6767
///
6868
/// @PolymorphicValue<ViewItemCodableStrategy>
6969
/// var item: ViewItem
70+
/// ```
71+
///
72+
/// - SeeAlso: ``PolymorphicMatchingTypesProviding``, which refines this protocol so the family can be
73+
/// enumerated from the outside. Strategies generated by `@PolymorphicCodableStrategyProviding`
74+
/// conform to it automatically.
7075
public protocol PolymorphicCodableStrategy {
7176
associatedtype ExpectedType
7277
static var polymorphicMetaCodingKey: CodingKey { get }
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
//
2+
// PolymorphicMatchingTypesProviding.swift
3+
// KarrotCodableKit
4+
//
5+
// Created by Elon on 8/4/26.
6+
// Copyright © 2026 Danggeun Market Inc. All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
/// A ``PolymorphicCodableStrategy`` whose polymorphic family can be enumerated from the outside.
12+
///
13+
/// A plain ``PolymorphicCodableStrategy`` keeps its candidate types inside `decode(from:)`, so there is
14+
/// no way to ask which types belong to the family. Conforming types expose that list as a value, which
15+
/// lets callers cross-check the family against something else — for example, asserting in a test that
16+
/// every declared type has a registered handler.
17+
///
18+
/// Strategies generated by `@PolymorphicCodableStrategyProviding` adopt this protocol automatically,
19+
/// and their `decode(from:)` reads ``matchingTypes`` and ``fallbackType`` rather than repeating the
20+
/// list, so the exposed family and the decoded family cannot drift apart. Hand-written strategies
21+
/// adopt it only when they need to be enumerated.
22+
///
23+
/// ```swift
24+
/// func assertEveryTypeHasHandler<Strategy: PolymorphicMatchingTypesProviding>(
25+
/// declaredIn _: Strategy.Type,
26+
/// registeredIdentifiers: Set<String>,
27+
/// ) {
28+
/// let declared = Set(Strategy.matchingTypes.map { $0.polymorphicIdentifier })
29+
/// #expect(declared.subtracting(registeredIdentifiers).isEmpty)
30+
/// }
31+
///
32+
/// assertEveryTypeHasHandler(
33+
/// declaredIn: ViewItemCodableStrategy.self,
34+
/// registeredIdentifiers: Set(handlers.keys),
35+
/// )
36+
/// ```
37+
///
38+
/// - Note: This protocol intentionally refines ``PolymorphicCodableStrategy`` instead of adding
39+
/// requirements to it. A defaulted requirement on the base protocol would let a hand-written strategy
40+
/// report an empty family, and an exhaustiveness check reading that value would pass vacuously.
41+
public protocol PolymorphicMatchingTypesProviding: PolymorphicCodableStrategy {
42+
/// The candidate types matched against the polymorphic identifier, in the order they are matched.
43+
///
44+
/// This is the same list `decode(from:)` uses.
45+
static var matchingTypes: [PolymorphicDecodableType.Type] { get }
46+
47+
/// The type used when the identifier matches none of the ``matchingTypes``.
48+
///
49+
/// `nil` means decoding fails with `PolymorphicCodableError.unableToFindPolymorphicType(_:)`
50+
/// instead of falling back.
51+
static var fallbackType: PolymorphicDecodableType.Type? { get }
52+
}

Sources/KarrotCodableKitMacros/PolymorphicCodableMacros/PolymorphicCodableStrategyMacro.swift

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,15 +79,15 @@ extension PolymorphicCodableStrategyProvidingMacro: PeerMacro {
7979
let strategyStructName = "\(identifier)CodableStrategy"
8080

8181
let formattedMatchingTypes = matchingTypes
82-
.formatted(using: .init(initialIndentation: .spaces(4)))
82+
.formatted(using: .init(initialIndentation: .spaces(2)))
8383
.trimmed
8484

8585
let fallbackType = SyntaxHelper.findArgument(named: "fallbackType", in: arguments)
8686

8787
return [
8888
DeclSyntax(
8989
"""
90-
\(raw: accessModifier)struct \(raw: strategyStructName): PolymorphicCodableStrategy {
90+
\(raw: accessModifier)struct \(raw: strategyStructName): PolymorphicMatchingTypesProviding {
9191
enum PolymorphicMetaCodingKey: CodingKey {
9292
case \(raw: identifierCodingKeyString)
9393
}
@@ -96,11 +96,19 @@ extension PolymorphicCodableStrategyProvidingMacro: PeerMacro {
9696
PolymorphicMetaCodingKey.\(raw: identifierCodingKeyString)
9797
}
9898
99+
\(raw: accessModifier)static var matchingTypes: [PolymorphicDecodableType.Type] {
100+
\(raw: formattedMatchingTypes)
101+
}
102+
103+
\(raw: accessModifier)static var fallbackType: PolymorphicDecodableType.Type? {
104+
\(raw: fallbackType ?? "nil")
105+
}
106+
99107
\(raw: accessModifier)static func decode(from decoder: Decoder) throws -> any \(raw: identifier) {
100108
try decoder.decode(
101109
codingKey: Self.polymorphicMetaCodingKey,
102-
matchingTypes: \(raw: formattedMatchingTypes),
103-
fallbackType: \(raw: fallbackType ?? "nil")
110+
matchingTypes: Self.matchingTypes,
111+
fallbackType: Self.fallbackType
104112
)
105113
}
106114
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
//
2+
// PolymorphicMatchingTypesProvidingTests.swift
3+
// KarrotCodableKit
4+
//
5+
// Created by Elon on 8/4/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 PolymorphicMatchingTypesProvidingTests {
15+
16+
@Test
17+
func `generated strategy exposes its matching types in declaration order`() {
18+
// given
19+
// DummyNotice declares DummyCallout, DummyActionableCallout and DummyDismissibleCallout.
20+
21+
// when
22+
let identifiers = DummyNoticeCodableStrategy.matchingTypes.map { $0.polymorphicIdentifier }
23+
24+
// then
25+
#expect(identifiers == ["callout", "actionable-callout", "dismissible-callout"])
26+
}
27+
28+
@Test
29+
func `generated strategy exposes its fallback type`() throws {
30+
// given
31+
// DummyNotice declares DummyUndefinedCallout as its fallback.
32+
33+
// when
34+
let fallbackType = try #require(DummyNoticeCodableStrategy.fallbackType)
35+
36+
// then
37+
#expect(fallbackType.polymorphicIdentifier == "undefined-callout")
38+
}
39+
40+
@Test
41+
func `strategy declared without a fallback type exposes nil`() {
42+
// given
43+
// ViewItem declares matching types only, with no fallbackType argument.
44+
45+
// when
46+
let fallbackType = ViewItemCodableStrategy.fallbackType
47+
48+
// then
49+
#expect(fallbackType == nil)
50+
#expect(ViewItemCodableStrategy.matchingTypes.isEmpty == false)
51+
}
52+
53+
@Test
54+
func `exposed matching types describe what decoding actually resolves`() throws {
55+
// given
56+
let jsonData = #"""
57+
{
58+
"notice" : {
59+
"description" : "Your listing is under review",
60+
"key" : "listing-under-review",
61+
"title" : "Under review",
62+
"type" : "dismissible-callout"
63+
},
64+
"notices" : [
65+
{
66+
"description" : "A notice type this client does not know yet",
67+
"title" : "Sponsored",
68+
"type" : "sponsored-callout"
69+
}
70+
]
71+
}
72+
"""#
73+
74+
let declaredIdentifiers = DummyNoticeCodableStrategy.matchingTypes.map { $0.polymorphicIdentifier }
75+
let fallbackIdentifier = try #require(DummyNoticeCodableStrategy.fallbackType).polymorphicIdentifier
76+
77+
// when
78+
let response = try JSONDecoder().decode(DummyResponse.self, from: Data(jsonData.utf8))
79+
80+
// then
81+
// A declared identifier resolves to the declared type.
82+
#expect(declaredIdentifiers.contains("dismissible-callout"))
83+
let notice = try #require(response.notice as? DummyDismissibleCallout)
84+
#expect(notice.key == "listing-under-review")
85+
86+
// An identifier outside the exposed list resolves to the exposed fallback type.
87+
#expect(declaredIdentifiers.contains("sponsored-callout") == false)
88+
#expect(fallbackIdentifier == "undefined-callout")
89+
let unknownNotice = try #require(response.notices.first as? DummyUndefinedCallout)
90+
#expect(unknownNotice.description == "A notice type this client does not know yet")
91+
}
92+
93+
@Test
94+
func `a generic helper can enumerate any conforming strategy`() {
95+
// given
96+
// A caller that only knows the protocol, mirroring an exhaustiveness check in a consumer.
97+
98+
// when
99+
let noticeIdentifiers = polymorphicIdentifiers(declaredIn: DummyNoticeCodableStrategy.self)
100+
let viewItemIdentifiers = polymorphicIdentifiers(declaredIn: ViewItemCodableStrategy.self)
101+
102+
// then
103+
#expect(noticeIdentifiers == ["callout", "actionable-callout", "dismissible-callout"])
104+
#expect(viewItemIdentifiers.contains("TITLE_VIEW_ITEM"))
105+
}
106+
107+
@Test
108+
func `a hand-written strategy keeps decoding without adopting the new protocol`() throws {
109+
// given
110+
let jsonData = #"""
111+
{
112+
"notice" : {
113+
"description" : "Welcome to Karrot",
114+
"icon" : "waving_hand",
115+
"type" : "callout"
116+
}
117+
}
118+
"""#
119+
120+
// when
121+
let response = try JSONDecoder().decode(
122+
HandWrittenStrategyDummyResponse.self,
123+
from: Data(jsonData.utf8),
124+
)
125+
126+
// then
127+
let notice = try #require(response.notice as? DummyCallout)
128+
#expect(notice.description == "Welcome to Karrot")
129+
#expect(notice.icon == "waving_hand")
130+
}
131+
}
132+
133+
private func polymorphicIdentifiers<Strategy: PolymorphicMatchingTypesProviding>(
134+
declaredIn _: Strategy.Type
135+
) -> [String] {
136+
Strategy.matchingTypes.map { $0.polymorphicIdentifier }
137+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
//
2+
// HandWrittenStrategyDummy.swift
3+
// KarrotCodableKit
4+
//
5+
// Created by Elon on 8/4/26.
6+
// Copyright © 2026 Danggeun Market Inc. All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
import KarrotCodableKit
12+
13+
/// A strategy written by hand, deliberately conforming to `PolymorphicCodableStrategy` only.
14+
///
15+
/// `PolymorphicMatchingTypesProviding` refines `PolymorphicCodableStrategy` instead of adding
16+
/// requirements to it, so strategies like this one keep working untouched. This double guards that
17+
/// promise — if the requirements ever move onto the base protocol, this file stops compiling.
18+
struct HandWrittenNoticeCodableStrategy: PolymorphicCodableStrategy {
19+
enum PolymorphicMetaCodingKey: CodingKey {
20+
case type
21+
}
22+
23+
static var polymorphicMetaCodingKey: CodingKey {
24+
PolymorphicMetaCodingKey.type
25+
}
26+
27+
static func decode(from decoder: Decoder) throws -> any DummyNotice {
28+
try decoder.decode(
29+
codingKey: Self.polymorphicMetaCodingKey,
30+
matchingTypes: [
31+
DummyCallout.self,
32+
DummyActionableCallout.self,
33+
],
34+
fallbackType: DummyUndefinedCallout.self,
35+
)
36+
}
37+
}
38+
39+
@CustomCodable(codingKeyStyle: .snakeCase)
40+
struct HandWrittenStrategyDummyResponse {
41+
42+
@PolymorphicValue<HandWrittenNoticeCodableStrategy>
43+
var notice: any DummyNotice
44+
}

Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicCodableStrategyProvidingMacroTests.swift

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ final class PolymorphicCodableStrategyProvidingMacroTests: XCTestCase {
6767
typealias DefaultEmptyPolymorphicArray = DefaultEmptyPolymorphicArrayValue<NoticeCodableStrategy>
6868
}
6969
70-
public struct NoticeCodableStrategy: PolymorphicCodableStrategy {
70+
public struct NoticeCodableStrategy: PolymorphicMatchingTypesProviding {
7171
enum PolymorphicMetaCodingKey: CodingKey {
7272
case type
7373
}
@@ -76,14 +76,22 @@ final class PolymorphicCodableStrategyProvidingMacroTests: XCTestCase {
7676
PolymorphicMetaCodingKey.type
7777
}
7878
79+
public static var matchingTypes: [PolymorphicDecodableType.Type] {
80+
[
81+
ActionableCallout.self,
82+
DismissibleCallout.self
83+
]
84+
}
85+
86+
public static var fallbackType: PolymorphicDecodableType.Type? {
87+
UndefinedCallout.self
88+
}
89+
7990
public static func decode(from decoder: Decoder) throws -> any Notice {
8091
try decoder.decode(
8192
codingKey: Self.polymorphicMetaCodingKey,
82-
matchingTypes: [
83-
ActionableCallout.self,
84-
DismissibleCallout.self
85-
],
86-
fallbackType: UndefinedCallout.self
93+
matchingTypes: Self.matchingTypes,
94+
fallbackType: Self.fallbackType
8795
)
8896
}
8997
}
@@ -137,7 +145,7 @@ final class PolymorphicCodableStrategyProvidingMacroTests: XCTestCase {
137145
typealias DefaultEmptyPolymorphicArray = DefaultEmptyPolymorphicArrayValue<NoticeCodableStrategy>
138146
}
139147
140-
public struct NoticeCodableStrategy: PolymorphicCodableStrategy {
148+
public struct NoticeCodableStrategy: PolymorphicMatchingTypesProviding {
141149
enum PolymorphicMetaCodingKey: CodingKey {
142150
case type
143151
}
@@ -146,14 +154,22 @@ final class PolymorphicCodableStrategyProvidingMacroTests: XCTestCase {
146154
PolymorphicMetaCodingKey.type
147155
}
148156
157+
public static var matchingTypes: [PolymorphicDecodableType.Type] {
158+
[
159+
ActionableCallout.self,
160+
DismissibleCallout.self
161+
]
162+
}
163+
164+
public static var fallbackType: PolymorphicDecodableType.Type? {
165+
nil
166+
}
167+
149168
public static func decode(from decoder: Decoder) throws -> any Notice {
150169
try decoder.decode(
151170
codingKey: Self.polymorphicMetaCodingKey,
152-
matchingTypes: [
153-
ActionableCallout.self,
154-
DismissibleCallout.self
155-
],
156-
fallbackType: nil
171+
matchingTypes: Self.matchingTypes,
172+
fallbackType: Self.fallbackType
157173
)
158174
}
159175
}

0 commit comments

Comments
 (0)