Swift Macro’ları 2024’te tüketici olarak kullanıyordum (@Observable, #Preview). 2025’te kendi macro’umu yazmam gerekti. Boilerplate kaldırmak için yazılmış bir tool’un öğrenme eğrisi de öyle. Başardığım ve başaramadığım yerleri paylaşayım.
Problem: Codable key mapping boilerplate
API response’ları snake_case, modellerim camelCase. Her struct için CodingKeys enum’u elle yazmak 50 modelden sonra yorucu olmaya başladı. JSONDecoder‘ın keyDecodingStrategy‘si genelde yeter ama bazı field’larda custom isim gerekiyordu.
Elimde şu vardı:
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"
}
}Hedefim şuydu:
@SnakeCodable
struct User: Codable {
let id: Int
let userName: String
let createdAt: Date
}Macro package kurulumu
Terminal:
swift package init --type macro --name SnakeCodableXcode template’i da var ama SPM daha temiz bir başlangıç veriyor. Package içinde iki target oluyor: SnakeCodableMacros (macro implementation) ve SnakeCodable (public API).
Implementation tarafı
Member macro olarak tanımladım; struct’a yeni member (CodingKeys enum) ekleyecek:
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
}Public API
@attached(member, names: named(CodingKeys))
public macro SnakeCodable() = #externalMacro(
module: "SnakeCodableMacros",
type: "SnakeCodableMacro"
)names parametresi önemli; eklediğin member isimlerini compiler’a bildirmezsen macro reddediliyor.
İlk güçlük: nested type’lar
İlk versiyonumda let address: Address gibi nested type’ları doğru işliyordum ama let tags: [Tag] dizilerinde patladı. Çünkü sadece identifier pattern değil aynı zamanda type annotation’a bakmam gerekiyordu; normalde CodingKeys için sadece property name lazım, type önemli değil. Bu yanılgım için yarım gün kaybettim. Ders: macro debug etmek zor, minimal test case’le başlamak şart.
İkinci güçlük: macro’nun görünmeyen output’u
Xcode’un “Expand Macro” özelliği yokken macro’nun ne ürettiğini görmek için -dump-macro-expansions flag’ini denedim. Sonunda Xcode 15.4’ten sonra ekrana sağ tıklayıp Expand Macro direkt çalıştı. Debug sürecinde bu özelliği bilmeden vakit harcıyorsun.
Test stratejisi
SwiftSyntaxMacrosTestSupport paketi assertion helper’ları sunuyor:
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])
}
}Ne zaman macro yazmamalı
Tek sefer kullanacağın bir boilerplate için macro yazma; sade function yaz. Macro yazmak 2-3 saatlik bir iş ve test etmesi zor. Boilerplate 10’dan fazla yerde aynıysa, muhtemelen 10 kere daha yazacaksın; o zaman değer.
Sonuç
Macro yazmak @Observable kullanmak kadar şeker değil. Ama doğru yerde kullanırsan 300 satır boilerplate 30 satıra iniyor. Tavsiyem: ilk macro’nu küçük bir scope’ta yaz, hata mesajlarını iyi olması için emek ver, karmaşık case’lere atlama. Macro’nun mesajı kötüyse sen de kullanmayı bırakırsın.