feat: @DerivedKey derived properties + slot-pipeline transformer primitives#27
Conversation
…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>
There was a problem hiding this comment.
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 generatedinit(from:)while omitting derived properties from coding keys and encoding. - Adds transformer primitives:
DictionaryLookupTransformer,liftOptional(), andonFailure(_:), 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.
| public func onFailure( | ||
| _ handler: @escaping (any Error) -> Void | ||
| ) -> some CodingTransformer<Input, Output> { | ||
| OnFailure(transformer: self, handler: handler) | ||
| } |
There was a problem hiding this comment.
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.
| 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" | ||
| ) | ||
| } |
There was a problem hiding this comment.
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.
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>
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 SwiftUIbody). With this feature the typed value materializes exactly once, duringCodabledecode:@DerivedKey semantics
CodingKeyscase; never read from the decoder, never encoded.super.init(from:)(classes) and beforedidDecodehooks, in declaration order.useDefaultOnFailure: Optional/defaulted properties fall back; otherwise the error propagates frominit(from:).@CodableKey/@DecodableKey/@EncodableKey,@Encodable-only types,letwith initializer, and computed properties. Diagnostics attach to the offending@DerivedKeyattribute.__ckDecode*runtime-helper pattern (__ckDecodeDerived); no parallel mechanism.Transformer primitives
DictionaryLookupTransformer<Key, Value>:[Key: Value]? -> Value?, never fails on a missing key.liftOptional(): liftsI -> OtoI? -> O?.nilpasses 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 thetry?fallback path.Design notes for review
Sendable, consistent with the package today; tightening later is source-breaking, so 2.x is the cheap moment to decide the posture.Codablebase receives an empty conformance list from the compiler, so@Codablegenerates no members for the child; the subclass runtime test uses.skipProtocolConformancewith a comment. Worth a separate issue.Test plan
swift buildcleansuper.initpinned), multiple derived ordering,@DecodablemirrorJSONDecoder: derive on decode, malformed-payload fallback tonil, non-optional pipeline failure throws, encode omits derived,didDecodeobserves derived values (struct + class)🤖 Generated with Claude Code