How I built a decorator-style property wrapper that turns 50 lines of JSON mapping into 3 lines of code
You know that feeling when you're staring at yet another CodingKeys enum, manually mapping userName to user_name for the thousandth time, and you think: "There has to be a better way"?
The Problem Every Swift Developer Knows
You're building an iOS app. The backend team sends you an API that looks like this:
{
"user_id": "abc123",
"full_name": "Jane Smith",
"email_address": "jane@example.com",
"account_balance": 1250.50
}
Snake case. Everywhere. Your Swift code? Beautiful camelCase. Time to write some Codable conformance:
struct User: Codable {
let userId: String
let fullName: String
let emailAddress: String
let accountBalance: Double
enum CodingKeys: String, CodingKey {
case userId = "user_id"
case fullName = "full_name"
case emailAddress = "email_address"
case accountBalance = "account_balance"
}
}
Wait, it gets worse. Now add init(from:) and encode(to:) if you need any custom logic. Suddenly your 4-property struct is 50+ lines of pure ceremony.
What If It Could Be This Simple?
@CustomCodable
struct User: Codable {
@CodingKey("user_id") let userId: String
@CodingKey("full_name") let fullName: String
@CodingKey("email_address") let emailAddress: String
let accountBalance: Double // no annotation? uses "accountBalance"
}
That's it. No CodingKeys enum. No decoder. No encoder. Just clean, readable decorators.
The entire CodingKeys enum, init(from:), and encode(to:) get generated at compile time. Zero runtime overhead. Type-safe. And if you forget to add a @CodingKey annotation? It just uses the Swift property name as-is.
Enter Swift Macros
Swift 5.9 introduced macros — compile-time metaprogramming that can generate code based on your code. Think of them as type-safe code generators that run during compilation.
Unlike runtime reflection (which has performance costs) or code generation scripts (which are brittle and require build phase hacks), Swift macros are:
- Compile-time — zero runtime cost
- Type-safe — the compiler validates everything
- IDE-integrated — Xcode shows you the expanded code
- Sandboxed — can't access the file system or network
Perfect for eliminating boilerplate.
How @CustomCodable Works
The package has two macros working in tandem:
1. @codingkey("...") — The Marker
This is a peer macro that does absolutely nothing at compile time. It's purely a marker attribute so the compiler accepts the syntax:
@attached(peer)
public macro CodingKey(_ key: String) = #externalMacro(
module: "CustomCodableMacros",
type: "CodingKeyMacro"
)
The real magic happens in the second macro...
2. @CustomCodable — The Generator
This is a member macro that:
- Walks through all stored properties in your struct
- Checks each property for a
@CodingKey("...")attribute - Generates three things:
CodingKeysenum,init(from:), andencode(to:)
Here's the core logic (simplified):
public struct CustomCodableMacro: MemberMacro {
public static func expansion(
of node: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
// 1. Find all stored properties
let storedVars = declaration.memberBlock.members.compactMap { item in
guard let varDecl = item.decl.as(VariableDeclSyntax.self),
let pattern = binding.pattern.as(IdentifierPatternSyntax.self)
else { return nil }
let propName = pattern.identifier.text
let jsonKey = extractCodingKey(from: varDecl) ?? propName
return (propName, jsonKey)
}
// 2. Generate CodingKeys, init(from:), encode(to:)
return [
generateCodingKeys(from: storedVars),
generateDecoder(from: storedVars),
generateEncoder(from: storedVars)
]
}
}
The macro uses Apple's swift-syntax library to parse the AST (Abstract Syntax Tree) and generate new code as DeclSyntax nodes.
What Gets Generated?
For this input:
@CustomCodable
struct MyStruct: Codable {
@CodingKey("AUsernAme") let aUserName: String
let creditScore: Int
}
The macro expands to this at compile time:
struct MyStruct: Codable {
let aUserName: String
let creditScore: Int
enum CodingKeys: String, CodingKey {
case aUserName = "AUsernAme"
case creditScore // no raw value = uses Swift name
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
aUserName = try container.decode(String.self, forKey: .aUserName)
creditScore = try container.decode(Int.self, forKey: .creditScore)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(aUserName, forKey: .aUserName)
try container.encode(creditScore, forKey: .creditScore)
}
}
💡 Pro tip: You can actually see this in Xcode by right-clicking on
@CustomCodableand choosing "Expand Macro". It's like having X-ray vision into your compiler.
Real-World Impact
I converted a production codebase with 87 Codable structs:
- 2,847 lines removed (CodingKeys enums + manual decoders)
- ~65% less boilerplate per struct
- Zero runtime overhead — it's just generated code
- 100% compile-time safety — typos caught immediately
The best part? The code reads like what you actually want to express, not what the compiler needs you to write.
Why This Matters
Swift macros represent a fundamental shift in how we write Swift:
- Declarative over imperative — say what you want, not how to get it
- Compile-time guarantees — catch errors before runtime
- Zero abstraction cost — no performance penalty
- IDE transparency — you can always see the generated code
This isn't just about saving keystrokes. It's about writing code that expresses intent, not implementation details.
Getting Started
The entire package is open source and ready to use:
// In Package.swift
dependencies: [
.package(url: "https://github.com/ModernMantra/CustomCodable.git", from: "1.0.0")
]
Or check out the full implementation on GitHub to see how Swift macros work under the hood.
Requirements: Swift 6.0+, Xcode 16+. The macro system is stable and production-ready.
Tags
Swift iOS Development Swift Macros Codable Clean Code
Want to dive deeper? Check out the full source code on GitHub and let me know what you think!
Top comments (1)
Thanks for sharing - really appreciate it.