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 SnakeCodableThere’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.