LenientCodable — resilient Codable decoding with macros (lenient by default, strict by opt-in)

Hey everyone :waving_hand:

I'd like to share LenientCodable, a macro package for resilient Codable decoding.

GitHub: GitHub - EngOmarElsayed/swift-lenient-codable: Swift macros for resilient Codable — one unknown enum case or malformed array element no longer fails your whole response. Lenient by default, strict by explicit opt-in, with compile-time diagnostics and fix-its. · GitHub
Docs: LenientCodable Documentation

The problem

Swift's synthesized decoding is all-or-nothing. One surprise anywhere in the payload — the backend ships an enum case your compiled app doesn't know, one element in a 20-element array is malformed — and the entire response throws. The bug ships silently and detonates the day the API evolves, usually in the oldest app version still installed.

What it looks like

import LenientCodable

@LenientDecodable
struct ApplicationResponse {
    @Strict var applicationId: String        // decode fails if this fails
    var status: Status?                      // lenient by default: nil on any failure
    @NilOnFailure var documents: [Document?] // failed elements → nil in place
    @DropOnFailure var offers: [Offer]       // failed elements → removed
}

@LenientDecodable generates CodingKeys, init(from:), and the Decodable conformance. Unannotated properties are lenient by default — failures degrade into nil (or dropped elements) exactly where they happened, instead of sinking the whole decode.

The design guarantee

The part I care most about: nothing is silently strict and nothing is silently lenient.

Lenient-by-default requires the type to have a nil-shaped hole for the failure to land in (T?, [T?], [K: V?]…). A non-optional Int with no annotation is a compile error with fix-its offering your actual choices: change the type to Int?, or opt out explicitly with @Strict. Every property's failure behavior is readable at its declaration site, and the compiler enforces that the accounting is complete.

A consequence I've come to love: in a @LenientDecodable struct, @Strict properties are the only way a decode can fail — so grep @Strict audits every hard failure point in your models, and the compiler guarantees the list is exhaustive.

Leniency without silent data loss

Every absorbed failure is logged in DEBUG builds via os.Logger (missing keys included — the backend omitting a field is worth knowing about; an explicit JSON null is the one silent case). Release builds compile the logging out entirely. Production degrades gracefully; development stays loud.

Dictionaries are handled with their two distinct failure points (key vs. value), with a small LenientDictionaryKey protocol as the string → key contract — String, Int, and RawRepresentable keys work out of the box.

How it differs from prior art

ResilientDecoding (Airbnb) and BetterCodable solve overlapping problems with property wrappers. LenientCodable being macro-based changes a few things:

  • No wrapper types in your stored propertiesEquatable/Hashable/memberwise-init synthesis are untouched
  • Compile-time shape validation with fix-its, instead of runtime-only behavior
  • Lenient by default with enforced total accounting, rather than opt-in leniency per property
  • Readable generated code — right-click → Expand Macro shows exactly what was written, including provenance comments on implicitly-lenient properties

Honest limitations

Structs only in v1 (classes and enums are a compile error), explicit type annotations required (macros see syntax, not inferred types), and leniency depth is one level — for control inside a nested type, make it @LenientDecodable too; leniency composes by nesting. And to be clear about intent: this is for API evolution, not hiding bugs — anything whose absence should be impossible (IDs, amounts in a payments flow) belongs on @Strict, and the README has a section on exactly that.

Requirements

Swift 6.2+ toolchain (Xcode 26+), deploys back to iOS 13 / macOS 10.15. v1.1.0 is out, MIT licensed, builds on all platforms including Linux, Wasm, and Android, with zero data race safety errors on SPI.

I'd love feedback — on the API surface, the lenient-by-default choice (I know defaulting to leniency is opinionated!), and edge cases you'd expect covered. Issues and PRs are very welcome.

2 Likes

Hi, great tool!

While os.Logger is excellent for debug / stage, many production bugs are only caught at scale after release, even when everything passed testing perfectly. These post-release issues happen for various reasons, such as external data providers breaking API contracts or underlying backend services changing their internal data processing.

In large-scale production environments, relying solely on local system logs is often insufficient. To effectively monitor, alert, and debug these issues in real time, we need the flexibility to route logs directly to third-party monitoring tools and custom infrastructure.

Typically we need to send error logs directly to services like Datadog or Sentry for real-time dashboard tracking. Also we need to capture precise decoding failures information, e.g to have details which elements at which specific indices failed to decode and for what reasons.
In my practice, such error aggregation was done in two phases:

  1. Information is collected for each field of a network response. The default Codable implementation throws only the first error. However, there are cases where several nested structures have different failure reasons. Therefore, errors are first gathered for the entire response.
  2. Such logs are buffered and then sent to the monitoring infrastructure.

I would love to hear if there are any ongoing discussions or plans to open up the logging architecture for dependency injection.

1 Like

I love the idea and will be very happy to work on this together. I am very open to the idea

Ok, I want to share some suggestions and overall shape for logging errors and warnings to external systems:

  • Log injection via closure @Sendable @escaping (LogEntry) -> Void.
  • The library would call this closure when decoding completely fails, or when warnings occur (e.g., overall decoding completed, but errors occurred while decoding nested structures).
    • This closure then forwards logs to concrete loggers like SwiftLog, Sentry, etc.
    • This approach features Zero Dependencies, provides flexibility to route logs to multiple destinations and completely decouples logging from concrete implementations.
  • Structured log information: the logs should provide structured information that is easy to read and understand, including the value of which Type failed to decode and the reason.
  • Default Codable errors often provide enough information, but their default description is not clear and easy to read by monitoring team. This can be addressed by adding helper functions that transform Codable error descriptions into more convenient text.
  • Decoding strategies: besides @NilOnFailure, @DropOnFailure, and @Strict, it is essential to also have the ability to use a default value when decoding fails.
  • Error aggregation (Buffering): errors should be aggregated when no logger is injected yet. There are situations where errors happen before the logger is set up. Logs from this buffer are then forwarded once the logger is injected.
  • Good defaults by build configuration: for Debug builds, os.Logger should be used by default if no closure was injected. For Release builds, only the injected closure should be called.
1 Like

I really love your suggestions, can you open issues on the repo with those suggestions and i will work on the new release to include those enhancments in the up coming days.

And you are always welcome if you want to contrubite to the repo by a PR :grin:

Thanks so much for those suggestions

I have some questions:

  1. Naming. @DefaultOnFailure is consistent with the existing ...OnFailure family. @Default(_:) is shorter but reads like "default value when absent," which under-describes the trigger set. Preference?

  2. Element-level defaults. Is there demand for [T] where a failed element is replaced by a default rather than dropped or nil'd? Could be a later @DefaultOnFailure(element:) overload; I'd keep it out of v1.

  3. Dictionaries. With two failure points (key vs. value), does a property-level default cover both, or is a value-level variant needed?

  4. Autoclosure. Should the argument be @autoclosure () -> T so an expensive default isn't evaluated on the happy path? Macro expansion places it inside the catch, so this may be moot — worth confirming against the generated code.

  1. Naming. LenientDecodingLog vs LogEntry vs LenientDecodingEvent. LogEntry is unqualified for a public type in a package that also exports macros.
  2. Underlying error. Should the payload carry the original any Error? It's the thing a debugger wants and it's the thing that breaks SendableDecodingError.Context.underlyingError is untyped. Options: omit it, keep the pre-rendered summary as the lossy-but-safe answer, or @unchecked Sendable and document the hazard. Leaning omit for v1.
  3. Global vs per-decode scoping. A global handler is simple but process-wide. The generated init(from:) has the decoder in hand, so decoder.userInfo[.lenientLogHandler] would allow per-JSONDecoder routing — useful when one app has several backends with different noise tolerances. userInfo is [CodingUserInfoKey: Any] and therefore its own concurrency wart. Support both, with global as the fallback?
  4. Explicit null. Currently the one silent case. Now that there's a production sink, is it worth emitting as a distinct Reason.explicitNull warning that callers can filter, rather than not emitting at all?
  5. Rate limiting. A malformed 500-element array produces 500 entries. Library concern or caller concern? I'd say caller, but it should be called out in the docs.
  1. Naming. @DefaultOnFailure is self describing. Most of the time code is read and less of time is written. Therefore @DefaultOnFailure is more preferable for those who read the code rather, whereas shorter variant only benefits for those who write.

  2. Element-level defaults. In my past projects, I have never encountered a requirement to replace failed array elements with a default value. Most of the cases I have seen involved arrays of models with 3–20 properties, where a single default fallback doesn't make sense. For example, if we receive an array of flight data with departure and arrival times, there is no meaningful default value we can display to the user. This feature can be deferred until strong motivating use cases emerge.

  3. Dictionaries. In my experience, dictionaries usually require non-trivial validation. This includes verifying relationships between key-value pairs and enforcing business rules, rather than just handling a simple missing key or value mismatch.
    For instance, if the server returns a configuration dictionary (like currency transfer limits), business logic often dictates that these values must be strictly consistent. Consider a scenario where a "premium_user" key is true; the corresponding "max_transfer_limit" key in the same dictionary must be a valid number. If that number fails to decode, simply slapping a default zero there breaks the business logic for that premium user.
    Because of this complexity, I see only three viable options when a dictionary fails validation:

  • Replace the entire dictionary with an empty one ([:]).
  • Replace the entire dictionary with a nil value.
  • Throw a decoding error.
    If we are dealing with an array of dictionaries, a reasonable approach would be to skip the specific array elements that failed decoding. However, that capability is already handled by the existing @DropOnFailure wrapper.
    Therefore, a specialized property-level default wrapper for dictionary elements seems not necessary, at least for now.
  1. Autoclosure. Here are my thoughts:
    While pure macro-inlining inside a catch block also provides lazy evaluation as @autoclosure, keeping @autoclosure additionally ensures API resilience:
    • Future-proofing: If implementation changes e.g. from inlining to passing the default value into a backing property wrapper, omitting @autoclosure will silently break lazy evaluation.
    • No syntax limits: Swift infers type context through the macro, supporting both implicit member syntax (e.g. .express) and explicit function calls.
    • Expression execution: It guarantees runtime-dependent defaults evaluate precisely when the failure occurs, not during initialization.
1 Like

Here are my thoughts on the logging and error handling:

  1. Naming. LenientDecodingLog is highly understandable. For convenience within the library internals, a typealias like LogEntry can be used.

  2. Underlying Error and Sendability. The Error protocol inherits from Sendable, so providing the underlying error should not break Sendable requirements. Retaining the underlying error is critical for production incident investigations.
    Alternatively, the log payload can use a [String: any Sendable & CustomStringConvertible] dict. Under the hood all Any instances can be conditionally casted to Error, String, Int, UInt and several others, while other types fallback to their string representation.

  3. Global vs. Per-Decode Scoping. Utilizing decoder.userInfo[.lenientLogHandler] is an excellent approach. The hierarchy should prioritize the local handler from decoder.userInfo, falling back to the global handler only if the local one is absent.

  4. Explicit null. If you are referring to @NilOnFailure, any failed value that gets replaced by nil should be logged exactly like any other decoding failure.

  5. Rate Limiting and Aggregation. To prevent log bloating while retaining diagnostic value, I propose the following strategy:

    • Array Limits: Log only the first 3 unique (distinct) errors inside a single array.
    • Global Limits: Set a total capacity of 3 distinct error slots per decoded object instance. If an array emits multiple errors (e.g., via @DropOnFailure), the entire array's failure batch consumes only 1 of these global slots, collecting up to 3 unique errors within that batch.
    • Fallback Counter: Once all 3 global slots are filled, subsequent errors should be aggregated into a counter map. The map key can use an error identity string (like a combination of domain and code, or type name) to group and count identical failure types.

    In my experience, large arrays using @DropOnFailure sometimes fail due to diverse underlying reasons, so tracking distinct types is valuable. Users who want to process all errors manually can override this limit by injecting a custom threshold (e.g., Int.max) into the global or local logger config.

In my personal practice such capacity is enough as a reasonable defaults.

1 Like

Additionally, it would be helpful if all provided macros supported a custom decoding key for the property.

I totally agree about your thoughts for LenientDecodingLog , really appreciate it

what do you mean by this ?

For the defualts for the array and the Dictionaries:

  • In case it’s an array if the decoding failed for any element the array will be replaced with defualt value same thing for the Dictionaries.

Currently, if a developer wants to use a custom JSON key for just one property, Swift's Codable forces to write CodingKeys enum for all properties.
What I mean is allowing the macro to accept an optional key argument like this:

struct Flight: Codable {    
    @Strict(key: "arrival_time_at_destination") 
    var arrivalTime: String
}

If someone wants something like that he can just add CodingKeys and the macro will detect it like so:

struct Flight: Codable {    
    @Strict 
    var arrivalTime: String

    enum CodingKeys: String, CodingKey {
        case arrivalTime  = “arrival_time_at_destination"
    }
}