Skip to content

feat: @DerivedKey derived properties + slot-pipeline transformer primitives#27

Merged
WendellXY merged 4 commits into
mainfrom
feat/derived-key
Jun 11, 2026
Merged

feat: @DerivedKey derived properties + slot-pipeline transformer primitives#27
WendellXY merged 4 commits into
mainfrom
feat/derived-key

Conversation

@WendellXY

Copy link
Copy Markdown
Owner

Summary

Adds @DerivedKey — derived stored properties populated at decode time by a transformer pipeline over an already-decoded sibling property — plus three transformer primitives (DictionaryLookupTransformer, liftOptional(), onFailure(_:)) that make slot-bag extraction chains declarative.

Motivating use case: payloads carrying a [String: Slot] dictionary whose slots hold string-encoded JSON. Downstream apps currently re-parse those strings on every property access (including inside SwiftUI body). With this feature the typed value materializes exactly once, during Codable decode:

@Codable
public struct UserCommonConfigInfo: Sendable, Hashable {
  public var userConfigValue: [String: UserConfigValue]?

  @DerivedKey(
    from: "userConfigValue",
    transformer: DictionaryLookupTransformer(key: "10010")
      .chained(KeyPathTransformer(keyPath: \UserConfigValue?.?.strDataValue1))
      .chained(RawStringDecodingTransformer<AvatarFrame>().liftOptional())
      .onFailure { log.error("avatarFrame payload malformed: \($0)") }
  )
  public private(set) var avatarFrame: AvatarFrame?
}

@DerivedKey semantics

  • No CodingKeys case; never read from the decoder, never encoded.
  • Tail assignments run after all coded properties, before super.init(from:) (classes) and before didDecode hooks, in declaration order.
  • Failure policy mirrors useDefaultOnFailure: Optional/defaulted properties fall back; otherwise the error propagates from init(from:).
  • v1 scope: pipelines depend only on coded stored properties of the same type — derived-on-derived and inherited sources are diagnosed, as are combinations with @CodableKey/@DecodableKey/@EncodableKey, @Encodable-only types, let with initializer, and computed properties. Diagnostics attach to the offending @DerivedKey attribute.
  • Existential opening reuses the existing __ckDecode* runtime-helper pattern (__ckDecodeDerived); no parallel mechanism.

Transformer primitives

  • DictionaryLookupTransformer<Key, Value>: [Key: Value]? -> Value?, never fails on a missing key.
  • liftOptional(): lifts I -> O to I? -> O?. nil passes through without invoking the base; upstream failures are forwarded into the base, so failure-recovering transformers (DefaultOnFailureTransformer) recover when lifted, while non-recovering bases propagate unchanged. Bidirectional variant via conditional conformance. This default is documented and test-pinned — review welcome.
  • onFailure(_:): pass-through tap on failure results, so malformed payloads can be logged instead of silently swallowed by the try? fallback path.

Design notes for review

  • Transformer pipelines remain non-Sendable, consistent with the package today; tightening later is source-breaking, so 2.x is the cheap moment to decide the posture.
  • Purely additive public API — suitable for a minor version bump.
  • Discovered while testing (pre-existing, not addressed here): a subclass of an already-Codable base receives an empty conformance list from the compiler, so @Codable generates no members for the child; the subclass runtime test uses .skipProtocolConformance with a comment. Worth a separate issue.

Test plan

  • swift build clean
  • Full suite green: 41 TransformerTests / 65 EncodableKitTests / 74 DecodableKitTests / 167 CodableKitTests — 347 tests, 0 failures
  • Expansion tests: struct, class (with/without superclass, assignment-before-super.init pinned), multiple derived ordering, @Decodable mirror
  • Runtime tests through real JSONDecoder: derive on decode, malformed-payload fallback to nil, non-optional pipeline failure throws, encode omits derived, didDecode observes derived values (struct + class)
  • Diagnostics tests for all seven errors, pinned to property-attribute locations
  • swift-format applied with repo config

🤖 Generated with Claude Code

WendellXY and others added 2 commits June 11, 2026 19:05
…ure combinators

Three composable primitives for deriving typed values from keyed
slot-bag payloads (e.g. dictionaries of config slots containing
string-encoded JSON):

- DictionaryLookupTransformer: [Key: Value]? -> Value? lookup that
  never fails on a missing key
- liftOptional(): lifts any transformer to Input? -> Output?; nil
  passes through without invoking the base, upstream failures are
  forwarded into the base so failure-recovering transformers
  (DefaultOnFailureTransformer) still recover when lifted; includes
  a BidirectionalCodingTransformer variant via conditional conformance
- onFailure(_:): pass-through tap invoked on failure results, so
  malformed payloads can be logged instead of silently swallowed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A stored property populated at the end of the generated init(from:)
by a transformer pipeline over an already-decoded sibling property:

    @DerivedKey(from: "rawConfig", transformer: slotPipeline)
    public private(set) var avatarFrame: AvatarFrame?

Semantics:
- no CodingKeys case; never read from the decoder, never encoded
- assignments run after all coded properties, before super.init(from:)
  (classes) and before didDecode hooks, in declaration order
- failure policy mirrors useDefaultOnFailure: Optional/defaulted
  properties fall back, otherwise the error propagates from decode
- pipelines may depend only on coded properties of the same type;
  derived-on-derived and inherited sources are diagnosed
- existential opening reuses the __ckDecode* runtime helper pattern
  via a new __ckDecodeDerived helper

Diagnostics (attached to the offending @DerivedKey attribute):
combination with @CodableKey/@DecodableKey/@EncodableKey, unknown or
non-literal source, derived-on-derived, @Encodable-only types,
let-with-initializer, and computed properties.

Covered by expansion, diagnostics, and runtime tests for structs and
classes (including subclass ordering before super.init and hook
observation), mirrored into DecodableKitTests; README updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 11, 2026 11:06
@WendellXY WendellXY self-assigned this Jun 11, 2026

Copilot AI 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.

Pull request overview

Adds a new decode-time “derived stored property” capability to CodableKit via @DerivedKey, plus transformer primitives that make derived-value pipelines (e.g., slot-bag extraction + embedded JSON decoding) declarative and single-pass during decoding.

Changes:

  • Introduces @DerivedKey (macro + runtime helper) and integrates derived-property tail assignments into generated init(from:) while omitting derived properties from coding keys and encoding.
  • Adds transformer primitives: DictionaryLookupTransformer, liftOptional(), and onFailure(_:), with new unit tests and macro expansion/diagnostics/runtime coverage.
  • Updates README feature highlights and adds/extends test harness macro registrations.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
Tests/TransformerTests/DerivedValueTransformerTests.swift Adds unit tests for the new transformer primitives and an end-to-end slot-bag pipeline.
Tests/DecodableKitTests/Defines.swift Registers DerivedKey in the DecodableKit macro test harness.
Tests/DecodableKitTests/CodableMacroTests+derived.swift Adds @Decodable expansion + diagnostics tests for @DerivedKey.
Tests/CodableKitTests/Defines.swift Registers DerivedKey (and Encodable spec) in the CodableKit macro test harness.
Tests/CodableKitTests/CodableMacroTests+derived.swift Adds @Codable expansion + diagnostics tests for @DerivedKey (struct + class + subclass ordering).
Tests/CodableKitTests/CodableMacroTests+derived.runtime.swift Adds runtime JSONDecoder behavior tests for derived properties (fallbacks, omission from encoding, hooks ordering).
Sources/CodableKitMacros/Plugin.swift Adds DerivedKeyMacro to the compiler plugin’s macro list.
Sources/CodableKitMacros/Diagnostic.swift Adds DiagnosticAlreadyEmitted to avoid duplicate diagnostics across macro roles.
Sources/CodableKitMacros/DerivedKeyMacro.swift Implements the @DerivedKey peer macro as a validating marker (no generated peers).
Sources/CodableKitMacros/CodeGenCore.swift Validates @DerivedKey usage and excludes derived properties from key-conflict checks.
Sources/CodableKitMacros/CodableProperty+Derived.swift Adds helpers for detecting/parsing @DerivedKey(from:, transformer:).
Sources/CodableKitMacros/CodableProperty.swift Tracks whether a property is let (isConstant) to support derived assignment rules.
Sources/CodableKitMacros/CodableMacro.swift Excludes derived properties from coding-key trees; emits derived tail assignments; suppresses duplicate diagnostics.
Sources/CodableKit/Transformers/CodingTransformerComposition.swift Adds internal composition wrappers for liftOptional() and onFailure(_:).
Sources/CodableKit/Transformers/CodingTransformer.swift Exposes liftOptional() and onFailure(_:) on transformer pipelines (and bidirectional liftOptional()).
Sources/CodableKit/Transformers/BuiltInTransformers.swift Adds public DictionaryLookupTransformer.
Sources/CodableKit/CodingTransformerRuntime.swift Adds __ckDecodeDerived runtime helper used by generated derived assignments.
Sources/CodableKit/CodableKit.swift Adds public @DerivedKey macro and API documentation for derived semantics.
README.md Documents derived properties and the new transformer primitives in the public README.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +62 to +66
public func onFailure(
_ handler: @escaping (any Error) -> Void
) -> some CodingTransformer<Input, Output> {
OnFailure(transformer: self, handler: handler)
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Resolved in f9695aa: OnFailure now conditionally conforms to BidirectionalCodingTransformer (forwarding reverseTransform and invoking the handler on reverse failures), and BidirectionalCodingTransformer gains an onFailure(_:) overload returning some BidirectionalCodingTransformer. Test pins that a tapped bidirectional chain still satisfies any BidirectionalCodingTransformer.

Comment on lines +476 to +487
guard let sourceProperty = extractedProperties.first(where: { $0.name.trimmedDescription == derivedFromName })
else {
throw derivedError(
"@DerivedKey source property '\(derivedFromName)' does not exist as a stored property of this type; inherited properties are not supported as 'from:' sources"
)
}

if sourceProperty.isDerived {
throw derivedError(
"@DerivedKey source property '\(derivedFromName)' is itself derived; derived properties may only depend on coded properties"
)
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Resolved in 5737e96: from: sources with .ignored are now rejected with a dedicated diagnostic attached to the @DerivedKey attribute ("…is excluded from decoding (.ignored); derived properties may only depend on decoded properties"). The check reads .ignored across all key-macro flavors, so @EncodableKey(options: .ignored) inside @Codable types is caught as well; @EncodableKey("key")-only siblings remain valid since the separate decode tree still decodes them. Docs/README updated.

WendellXY and others added 2 commits June 11, 2026 19:26
OnFailure now conditionally conforms to BidirectionalCodingTransformer
(mirroring OptionalLifted), and BidirectionalCodingTransformer gains an
onFailure(_:) overload returning some BidirectionalCodingTransformer.
The handler fires on failures in both directions; results pass through
unchanged. Without this, tapping a @CodableKey(transformer:) pipeline
for logging erased its reverse transform.

Addresses PR #27 review feedback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A from: source marked .ignored is never decoded, so the derived
pipeline would silently run over the source's default/initial value.
Now rejected with a dedicated diagnostic attached to the @DerivedKey
attribute. The check reads .ignored across all key-macro flavors, so
@EncodableKey(options: .ignored) inside @codable types (which also
suppresses decoding via the shared tree) is caught too. Docs updated
to state sources must be decoded stored properties of the same type.

Addresses PR #27 review feedback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@WendellXY WendellXY merged commit 0de66de into main Jun 11, 2026
8 checks passed
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