Home / Blog / Writing my first Swift macro: killing Codable boilerplate

Writing my first Swift macro: killing Codable boilerplate

I was a consumer of Swift macros in 2024 (@Observable, #Preview). In 2025 I had to write one of my own. A tool designed to remove boilerplate has its own learning curve. Here’s what I got right and what I got wrong. The problem: Codable key mapping boilerplate API responses come back in snake_case, my […]

I was a consumer of Swift macros in 2024 (@Observable, #Preview). In 2025 I had to write one of my own. A tool designed to remove boilerplate has its own learning curve. Here’s what I got right and what I got wrong.

The problem: Codable key mapping boilerplate

API responses come back in snake_case, my models are camelCase. Writing CodingKeys enums by hand got old around the 50th model. JSONDecoder‘s keyDecodingStrategy usually covers it, but a handful of fields always needed custom names.

Where I was:

struct User: Codable {
    let id: Int
    let userName: String
    let createdAt: Date
    enum CodingKeys: String, CodingKey {
        case id
        case userName = "user_name"
        case createdAt = "created_at"
    }
}

Where I wanted to be:

@SnakeCodable
struct User: Codable {
    let id: Int
    let userName: String
    let createdAt: Date
}

Setting up the macro package

Terminal:

swift package init --type macro --name SnakeCodable

There’s an Xcode template too, but SPM gives you a cleaner starting point. The package ends up with two targets: SnakeCodableMacros (the implementation) and SnakeCodable (the public API).

The implementation side

I declared it as a member macro so it adds a new member (the CodingKeys enum) to the struct:

public struct SnakeCodableMacro: MemberMacro {
    public static func expansion(
        of node: AttributeSyntax,
        providingMembersOf declaration: some DeclGroupSyntax,
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        guard let structDecl = declaration.as(StructDeclSyntax.self) else {
            throw MacroError.notAStruct
        }
        let properties = structDecl.memberBlock.members
            .compactMap { $0.decl.as(VariableDeclSyntax.self) }
            .flatMap { $0.bindings.compactMap { $0.pattern.as(IdentifierPatternSyntax.self)?.identifier.text } }
        let cases = properties.map { prop in
            let snake = camelToSnake(prop)
            return prop == snake ? "case (prop)" : "case (prop) = "(snake)""
        }.joined(separator: "n    ")
        return ["""
        enum CodingKeys: String, CodingKey {
            (raw: cases)
        }
        """]
    }
}

camelToSnake helper

private func camelToSnake(_ input: String) -> String {
    var result = ""
    for ch in input {
        if ch.isUppercase {
            if !result.isEmpty { result += "_" }
            result += ch.lowercased()
        } else {
            result.append(ch)
        }
    }
    return result
}

The public API

@attached(member, names: named(CodingKeys))
public macro SnakeCodable() = #externalMacro(
    module: "SnakeCodableMacros",
    type: "SnakeCodableMacro"
)

The names parameter matters. If you don’t tell the compiler which member names you’re producing, it rejects the macro.

First sticking point: nested types

My first version handled nested types like let address: Address fine, but exploded on arrays like let tags: [Tag]. The cause was that I was looking at more than the identifier pattern, I was also reading the type annotation. For CodingKeys you only need the property name, the type doesn’t matter. That mistake cost me half a day. Lesson: macros are hard to debug, start from the smallest possible test case.

Second sticking point: invisible macro output

Before Xcode’s “Expand Macro” feature was reliable, I tried the -dump-macro-expansions flag to see what the macro was producing. Eventually, after Xcode 15.4, right-clicking on the screen and picking Expand Macro just worked. If you don’t know the feature is there, you burn real time in the dark.

Test strategy

The SwiftSyntaxMacrosTestSupport package gives you proper assertion helpers:

import SwiftSyntaxMacrosTestSupport
import XCTest
final class SnakeCodableTests: XCTestCase {
    func testBasicStruct() {
        assertMacroExpansion("""
        @SnakeCodable
        struct User {
            let userName: String
        }
        """, expandedSource: """
        struct User {
            let userName: String
            enum CodingKeys: String, CodingKey {
                case userName = "user_name"
            }
        }
        """, macros: ["SnakeCodable": SnakeCodableMacro.self])
    }
}

When not to write a macro

If you’ll only use the boilerplate once, write a plain function, not a macro. A macro is two or three hours of work and hard to test. If the boilerplate appears in more than ten places, you’ll probably write it ten more times, and now it’s worth it.

Takeaway

Writing a macro isn’t as sweet as using @Observable. But in the right spot, 300 lines of boilerplate collapse into 30. My advice: make your first macro small in scope, spend the time on good error messages, and don’t jump to complex cases. If the macro’s messages are bad, even you’ll stop using it.

Have a project on this topic?

Leave a brief summary — I’ll get back to you within 24 hours.

Get in touch