Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,34 @@ struct UndefinedViewItem: ViewItem {
}
```

#### Enumerating a polymorphic family

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.

```swift
ViewItemCodableStrategy.matchingTypes // [ImageViewItem.self, TextViewItem.self]
ViewItemCodableStrategy.fallbackType // UndefinedViewItem.self
```

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:

```swift
func assertEveryTypeHasHandler<Strategy: PolymorphicMatchingTypesProviding>(
declaredIn _: Strategy.Type,
registeredIdentifiers: Set<String>,
) {
let declared = Set(Strategy.matchingTypes.map { $0.polymorphicIdentifier })
#expect(declared.subtracting(registeredIdentifiers).isEmpty)
}

assertEveryTypeHasHandler(
declaredIn: ViewItemCodableStrategy.self,
registeredIdentifiers: Set(handlers.keys),
)
```

`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.

### PolymorphicEnumCodable

`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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ import Foundation
///
/// @PolymorphicValue<ViewItemCodableStrategy>
/// var item: ViewItem
/// ```
///
/// - SeeAlso: ``PolymorphicMatchingTypesProviding``, which refines this protocol so the family can be
/// enumerated from the outside. Strategies generated by `@PolymorphicCodableStrategyProviding`
/// conform to it automatically.
public protocol PolymorphicCodableStrategy {
associatedtype ExpectedType
static var polymorphicMetaCodingKey: CodingKey { get }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//
// PolymorphicMatchingTypesProviding.swift
// KarrotCodableKit
//
// Created by Elon on 8/4/26.
// Copyright © 2026 Danggeun Market Inc. All rights reserved.
//

import Foundation

/// A ``PolymorphicCodableStrategy`` whose polymorphic family can be enumerated from the outside.
///
/// A plain ``PolymorphicCodableStrategy`` keeps its candidate types inside `decode(from:)`, so there is
/// no way to ask which types belong to the family. Conforming types expose that list as a value, which
/// lets callers cross-check the family against something else — for example, asserting in a test that
/// every declared type has a registered handler.
///
/// Strategies generated by `@PolymorphicCodableStrategyProviding` adopt this protocol automatically,
/// and their `decode(from:)` reads ``matchingTypes`` and ``fallbackType`` rather than repeating the
/// list, so the exposed family and the decoded family cannot drift apart. Hand-written strategies
/// adopt it only when they need to be enumerated.
///
/// ```swift
/// func assertEveryTypeHasHandler<Strategy: PolymorphicMatchingTypesProviding>(
/// declaredIn _: Strategy.Type,
/// registeredIdentifiers: Set<String>,
/// ) {
/// let declared = Set(Strategy.matchingTypes.map { $0.polymorphicIdentifier })
/// #expect(declared.subtracting(registeredIdentifiers).isEmpty)
/// }
///
/// assertEveryTypeHasHandler(
/// declaredIn: ViewItemCodableStrategy.self,
/// registeredIdentifiers: Set(handlers.keys),
/// )
/// ```
///
/// - Note: This protocol intentionally refines ``PolymorphicCodableStrategy`` instead of adding
/// requirements to it. A defaulted requirement on the base protocol would let a hand-written strategy
/// report an empty family, and an exhaustiveness check reading that value would pass vacuously.
public protocol PolymorphicMatchingTypesProviding: PolymorphicCodableStrategy {
/// The candidate types matched against the polymorphic identifier, in the order they are matched.
///
/// This is the same list `decode(from:)` uses.
static var matchingTypes: [PolymorphicDecodableType.Type] { get }

/// The type used when the identifier matches none of the ``matchingTypes``.
///
/// `nil` means decoding fails with `PolymorphicCodableError.unableToFindPolymorphicType(_:)`
/// instead of falling back.
static var fallbackType: PolymorphicDecodableType.Type? { get }
}
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,15 @@ extension PolymorphicCodableStrategyProvidingMacro: PeerMacro {
let strategyStructName = "\(identifier)CodableStrategy"

let formattedMatchingTypes = matchingTypes
.formatted(using: .init(initialIndentation: .spaces(4)))
.formatted(using: .init(initialIndentation: .spaces(2)))
.trimmed

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

return [
DeclSyntax(
"""
\(raw: accessModifier)struct \(raw: strategyStructName): PolymorphicCodableStrategy {
\(raw: accessModifier)struct \(raw: strategyStructName): PolymorphicMatchingTypesProviding {
enum PolymorphicMetaCodingKey: CodingKey {
case \(raw: identifierCodingKeyString)
}
Expand All @@ -96,11 +96,19 @@ extension PolymorphicCodableStrategyProvidingMacro: PeerMacro {
PolymorphicMetaCodingKey.\(raw: identifierCodingKeyString)
}

\(raw: accessModifier)static var matchingTypes: [PolymorphicDecodableType.Type] {
\(raw: formattedMatchingTypes)
}

\(raw: accessModifier)static var fallbackType: PolymorphicDecodableType.Type? {
\(raw: fallbackType ?? "nil")
}

\(raw: accessModifier)static func decode(from decoder: Decoder) throws -> any \(raw: identifier) {
try decoder.decode(
codingKey: Self.polymorphicMetaCodingKey,
matchingTypes: \(raw: formattedMatchingTypes),
fallbackType: \(raw: fallbackType ?? "nil")
matchingTypes: Self.matchingTypes,
fallbackType: Self.fallbackType
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
//
// PolymorphicMatchingTypesProvidingTests.swift
// KarrotCodableKit
//
// Created by Elon on 8/4/26.
// Copyright © 2026 Danggeun Market Inc. All rights reserved.
//

import Foundation
import Testing

import KarrotCodableKit

struct PolymorphicMatchingTypesProvidingTests {

@Test
func `generated strategy exposes its matching types in declaration order`() {
// given
// DummyNotice declares DummyCallout, DummyActionableCallout and DummyDismissibleCallout.

// when
let identifiers = DummyNoticeCodableStrategy.matchingTypes.map { $0.polymorphicIdentifier }

// then
#expect(identifiers == ["callout", "actionable-callout", "dismissible-callout"])
}

@Test
func `generated strategy exposes its fallback type`() throws {
// given
// DummyNotice declares DummyUndefinedCallout as its fallback.

// when
let fallbackType = try #require(DummyNoticeCodableStrategy.fallbackType)

// then
#expect(fallbackType.polymorphicIdentifier == "undefined-callout")
}

@Test
func `strategy declared without a fallback type exposes nil`() {
// given
// ViewItem declares matching types only, with no fallbackType argument.

// when
let fallbackType = ViewItemCodableStrategy.fallbackType

// then
#expect(fallbackType == nil)
#expect(ViewItemCodableStrategy.matchingTypes.isEmpty == false)
}

@Test
func `exposed matching types describe what decoding actually resolves`() throws {
// given
let jsonData = #"""
{
"notice" : {
"description" : "Your listing is under review",
"key" : "listing-under-review",
"title" : "Under review",
"type" : "dismissible-callout"
},
"notices" : [
{
"description" : "A notice type this client does not know yet",
"title" : "Sponsored",
"type" : "sponsored-callout"
}
]
}
"""#

let declaredIdentifiers = DummyNoticeCodableStrategy.matchingTypes.map { $0.polymorphicIdentifier }
let fallbackIdentifier = try #require(DummyNoticeCodableStrategy.fallbackType).polymorphicIdentifier

// when
let response = try JSONDecoder().decode(DummyResponse.self, from: Data(jsonData.utf8))

// then
// A declared identifier resolves to the declared type.
#expect(declaredIdentifiers.contains("dismissible-callout"))
let notice = try #require(response.notice as? DummyDismissibleCallout)
#expect(notice.key == "listing-under-review")

// An identifier outside the exposed list resolves to the exposed fallback type.
#expect(declaredIdentifiers.contains("sponsored-callout") == false)
#expect(fallbackIdentifier == "undefined-callout")
let unknownNotice = try #require(response.notices.first as? DummyUndefinedCallout)
#expect(unknownNotice.description == "A notice type this client does not know yet")
}

@Test
func `a generic helper can enumerate any conforming strategy`() {
// given
// A caller that only knows the protocol, mirroring an exhaustiveness check in a consumer.

// when
let noticeIdentifiers = polymorphicIdentifiers(declaredIn: DummyNoticeCodableStrategy.self)
let viewItemIdentifiers = polymorphicIdentifiers(declaredIn: ViewItemCodableStrategy.self)

// then
#expect(noticeIdentifiers == ["callout", "actionable-callout", "dismissible-callout"])
#expect(viewItemIdentifiers.contains("TITLE_VIEW_ITEM"))
}

@Test
func `a hand-written strategy keeps decoding without adopting the new protocol`() throws {
// given
let jsonData = #"""
{
"notice" : {
"description" : "Welcome to Karrot",
"icon" : "waving_hand",
"type" : "callout"
}
}
"""#

// when
let response = try JSONDecoder().decode(
HandWrittenStrategyDummyResponse.self,
from: Data(jsonData.utf8),
)

// then
let notice = try #require(response.notice as? DummyCallout)
#expect(notice.description == "Welcome to Karrot")
#expect(notice.icon == "waving_hand")
}
}

private func polymorphicIdentifiers<Strategy: PolymorphicMatchingTypesProviding>(
declaredIn _: Strategy.Type
) -> [String] {
Strategy.matchingTypes.map { $0.polymorphicIdentifier }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
//
// HandWrittenStrategyDummy.swift
// KarrotCodableKit
//
// Created by Elon on 8/4/26.
// Copyright © 2026 Danggeun Market Inc. All rights reserved.
//

import Foundation

import KarrotCodableKit

/// A strategy written by hand, deliberately conforming to `PolymorphicCodableStrategy` only.
///
/// `PolymorphicMatchingTypesProviding` refines `PolymorphicCodableStrategy` instead of adding
/// requirements to it, so strategies like this one keep working untouched. This double guards that
/// promise — if the requirements ever move onto the base protocol, this file stops compiling.
struct HandWrittenNoticeCodableStrategy: PolymorphicCodableStrategy {
enum PolymorphicMetaCodingKey: CodingKey {
case type
}

static var polymorphicMetaCodingKey: CodingKey {
PolymorphicMetaCodingKey.type
}

static func decode(from decoder: Decoder) throws -> any DummyNotice {
try decoder.decode(
codingKey: Self.polymorphicMetaCodingKey,
matchingTypes: [
DummyCallout.self,
DummyActionableCallout.self,
],
fallbackType: DummyUndefinedCallout.self,
)
}
}

@CustomCodable(codingKeyStyle: .snakeCase)
struct HandWrittenStrategyDummyResponse {

@PolymorphicValue<HandWrittenNoticeCodableStrategy>
var notice: any DummyNotice
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ final class PolymorphicCodableStrategyProvidingMacroTests: XCTestCase {
typealias DefaultEmptyPolymorphicArray = DefaultEmptyPolymorphicArrayValue<NoticeCodableStrategy>
}

public struct NoticeCodableStrategy: PolymorphicCodableStrategy {
public struct NoticeCodableStrategy: PolymorphicMatchingTypesProviding {
enum PolymorphicMetaCodingKey: CodingKey {
case type
}
Expand All @@ -76,14 +76,22 @@ final class PolymorphicCodableStrategyProvidingMacroTests: XCTestCase {
PolymorphicMetaCodingKey.type
}

public static var matchingTypes: [PolymorphicDecodableType.Type] {
[
ActionableCallout.self,
DismissibleCallout.self
]
}

public static var fallbackType: PolymorphicDecodableType.Type? {
UndefinedCallout.self
}

public static func decode(from decoder: Decoder) throws -> any Notice {
try decoder.decode(
codingKey: Self.polymorphicMetaCodingKey,
matchingTypes: [
ActionableCallout.self,
DismissibleCallout.self
],
fallbackType: UndefinedCallout.self
matchingTypes: Self.matchingTypes,
fallbackType: Self.fallbackType
)
}
}
Expand Down Expand Up @@ -137,7 +145,7 @@ final class PolymorphicCodableStrategyProvidingMacroTests: XCTestCase {
typealias DefaultEmptyPolymorphicArray = DefaultEmptyPolymorphicArrayValue<NoticeCodableStrategy>
}

public struct NoticeCodableStrategy: PolymorphicCodableStrategy {
public struct NoticeCodableStrategy: PolymorphicMatchingTypesProviding {
enum PolymorphicMetaCodingKey: CodingKey {
case type
}
Expand All @@ -146,14 +154,22 @@ final class PolymorphicCodableStrategyProvidingMacroTests: XCTestCase {
PolymorphicMetaCodingKey.type
}

public static var matchingTypes: [PolymorphicDecodableType.Type] {
[
ActionableCallout.self,
DismissibleCallout.self
]
}

public static var fallbackType: PolymorphicDecodableType.Type? {
nil
}

public static func decode(from decoder: Decoder) throws -> any Notice {
try decoder.decode(
codingKey: Self.polymorphicMetaCodingKey,
matchingTypes: [
ActionableCallout.self,
DismissibleCallout.self
],
fallbackType: nil
matchingTypes: Self.matchingTypes,
fallbackType: Self.fallbackType
)
}
}
Expand Down
Loading