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
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
- One-line `@Codable`, `@Encodable`, and `@Decodable` synthesis
- Default-aware decoding, nested keys, and graceful fallbacks
- Raw-string transcoding, lossy collections, and transformer pipelines
- Derived properties computed from already-decoded fields via `@DerivedKey`
- Explicit lifecycle hooks with deterministic generated code

CodableKit is a Swift macro package built for the JSON you actually receive: nested payloads, string-encoded objects, partially invalid arrays, and schemas that drift over time. It keeps configuration close to each property, surfaces mistakes with compile-time diagnostics, and avoids runtime reflection or hidden magic.
Expand Down Expand Up @@ -58,7 +59,8 @@ That single model gets generated `CodingKeys`, `init(from:)`, and `encode(to:)`
| Raw-string transcoding | `@CodableKey(options: .safeTranscodeRawString)` | Decode string-encoded JSON into strongly typed models |
| Lossy collections | `@CodableKey(options: .lossy)` | Drop invalid array, set, or dictionary entries during decode |
| Explicit hooks | `@CodableHook(.didDecode)` | Run validation, normalization, or derived-value logic at clear lifecycle stages |
| Transformer pipelines | `@CodableKey(transformer: MyTransformer())` | Compose reusable decode and encode transformations |
| Transformer pipelines | `@CodableKey(transformer: MyTransformer())` | Compose reusable decode and encode transformations; `DictionaryLookupTransformer` pulls a value out of a decoded dictionary, `liftOptional()` lifts a pipeline to optional input/output, and `onFailure(_:)` observes pipeline errors for logging |
| Derived properties | `@DerivedKey(from: "slots", transformer: MyTransformer())` | Compute typed properties from an already-decoded sibling at the end of `init(from:)` — no coding key, never encoded |

## Targets

Expand Down Expand Up @@ -116,6 +118,30 @@ struct Feed {
}
```

### Derived properties

```swift
@Codable
struct UserConfigInfo {
var userConfigValue: [String: String]?

@DerivedKey(
from: "userConfigValue",
transformer: DictionaryLookupTransformer(key: "avatar_frame")
.chained(RawStringDecodingTransformer<AvatarFrame>().liftOptional())
)
private(set) var avatarFrame: AvatarFrame?
}
```

A derived property has no `CodingKeys` case and is never encoded. Its value is computed at the
end of `init(from:)` by feeding the already-decoded `from:` property through the transformer
pipeline, before any `@CodableHook(.didDecode)` hook runs. The `from:` source must itself be a
decoded stored property of the same type — `.ignored` properties are rejected at compile time.
Optional or defaulted derived properties fall back to `nil`/the default when the pipeline fails;
non-optional ones without a default rethrow from `init(from:)`. Add `.onFailure(_:)` to the
pipeline to log failures that the fallback path would otherwise swallow.

### Dynamic JSON values

```swift
Expand Down Expand Up @@ -158,7 +184,7 @@ swift build -v
swift test -v
```

Tests cover macro expansion, diagnostics, hooks, inheritance, lossy coding, nested keys, and transformer behavior.
Tests cover macro expansion, diagnostics, hooks, inheritance, lossy coding, nested keys, derived properties, and transformer behavior.

## Docs

Expand Down
56 changes: 56 additions & 0 deletions Sources/CodableKit/CodableKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,62 @@ public macro CodableKey(
transformer: any BidirectionalCodingTransformer = NothingToTransform()
) = #externalMacro(module: "CodableKitMacros", type: "CodableKeyMacro")

/// A macro that marks a stored property as *derived*: its value is not decoded from its own
/// coding key, but computed at the end of the generated `init(from:)` by a transformer pipeline
/// whose input is an already-decoded sibling property.
///
/// ## Overview
///
/// Use `@DerivedKey` when a typed property should be materialized from another property that is
/// decoded normally — for example, slot-bag dictionaries with embedded JSON strings:
///
/// ```swift
/// @Codable
/// public struct UserCommonConfigInfo: Sendable, Hashable {
/// public var userConfigValue: [String: UserConfigValue]?
///
/// @DerivedKey(from: "userConfigValue", transformer: AvatarFrameSlotTransformer())
/// public private(set) var avatarFrame: AvatarFrame?
/// }
/// ```
///
/// ## Semantics
///
/// - **No coding key:** derived properties get no `CodingKeys` case and are never read from the
/// decoder.
/// - **Never encoded:** `encode(to:)` skips derived properties entirely.
/// - **Decode ordering:** assignments are emitted at the end of `init(from:)` — after all coded
/// properties are assigned and before any `didDecode` hook runs, so hooks observe derived
/// values. Multiple derived properties are assigned in declaration order.
/// - **Failure policy:** if the property is optional or has a default initializer value, a
/// pipeline failure falls back to `nil`/the default. A non-optional property without a default
/// propagates the error from `init(from:)`. Because the optional/default fallback path swallows
/// pipeline errors via `try?`, attach `.onFailure(_:)` to the transformer pipeline when you
/// need to observe or log those failures.
/// - **Dependencies:** the `from:` property must be a stored property of the same type that is
/// actually decoded — properties marked `.ignored` and inherited superclass properties are not
/// supported as `from:` sources. Deriving from another derived property is not supported and
/// is diagnosed.
/// - **Decode-only:** `@DerivedKey` works under `@Codable` and `@Decodable`; it cannot be used in
/// an `@Encodable`-only type, and it cannot be combined with `@CodableKey`, `@DecodableKey`, or
/// `@EncodableKey` on the same property.
/// - **Hashable/Equatable:** no special handling — derived stored properties participate in
/// whatever synthesis the type already gets.
///
/// The transformer's `Input` must match the source property's type and its `Output` must match
/// the derived property's type; mismatches surface as compile errors in the generated code.
///
/// - Parameters:
/// - property: The name of the sibling stored property whose decoded value feeds the pipeline.
/// Must be a string literal.
/// - transformer: The transformer pipeline applied to the source value. Only the forward
/// direction is used; encoding never needs the reverse direction.
@attached(peer)
public macro DerivedKey(
from property: String,
transformer: any CodingTransformer
) = #externalMacro(module: "CodableKitMacros", type: "DerivedKeyMacro")

// MARK: - DecodeKey / EncodeKey

@attached(peer, names: arbitrary)
Expand Down
15 changes: 15 additions & 0 deletions Sources/CodableKit/CodingTransformerRuntime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,21 @@ public func __ckEncodeTransformedIfPresent<T, K>(
}
}

// MARK: - Derived property support

/// Runtime helper for `@DerivedKey`: feeds an already-decoded sibling value through a
/// one-directional transformer pipeline and returns its output.
///
/// Unlike the key-based helpers above, the input is not read from a decoding container — it is
/// the value of a sibling property that has already been assigned earlier in `init(from:)`.
@inline(__always)
public func __ckDecodeDerived<T>(
transformer: T,
from input: T.Input
) throws -> T.Output where T: CodingTransformer {
try transformer.transform(.success(input)).get()
}

// MARK: - One-way transformer support

@inline(__always)
Expand Down
19 changes: 19 additions & 0 deletions Sources/CodableKit/Transformers/BuiltInTransformers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,22 @@ public struct KeyPathTransformer<T, U: Decodable>: CodingTransformer {
input.map { $0[keyPath: keyPath] }
}
}

/// Looks up a value by key in an optional dictionary.
///
/// A nil dictionary or an absent key produces `nil` rather than an error, so
/// chains can treat missing slots as missing values and continue.
public struct DictionaryLookupTransformer<Key: Hashable, Value>: CodingTransformer {
public typealias Input = [Key: Value]?
public typealias Output = Value?

public let key: Key

public init(key: Key) {
self.key = key
}

public func transform(_ input: Result<[Key: Value]?, any Error>) -> Result<Value?, any Error> {
input.map { $0?[key] }
}
}
44 changes: 44 additions & 0 deletions Sources/CodableKit/Transformers/CodingTransformer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,27 @@ extension CodingTransformer {
public func optional() -> some CodingTransformer<Input, Output?> {
self.chained(Optional())
}

/// Lifts this transformer to operate on optional values.
///
/// A nil input produces a nil output without invoking the base transformer.
/// An upstream failure is forwarded into the base transformer, so
/// failure-recovering transformers (such as `DefaultOnFailureTransformer`)
/// recover when lifted, producing `.some(recovered)`; non-recovering
/// transformers propagate the failure unchanged.
public func liftOptional() -> some CodingTransformer<Input?, Output?> {
OptionalLifted(transformer: self)
}

/// Taps failures for observability without altering the result.
///
/// The handler is invoked whenever the transformed result is a failure; the
/// result is passed through unchanged in all cases.
public func onFailure(
_ handler: @escaping (any Error) -> Void
) -> some CodingTransformer<Input, Output> {
OnFailure(transformer: self, handler: handler)
}
Comment on lines +66 to +70

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.

}

public protocol BidirectionalCodingTransformer<Input, Output>: CodingTransformer {
Expand All @@ -63,6 +84,29 @@ extension BidirectionalCodingTransformer {
public var reversed: some BidirectionalCodingTransformer<Output, Input> {
Reversed(transformer: self)
}

/// Lifts this transformer to operate on optional values in both directions.
///
/// Forward and reverse share the same semantics: a nil input produces a nil
/// output without invoking the base transformer, and an upstream failure is
/// forwarded into the base transformer, so failure-recovering transformers
/// (such as `DefaultOnFailureTransformer`) recover when lifted, producing
/// `.some(recovered)`; non-recovering transformers propagate the failure
/// unchanged.
public func liftOptional() -> some BidirectionalCodingTransformer<Input?, Output?> {
OptionalLifted(transformer: self)
}

/// Taps failures for observability without altering the result.
///
/// The handler is invoked whenever the transformed result is a failure in
/// either direction — both `transform(_:)` and `reverseTransform(_:)` report
/// their failures — and the result is passed through unchanged in all cases.
public func onFailure(
_ handler: @escaping (any Error) -> Void
) -> some BidirectionalCodingTransformer<Input, Output> {
OnFailure(transformer: self, handler: handler)
}
}

extension BidirectionalCodingTransformer where Input == Output {
Expand Down
89 changes: 89 additions & 0 deletions Sources/CodableKit/Transformers/CodingTransformerComposition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,92 @@ struct Optional<T>: CodingTransformer {
}
}
}

/// Lifts a transformer over optional values.
///
/// A nil input passes through as a nil output; a non-nil input runs through
/// the base transformer and its output is wrapped. Failures are forwarded to
/// the base transformer so failure-recovering transformers keep working when
/// lifted.
struct OptionalLifted<T>: CodingTransformer
where
T: CodingTransformer
{
typealias Input = T.Input?
typealias Output = T.Output?

let transformer: T

init(transformer: T) {
self.transformer = transformer
}

func transform(_ input: Result<T.Input?, any Error>) -> Result<T.Output?, any Error> {
switch input {
case .success(.some(let value)):
transformer.transform(.success(value)).map { $0 as T.Output? }
case .success(.none):
.success(nil)
case .failure(let error):
transformer.transform(.failure(error)).map { $0 as T.Output? }
}
}
}

extension OptionalLifted: BidirectionalCodingTransformer
where
T: BidirectionalCodingTransformer
{
func reverseTransform(_ input: Result<T.Output?, any Error>) -> Result<T.Input?, any Error> {
switch input {
case .success(.some(let value)):
transformer.reverseTransform(.success(value)).map { $0 as T.Input? }
case .success(.none):
.success(nil)
case .failure(let error):
transformer.reverseTransform(.failure(error)).map { $0 as T.Input? }
}
}
}

/// Passes results through unchanged while reporting failures to a handler.
///
/// The handler is invoked with the error whenever the transformed result is a
/// failure; it cannot alter the result. Useful for logging malformed payloads
/// that would otherwise be silently swallowed downstream.
struct OnFailure<T>: CodingTransformer
where
T: CodingTransformer
{
typealias Input = T.Input
typealias Output = T.Output

let transformer: T
let handler: (any Error) -> Void

init(transformer: T, handler: @escaping (any Error) -> Void) {
self.transformer = transformer
self.handler = handler
}

func transform(_ input: Result<Input, any Error>) -> Result<Output, any Error> {
let output = transformer.transform(input)
if case .failure(let error) = output {
handler(error)
}
return output
}
}

extension OnFailure: BidirectionalCodingTransformer
where
T: BidirectionalCodingTransformer
{
func reverseTransform(_ input: Result<T.Output, any Error>) -> Result<T.Input, any Error> {
let output = transformer.reverseTransform(input)
if case .failure(let error) = output {
handler(error)
}
return output
}
}
Loading
Loading