Skip to content

refactor(polymorphic): 컨테이너 확장 중복 제거 + null outcome 일관화#24

Open
DevVenusK wants to merge 4 commits into
daangn:mainfrom
DevVenusK:refactor/polymorphic-container-extensions
Open

refactor(polymorphic): 컨테이너 확장 중복 제거 + null outcome 일관화#24
DevVenusK wants to merge 4 commits into
daangn:mainfrom
DevVenusK:refactor/polymorphic-container-extensions

Conversation

@DevVenusK

@DevVenusK DevVenusK commented Jul 6, 2026

Copy link
Copy Markdown

배경

polymorphic 프로퍼티 래퍼의 디코딩/인코딩은 KeyedDecodingContainer / KeyedEncodingContainer 확장 10개 파일에 흩어져 있고, 각 파일이 아래의 동일한 존재 판별 절차를 복붙 수준으로 반복합니다.

[모든 decode 오버로드가 반복하던 패턴]

  guard contains(key) else { ...key 없음 처리... }      # 1) 키 존재?
  if try decodeNil(forKey: key) { ...null 처리... }     # 2) 값이 null?
  let decoder = try superDecoder(forKey: key)           # 3) 실제 디코딩
  ...
  #if DEBUG ... #else ... #endif                        # 4) 빌드별 분기까지 중복

이 구조는 (a) 동기화가 어렵고 (b) 분기 하나만 어긋나도 버그가 됩니다. 실제로 어긋난 곳이 있었습니다 — KeyedDecodingContainer+PolymorphicLossyArrayValue:

진입점 null 값일 때 outcome (release)
decode(_:forKey:) .valueWasNil
decodeIfPresent(_:forKey:) .decodedSuccessfully ← 불일치

변경사항

1) 존재 판별 일원화 — PolymorphicKeyPresence

contains / decodeNil 순서를 단일 헬퍼로 캡슐화하고, 6개 decode 오버로드를 모두 이 헬퍼로 경유시킵니다.

                    decode / decodeIfPresent(forKey:)
                                  │
                    polymorphicKeyPresence(forKey:)
                                  │
        ┌─────────────────────────┼─────────────────────────┐
     key 없음                   값이 null                    그 외
        ▼                         ▼                          ▼
    .missing                   .null                     .present
        │                         │                          │
  nil / [] +                nil / [] +             superDecoder 또는
  .keyNotFound              .valueWasNil           nestedUnkeyedContainer
                                                   로 실제 디코딩
// 추가된 헬퍼
enum PolymorphicKeyPresence { case missing, null, present }

extension KeyedDecodingContainer {
  func polymorphicKeyPresence(forKey key: Key) throws -> PolymorphicKeyPresence {
    guard contains(key) else { return .missing }
    return try decodeNil(forKey: key) ? .null : .present
  }
}
// before (각 파일 반복)                    // after (헬퍼 경유)
guard contains(key) else { return nil }     switch try polymorphicKeyPresence(forKey: key) {
if try decodeNil(forKey: key) {             case .missing: return nil
  return X(wrappedValue: nil,               case .null:    return X(wrappedValue: nil, outcome: .valueWasNil)
           outcome: .valueWasNil)           case .present:
}                                             let value = try T.decode(from: superDecoder(forKey: key))
do {                                          return X(wrappedValue: value, outcome: .decodedSuccessfully)
  let decoder = try superDecoder(forKey:key) }
  ...
}

2) null outcome 일관화

헬퍼의 .null 분기가 단일 지점이 되면서 위 불일치가 구조적으로 해소됩니다.

// before — PolymorphicLossyArrayValue.decodeIfPresent (release)
return PolymorphicLossyArrayValue(wrappedValue: [])                       // → .decodedSuccessfully (버그)
// after
return PolymorphicLossyArrayValue(wrappedValue: [], outcome: .valueWasNil) // decode(_:forKey:) 와 일치

3) omit-nil encode 통합 — OmitNilPolymorphicEncodable

nil 이면 키를 생략하는 4개의 거의 동일한 encode 오버로드를 제약 제네릭 1개로 축약합니다.

before: 4개의 거의 동일한 오버로드                       after: 제약 제네릭 1개 + conformance 4개
┌──────────────────────────────────────────────┐        ┌────────────────────────────────────────────┐
│ encode(_: OptionalPolymorphicValue,        …) │        │ encode<W: OmitNilPolymorphicEncodable>       │
│ encode(_: LossyOptionalPolymorphicValue,   …) │  ───▶  │        (_ value: W, forKey:)                  │
│ encode(_: OptionalPolymorphicArrayValue,   …) │        │   guard !value.isWrappedValueNil else{return}│
│ encode(_: OptionalPolymorphicLossyArray,   …) │        │   try value.encode(to: superEncoder(forKey:))│
└──────────────────────────────────────────────┘        └────────────────────────────────────────────┘
// after
public protocol OmitNilPolymorphicEncodable: Encodable {
  var isWrappedValueNil: Bool { get }
}

extension KeyedEncodingContainer {
  // 제약 제네릭이 더 구체적이므로 생성 코드의 encode(_:forKey:) 호출이 이 오버로드를 선택
  public mutating func encode<W>(_ value: W, forKey key: Key) throws
    where W: OmitNilPolymorphicEncodable {
    guard !value.isWrappedValueNil else { return }
    try value.encode(to: superEncoder(forKey: key))
  }
}

변경 파일

  • 추가(3): KeyedDecodingContainer+PolymorphicKeyPresence.swift, KeyedEncodingContainer+OmitNilPolymorphicEncodable.swift, Tests/…/PolymorphicLossyArrayNullOutcomeTests.swift
  • 수정(6): KeyedDecodingContainer+{OptionalPolymorphicValue, LossyOptionalPolymorphicValue, OptionalPolymorphicArrayValue, OptionalPolymorphicLossyArrayValue, DefaultEmptyPolymorphicArrayValue, PolymorphicLossyArrayValue}.swift
  • 삭제(4): KeyedEncodingContainer+{OptionalPolymorphicValue, LossyOptionalPolymorphicValue, OptionalPolymorphicArrayValue, OptionalPolymorphicLossyArrayValue}.swift

12 files changed, +214 / −201

변경했을 때의 트레이드 오프

  • 간접호출 1단계 추가 ↔ 중복 제거·일관성. 헬퍼는 분기만 하는 단순 함수라 성능 영향은 사실상 없음.
  • .present의도적으로 Decoder 를 들고 있지 않습니다. OptionalPolymorphicArrayValuenestedUnkeyedContainer(forKey:) 를 쓰고 나머지 5개는 superDecoder(forKey:) 를 쓰기 때문에, 헬퍼가 특정 컨테이너를 강제하면 경로가 어긋납니다. 대신 각 arm 이 자기 경로를 직접 획득 → 판별 후 컨테이너 획득이 한 번 더 일어나지만(무시 가능) 기존 동작을 정확히 보존합니다.
  • omit-nil 통합으로 새 public 프로토콜 OmitNilPolymorphicEncodable 이 API 표면에 추가됩니다. 또 오버로드 해소가 "제약 제네릭이 stdlib encode<T: Encodable> 보다 더 구체적" 이라는 규칙에 의존합니다 → omit-nil 테스트로 검증했습니다.
  • null outcome 수정의 실이득은 코드 일관성입니다(런타임 관측 영향은 아래 "기타" 참고).

테스트 방법

  • swift test -c debug / swift test -c release — 전부 통과(실패 0). 총 360 테스트로 리팩터 전과 동일.
  • null/missing outcome 를 특성화하는 PolymorphicLossyArrayNullOutcomeTests 신규 추가.
  • omit-nil encode 동작은 기존 OptionalPolymorphicValueTests(예: encodingOptionalPolymorphicValueOmitsNilFields) 등으로 검증 — 오버로드 통합 후에도 green.

기타

  • null outcome 불일치는 release 전용이며 사실상 관측 불가입니다. release 에서 ResilientDecodingOutcome 는 크기 0의 미사용 struct 이고 wrappedValue 는 이전에도 이후에도 [] 입니다. 따라서 이는 기능 버그가 아니라 일관성 정리이며, 헬퍼 도입으로 자연히 해결됩니다.
  • 제거된 public API 없음 — OmitNilPolymorphicEncodable 은 추가만 합니다.

Summary by CodeRabbit

  • New Features

    • Improved polymorphic decoding so missing, null, and present values are handled more consistently across optional, lossy, and array-based wrappers.
    • Added support for automatically omitting nil wrapped values when encoding supported polymorphic wrappers.
  • Bug Fixes

    • Fixed inconsistent handling of null and missing values during decoding.
    • Made array decoding outcomes more reliable for empty or absent data.
  • Tests

    • Added coverage for null and missing polymorphic array cases.

contains/decodeNil 존재 판별을 캡슐화하는 내부 헬퍼를 도입해
이후 디코딩 오버로드들의 중복 보일러플레이트 제거의 기반을 마련한다.
6개 decode 컨테이너 확장의 contains/decodeNil 존재 판별을 공통 헬퍼로 통일한다.
또한 release 빌드에서 PolymorphicLossyArrayValue.decodeIfPresent 가 null 값에 대해
.valueWasNil 을 반환하도록 하여 decode(_:forKey:) 와 동작을 일치시킨다.
공유 헬퍼의 null 분기를 보호하기 위한 특성화 테스트를 추가한다.
…오버로드 통합

거의 동일한 4개의 KeyedEncodingContainer.encode 오버로드를
제약 제네릭 1개 + wrapper 별 conformance 로 축약한다.
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a shared polymorphicKeyPresence(forKey:) helper and refactors polymorphic decoding extensions (default-empty, lossy-optional, optional array/value, lossy array) to switch on it instead of separate contains/decodeNil checks. Removes per-type KeyedEncodingContainer.encode overloads in favor of a single generic overload gated by a new OmitNilPolymorphicEncodable protocol. Adds a null-outcome test suite.

Changes

Polymorphic Codable Refactor

Layer / File(s) Summary
Key presence helper
.../KeyedDecodingContainer+PolymorphicKeyPresence.swift
New PolymorphicKeyPresence enum (missing/null/present) and polymorphicKeyPresence(forKey:) method centralizing contains/decodeNil probing.
Optional/default decoding checkpoints
.../KeyedDecodingContainer+DefaultEmptyPolymorphicArrayValue.swift, .../+LossyOptionalPolymorphicValue.swift, .../+OptionalPolymorphicArrayValue.swift, .../+OptionalPolymorphicValue.swift
decode/decodeIfPresent implementations switch on the new presence helper instead of manual contains/decodeNil checks and, in one case, removes a redundant do/catch rethrow.
Lossy array decoding checkpoint
.../KeyedDecodingContainer+PolymorphicLossyArrayValue.swift
decode and decodeIfPresent restructured around the presence switch; the previous fallback that recovered decode errors into an empty array for present values is removed, letting errors propagate from PolymorphicLossyArrayValue(from:).
Shared omit-nil encoding
.../KeyedEncodingContainer+OmitNilPolymorphicEncodable.swift, .../+LossyOptionalPolymorphicValue.swift, .../+OptionalPolymorphicArrayValue.swift, .../+OptionalPolymorphicLossyArrayValue.swift, .../+OptionalPolymorphicValue.swift
Adds OmitNilPolymorphicEncodable protocol and one generic encode(_:forKey:) overload that skips encoding when isWrappedValueNil is true; removes the four separate per-type encode overloads previously providing this behavior.
Null outcome tests
Tests/.../PolymorphicLossyArrayNullOutcomeTests.swift
New test suite validating that null/missing lossy array values decode to empty arrays and, in DEBUG, that outcomes are not .decodedSuccessfully.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant KeyedDecodingContainer
  participant PolymorphicKeyPresenceHelper
  participant PolymorphicWrapper

  Client->>KeyedDecodingContainer: decodeIfPresent(forKey:)
  KeyedDecodingContainer->>PolymorphicKeyPresenceHelper: polymorphicKeyPresence(forKey:)
  PolymorphicKeyPresenceHelper-->>KeyedDecodingContainer: .missing / .null / .present
  alt .missing
    KeyedDecodingContainer-->>Client: nil
  else .null
    KeyedDecodingContainer->>PolymorphicWrapper: init(outcome: .valueWasNil)
    PolymorphicWrapper-->>Client: wrapper with valueWasNil
  else .present
    KeyedDecodingContainer->>PolymorphicWrapper: decode via superDecoder(forKey:)
    PolymorphicWrapper-->>Client: wrapper with decodedSuccessfully
  end
Loading

Suggested labels: Improvement, Bug

Nice tidy-up here — consolidating the contains/decodeNil dance into one presence helper is exactly the kind of refactor that makes future changes easier to reason about. One thing worth a close look during review: the lossy array's .present branch no longer swallows decode errors into a recovered empty array — worth double-checking downstream callers aren't relying on that silent recovery behavior in production, since that's now a genuine behavior change hiding inside what otherwise reads as a mechanical refactor.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly states the refactor focus, covering duplicate container extension removal and null outcome consistency.
Description check ✅ Passed The description covers background, changes, testing, and review notes with enough detail, even though the headings are localized.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+PolymorphicLossyArrayValue.swift (1)

74-106: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Restore lossy fallback for present array values
decodeIfPresent should mirror decode(_:forKey:) here. As written, a present-but-invalid JSON value now throws instead of degrading to [], which breaks the documented behavior of PolymorphicLossyArrayValue for malformed arrays.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer`+PolymorphicLossyArrayValue.swift
around lines 74 - 106, `decodeIfPresent(_:forKey:)` in
`KeyedDecodingContainer+PolymorphicLossyArrayValue` no longer preserves the
lossy array behavior for present values, because the `.present` path directly
throws from `PolymorphicLossyArrayValue(from:)` instead of recovering to an
empty array. Update the `.present` branch to mirror `decode(_:forKey:)` by
decoding through the same lossy path and, on malformed input, returning a
`PolymorphicLossyArrayValue` with `wrappedValue: []` and a recovered outcome
instead of propagating the decode error.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In
`@Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer`+PolymorphicLossyArrayValue.swift:
- Around line 74-106: `decodeIfPresent(_:forKey:)` in
`KeyedDecodingContainer+PolymorphicLossyArrayValue` no longer preserves the
lossy array behavior for present values, because the `.present` path directly
throws from `PolymorphicLossyArrayValue(from:)` instead of recovering to an
empty array. Update the `.present` branch to mirror `decode(_:forKey:)` by
decoding through the same lossy path and, on malformed input, returning a
`PolymorphicLossyArrayValue` with `wrappedValue: []` and a recovered outcome
instead of propagating the decode error.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: ASSERTIVE

Plan: Pro

Run ID: fbe55e7d-82ec-4d63-8eb7-6507796fd8ae

📥 Commits

Reviewing files that changed from the base of the PR and between 088c813 and b5c584e.

📒 Files selected for processing (12)
  • Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+DefaultEmptyPolymorphicArrayValue.swift
  • Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+LossyOptionalPolymorphicValue.swift
  • Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicArrayValue.swift
  • Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicValue.swift
  • Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+PolymorphicKeyPresence.swift
  • Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+PolymorphicLossyArrayValue.swift
  • Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedEncodingContainer+LossyOptionalPolymorphicValue.swift
  • Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedEncodingContainer+OmitNilPolymorphicEncodable.swift
  • Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedEncodingContainer+OptionalPolymorphicArrayValue.swift
  • Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedEncodingContainer+OptionalPolymorphicLossyArrayValue.swift
  • Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedEncodingContainer+OptionalPolymorphicValue.swift
  • Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicLossyArrayNullOutcomeTests.swift
💤 Files with no reviewable changes (4)
  • Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedEncodingContainer+OptionalPolymorphicArrayValue.swift
  • Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedEncodingContainer+LossyOptionalPolymorphicValue.swift
  • Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedEncodingContainer+OptionalPolymorphicValue.swift
  • Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedEncodingContainer+OptionalPolymorphicLossyArrayValue.swift

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants