Swift Macros: contoh praktis metaprogramming
Panduan lengkap Swift Macros: pembuatan macro freestanding dan attached, manipulasi AST dengan swift-syntax, serta contoh praktis untuk menghilangkan kode berulang.

Swift Macros yang diperkenalkan bersama Swift 5.9 dan Xcode 15 merupakan revolusi dalam cara penulisan kode Swift. Fitur ini memungkinkan pembuatan kode pada saat kompilasi sehingga boilerplate hilang tanpa mengorbankan keamanan tipe statis. Berbeda dengan macro preprocessor C, Swift Macros aman secara tipe, terintegrasi dengan compiler, dan didukung penuh oleh tooling pengembangan.
Panduan ini mengupas pembuatan Swift Macros dari awal hingga akhir: mulai dari konsep dasar hingga implementasi tingkat lanjut, lengkap dengan contoh kode siap pakai untuk proyek iOS apa pun.
Memahami jenis Swift Macros
Swift menyediakan dua kategori utama macro, masing-masing dengan kasus pakai berbeda. Macro freestanding bekerja secara mandiri sebagai ekspresi atau deklarasi, sedangkan macro attached terikat pada deklarasi yang sudah ada untuk memodifikasi atau memperkayanya.
Macro freestanding: ekspresi dan deklarasi
Macro freestanding diawali dengan simbol # dan dapat mengembalikan nilai (ekspresi) atau membuat deklarasi baru. Berikut contoh konkret macro ekspresi:
// Freestanding expression macro - generates a value
let buildInfo = #buildDate
// Expansion → "2026-03-11 10:30:45"
// Freestanding macro with arguments
let message = #stringify(1 + 2)
// Expansion → "1 + 2 = 3"
// Freestanding declaration macro - creates declarations
#makeCase("success", "failure", "pending")
// Expansion →
// case success
// case failure
// case pendingPerbedaan mendasar antara ekspresi dan deklarasi terletak pada hasilnya: ekspresi menghasilkan nilai, sedangkan deklarasi menghasilkan kode struktural (tipe, fungsi, variabel).
Macro attached: lima peran
Macro attached diawali @ dan ditempatkan di depan sebuah deklarasi. Swift menetapkan lima peran berbeda untuk macro ini:
// @attached(peer) - adds declarations at the same level
@AddAsync
func fetchUser(id: Int) -> User { ... }
// Expansion → adds func fetchUserAsync(id: Int) async -> User
// @attached(accessor) - adds getters/setters
@UserDefault("theme")
var currentTheme: String
// Expansion → adds get { UserDefaults.standard.string(...) }
// @attached(member) - adds members to a type
@AutoEquatable
struct Point {
var x: Int
var y: Int
}
// Expansion → adds static func == (lhs: Point, rhs: Point) -> Bool
// @attached(memberAttribute) - applies attributes to members
@CodableKeys
struct Config {
var apiUrl: String
var timeout: Int
}
// Expansion → adds @CodingKey("api_url") before apiUrl
// @attached(conformance) / @attached(extension) - adds conformances
@Hashable
struct User {
var id: Int
var name: String
}
// Expansion → adds extension User: Hashable { ... }Peran-peran tersebut dapat dikombinasikan untuk membentuk macro tangguh yang mengubah kode pada beberapa dimensi sekaligus.
Penyiapan proyek untuk membuat macro
Membuat Swift Macros memerlukan Swift Package dengan struktur tertentu. Paket bergantung pada swift-syntax, pustaka resmi untuk memanipulasi kode Swift sebagai abstract syntax tree (AST).
Struktur Package.swift
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "MyMacros",
platforms: [.macOS(.v10_15), .iOS(.v13)],
products: [
// Library exposing macros to the main project
.library(
name: "MyMacros",
targets: ["MyMacros"]
),
// Executable for testing macros
.executable(
name: "MyMacrosClient",
targets: ["MyMacrosClient"]
)
],
dependencies: [
// Required dependency for macros
.package(
url: "https://github.com/apple/swift-syntax.git",
from: "509.0.0"
)
],
targets: [
// Compiler plugin containing implementation
.macro(
name: "MyMacrosPlugin",
dependencies: [
.product(name: "SwiftSyntax", package: "swift-syntax"),
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
.product(name: "SwiftCompilerPlugin", package: "swift-syntax")
]
),
// Target exposing macro declarations
.target(
name: "MyMacros",
dependencies: ["MyMacrosPlugin"]
),
// Test client
.executableTarget(
name: "MyMacrosClient",
dependencies: ["MyMacros"]
),
// Unit tests
.testTarget(
name: "MyMacrosTests",
dependencies: [
"MyMacrosPlugin",
.product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax")
]
)
]
)Konfigurasi ini memisahkan deklarasi macro (yang dilihat kode klien) dari implementasinya (yang dijalankan saat kompilasi) secara jelas.
Minimal tiga berkas dibutuhkan: MyMacros.swift untuk deklarasi, MyMacrosPlugin.swift untuk implementasi, dan MyMacrosTests.swift untuk pengujian. Pemisahan ini memudahkan perawatan.
Membuat macro ekspresi
Macro ekspresi menghasilkan nilai yang langsung dipakai dalam kode. Berikut contoh pembuatan macro #unwrap yang membuka optional dengan pesan kesalahan kustom yang menyertakan nama variabel.
Deklarasi macro
import Foundation
/// Macro that unwraps an optional with an explicit error message
/// Usage: let value = #unwrap(optionalValue)
/// Expansion: guard let optionalValue else { fatalError("...") }; optionalValue
@freestanding(expression)
public macro unwrap<T>(_ value: T?) -> T = #externalMacro(
module: "MyMacrosPlugin",
type: "UnwrapMacro"
)Tanda tangan menyatakan bahwa macro menerima sebuah optional dan mengembalikan nilai non-optional. #externalMacro mengarah ke implementasi di dalam plugin.
Implementasi dengan swift-syntax
import SwiftSyntax
import SwiftSyntaxMacros
import SwiftCompilerPlugin
public struct UnwrapMacro: ExpressionMacro {
public static func expansion(
of node: some FreestandingMacroExpansionSyntax,
in context: some MacroExpansionContext
) throws -> ExprSyntax {
// Get the first argument passed to the macro
guard let argument = node.argumentList.first?.expression else {
throw MacroError.missingArgument
}
// Extract the variable name for the error message
let variableName = argument.description.trimmingCharacters(
in: .whitespacesAndNewlines
)
// Generate the expansion code
// Uses an immediately-invoked closure to encapsulate the guard
return """
{
guard let value = \(argument) else {
fatalError("Failed to unwrap '\\(\(literal: variableName))' - value was nil")
}
return value
}()
"""
}
}
// Custom errors for macros
enum MacroError: Error, CustomStringConvertible {
case missingArgument
case invalidSyntax(String)
var description: String {
switch self {
case .missingArgument:
return "The macro requires an argument"
case .invalidSyntax(let message):
return "Invalid syntax: \(message)"
}
}
}Metode expansion menerima node AST yang merepresentasikan pemanggilan macro dan konteks kompilasi. Metode tersebut mengembalikan ExprSyntax berisi kode yang dihasilkan.
Pendaftaran plugin
import SwiftCompilerPlugin
import SwiftSyntaxMacros
@main
struct MyMacrosPlugin: CompilerPlugin {
// List all macros provided by this plugin
let providingMacros: [Macro.Type] = [
UnwrapMacro.self,
// Add other macros here
]
}Titik masuk ini memberi tahu compiler tentang macro yang tersedia di dalam plugin.
Siap menguasai wawancara iOS Anda?
Berlatih dengan simulator interaktif, flashcards, dan tes teknis kami.
Membuat macro attached jenis member
Macro member menambahkan anggota (properti, metode, tipe bersarang) ke tipe yang sudah ada. Berikut macro @AutoInit yang otomatis menghasilkan inisialisasi dengan seluruh stored property.
Deklarasi dan implementasi lengkap
/// Automatically generates an initializer with all stored properties
@attached(member, names: named(init))
public macro AutoInit() = #externalMacro(
module: "MyMacrosPlugin",
type: "AutoInitMacro"
)import SwiftSyntax
import SwiftSyntaxMacros
public struct AutoInitMacro: MemberMacro {
public static func expansion(
of node: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
// Verify the macro is applied to a struct or class
guard declaration.is(StructDeclSyntax.self) ||
declaration.is(ClassDeclSyntax.self) else {
throw MacroError.invalidSyntax(
"@AutoInit can only be applied to structs and classes"
)
}
// Collect stored properties
let properties = declaration.memberBlock.members
.compactMap { $0.decl.as(VariableDeclSyntax.self) }
.filter { isStoredProperty($0) }
// Generate initializer parameters
let parameters = properties.compactMap { property -> String? in
guard let binding = property.bindings.first,
let identifier = binding.pattern.as(IdentifierPatternSyntax.self),
let type = binding.typeAnnotation?.type else {
return nil
}
let name = identifier.identifier.text
let typeName = type.description.trimmingCharacters(in: .whitespaces)
// Check if the property has a default value
if binding.initializer != nil {
return "\(name): \(typeName) = \(binding.initializer!.value)"
}
return "\(name): \(typeName)"
}
// Generate assignments in the init body
let assignments = properties.compactMap { property -> String? in
guard let binding = property.bindings.first,
let identifier = binding.pattern.as(IdentifierPatternSyntax.self) else {
return nil
}
let name = identifier.identifier.text
return "self.\(name) = \(name)"
}
// Build the complete initializer
let initDecl: DeclSyntax = """
public init(\(raw: parameters.joined(separator: ", "))) {
\(raw: assignments.joined(separator: "\n "))
}
"""
return [initDecl]
}
// Check if a variable is a stored property (not computed)
private static func isStoredProperty(_ variable: VariableDeclSyntax) -> Bool {
guard let binding = variable.bindings.first else { return false }
// A computed property has an accessor block with get/set
if let accessor = binding.accessorBlock {
// If it's a block with explicit accessors, it's computed
if accessor.accessors.is(AccessorDeclListSyntax.self) {
return false
}
}
// let or var without accessor = stored property
return true
}
}Penggunaan macro AutoInit
@AutoInit
struct User {
let id: UUID
var name: String
var email: String
var isActive: Bool = true
}
// Automatically generated code:
// public init(id: UUID, name: String, email: String, isActive: Bool = true) {
// self.id = id
// self.name = name
// self.email = email
// self.isActive = isActive
// }
// Usage
let user = User(id: UUID(), name: "Alice", email: "alice@example.com")
// isActive uses the default valueMacro ini meniadakan boilerplate inisialisasi, sangat berguna pada model data dengan banyak properti.
Macro attached peer untuk membuat versi async
Macro peer menambahkan deklarasi di tingkat yang sama dengan deklarasi yang dianotasi. Berikut macro @AddAsync yang membuat versi async dari fungsi berbasis completion handler.
/// Automatically generates an async version of a function with completion handler
@attached(peer, names: suffixed(Async))
public macro AddAsync() = #externalMacro(
module: "MyMacrosPlugin",
type: "AddAsyncMacro"
)import SwiftSyntax
import SwiftSyntaxMacros
public struct AddAsyncMacro: PeerMacro {
public static func expansion(
of node: AttributeSyntax,
providingPeersOf declaration: some DeclSyntax,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
// Verify it's a function
guard let funcDecl = declaration.as(FunctionDeclSyntax.self) else {
throw MacroError.invalidSyntax(
"@AddAsync requires a function"
)
}
let functionName = funcDecl.name.text
let asyncFunctionName = "\(functionName)Async"
// Analyze parameters to find the completion handler
let parameters = funcDecl.signature.parameterClause.parameters
// Filter parameters (exclude completion handler)
var regularParams: [String] = []
var completionType: String? = nil
for param in parameters {
let paramType = param.type.description
// Detect a completion handler (closure with Result or simple value)
if paramType.contains("->") && paramType.contains("Void") {
// Extract the return type from completion
completionType = extractCompletionReturnType(from: paramType)
} else {
let paramName = param.firstName.text
let paramSecondName = param.secondName?.text
let label = paramSecondName ?? paramName
regularParams.append("\(paramName): \(paramType)")
}
}
guard let returnType = completionType else {
throw MacroError.invalidSyntax(
"No completion handler found"
)
}
// Generate arguments for internal call
let callArgs = parameters.dropLast().map { param in
let name = param.firstName.text
return "\(name): \(name)"
}.joined(separator: ", ")
// Generate the async function
let asyncFunc: DeclSyntax = """
func \(raw: asyncFunctionName)(\(raw: regularParams.joined(separator: ", "))) async throws -> \(raw: returnType) {
try await withCheckedThrowingContinuation { continuation in
\(raw: functionName)(\(raw: callArgs.isEmpty ? "" : callArgs + ", ")completion: { result in
switch result {
case .success(let value):
continuation.resume(returning: value)
case .failure(let error):
continuation.resume(throwing: error)
}
})
}
}
"""
return [asyncFunc]
}
// Extract return type from a Result type
private static func extractCompletionReturnType(from type: String) -> String {
// Simplified pattern - in production, use the AST
if let match = type.range(of: #"Result<([^,]+)"#, options: .regularExpression) {
var result = String(type[match])
result = result.replacingOccurrences(of: "Result<", with: "")
return result.trimmingCharacters(in: .whitespaces)
}
return "Void"
}
}Demonstrasi macro AddAsync
class NetworkService {
@AddAsync
func fetchUser(
id: Int,
completion: @escaping (Result<User, Error>) -> Void
) {
// Implementation with callback
URLSession.shared.dataTask(with: URL(string: "/users/\(id)")!) { data, _, error in
if let error = error {
completion(.failure(error))
} else if let data = data {
let user = try? JSONDecoder().decode(User.self, from: data)
completion(.success(user!))
}
}.resume()
}
// Automatically generates:
// func fetchUserAsync(id: Int) async throws -> User {
// try await withCheckedThrowingContinuation { continuation in
// fetchUser(id: id, completion: { result in
// switch result {
// case .success(let value):
// continuation.resume(returning: value)
// case .failure(let error):
// continuation.resume(throwing: error)
// }
// })
// }
// }
}
// Modern usage with async/await
let user = try await networkService.fetchUserAsync(id: 42)Nama fungsi yang dihasilkan harus dideklarasikan pada names: atribut @attached. Di sini suffixed(Async) menunjukkan bahwa fungsi yang dihasilkan akan menambahkan akhiran "Async" pada nama aslinya.
Pengujian unit untuk macro
Menguji macro sangat penting karena macro menghasilkan kode yang akan dikompilasi. Swift menyediakan SwiftSyntaxMacrosTestSupport untuk memudahkan pengujian semacam ini.
import SwiftSyntaxMacros
import SwiftSyntaxMacrosTestSupport
import XCTest
@testable import MyMacrosPlugin
final class MyMacrosTests: XCTestCase {
// Dictionary of macros to test
let testMacros: [String: Macro.Type] = [
"unwrap": UnwrapMacro.self,
"AutoInit": AutoInitMacro.self,
"AddAsync": AddAsyncMacro.self
]
func testUnwrapMacroExpansion() throws {
assertMacroExpansion(
"""
let value = #unwrap(optionalString)
""",
expandedSource: """
let value = {
guard let value = optionalString else {
fatalError("Failed to unwrap 'optionalString' - value was nil")
}
return value
}()
""",
macros: testMacros
)
}
func testAutoInitMacroWithStruct() throws {
assertMacroExpansion(
"""
@AutoInit
struct Point {
let x: Int
var y: Int
}
""",
expandedSource: """
struct Point {
let x: Int
var y: Int
public init(x: Int, y: Int) {
self.x = x
self.y = y
}
}
""",
macros: testMacros
)
}
func testAutoInitWithDefaultValues() throws {
assertMacroExpansion(
"""
@AutoInit
struct Config {
var timeout: Int = 30
var retryCount: Int
}
""",
expandedSource: """
struct Config {
var timeout: Int = 30
var retryCount: Int
public init(timeout: Int = 30, retryCount: Int) {
self.timeout = timeout
self.retryCount = retryCount
}
}
""",
macros: testMacros
)
}
func testAutoInitFailsOnEnum() throws {
assertMacroExpansion(
"""
@AutoInit
enum Status {
case active
}
""",
expandedSource: """
enum Status {
case active
}
""",
diagnostics: [
DiagnosticSpec(
message: "@AutoInit can only be applied to structs and classes",
line: 1,
column: 1
)
],
macros: testMacros
)
}
}Pengujian ini memverifikasi ekspansi kode yang benar dan pesan kesalahan yang sesuai untuk penggunaan tidak valid.
Siap menguasai wawancara iOS Anda?
Berlatih dengan simulator interaktif, flashcards, dan tes teknis kami.
Macro lanjutan: property wrapper observable
Macro ini menggabungkan beberapa peran untuk membentuk sistem observasi properti dengan notifikasi otomatis.
/// Adds automatic property change observation
@attached(accessor)
@attached(peer, names: prefixed(_))
public macro Observable() = #externalMacro(
module: "MyMacrosPlugin",
type: "ObservableMacro"
)import SwiftSyntax
import SwiftSyntaxMacros
// Implements both roles: accessor and peer
public enum ObservableMacro {}
extension ObservableMacro: AccessorMacro {
public static func expansion(
of node: AttributeSyntax,
providingAccessorsOf declaration: some DeclSyntax,
in context: some MacroExpansionContext
) throws -> [AccessorDeclSyntax] {
guard let varDecl = declaration.as(VariableDeclSyntax.self),
let binding = varDecl.bindings.first,
let identifier = binding.pattern.as(IdentifierPatternSyntax.self) else {
return []
}
let name = identifier.identifier.text
let storageName = "_\(name)"
// Generate get and set accessors
let getter: AccessorDeclSyntax = """
get {
access(keyPath: \\.\(raw: name))
return \(raw: storageName)
}
"""
let setter: AccessorDeclSyntax = """
set {
withMutation(keyPath: \\.\(raw: name)) {
\(raw: storageName) = newValue
}
}
"""
return [getter, setter]
}
}
extension ObservableMacro: PeerMacro {
public static func expansion(
of node: AttributeSyntax,
providingPeersOf declaration: some DeclSyntax,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
guard let varDecl = declaration.as(VariableDeclSyntax.self),
let binding = varDecl.bindings.first,
let identifier = binding.pattern.as(IdentifierPatternSyntax.self),
let type = binding.typeAnnotation?.type else {
return []
}
let name = identifier.identifier.text
let storageName = "_\(name)"
let typeName = type.description
// Generate private storage property
let initializer = binding.initializer.map { " \($0)" } ?? ""
let storageDecl: DeclSyntax = """
private var \(raw: storageName): \(raw: typeName)\(raw: initializer)
"""
return [storageDecl]
}
}Penggunaan pola Observable
@Observable
class UserViewModel {
@Observable var name: String = ""
@Observable var age: Int = 0
@Observable var isActive: Bool = true
// Generated code for each property:
// private var _name: String = ""
// var name: String {
// get {
// access(keyPath: \.name)
// return _name
// }
// set {
// withMutation(keyPath: \.name) {
// _name = newValue
// }
// }
// }
}Apple memakai pola yang sama pada framework Observation baru sejak Swift 5.9.
Debugging dan inspeksi macro
Xcode menyediakan beberapa alat untuk men-debug macro dan memahami kode yang dihasilkan.
Ekspansi di Xcode
// Right-click on macro call → "Expand Macro"
// Displays generated code inline
@AutoInit
struct Product {
let id: UUID
var name: String
var price: Decimal
}
// To see the expansion:
// 1. Right-click on @AutoInit
// 2. Select "Expand Macro"
// 3. Generated code displays inline for inspection and debuggingLogging selama pengembangan
public struct DebugMacro: ExpressionMacro {
public static func expansion(
of node: some FreestandingMacroExpansionSyntax,
in context: some MacroExpansionContext
) throws -> ExprSyntax {
// Print the node's AST to understand the structure
print("=== DEBUG MACRO ===")
print("Node: \(node)")
print("Arguments: \(node.argumentList)")
// Complete dump of the syntax tree
dump(node)
// Continue with normal expansion
return "42"
}
}Menjelajahi AST dengan swift-ast-explorer
Alat daring swift-ast-explorer.com memungkinkan visualisasi pohon sintaksis untuk kode Swift apa pun. Sangat penting untuk memahami cara menelusuri node AST saat mengimplementasikan macro.
Praktik terbaik Swift Macros
Membuat macro yang mudah dirawat membutuhkan kepatuhan pada konvensi tertentu serta menghindari jebakan umum.
Validasi dan pesan kesalahan
public struct ValidatedMacro: MemberMacro {
public static func expansion(
of node: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
// ✅ Validate usage context
guard declaration.is(StructDeclSyntax.self) else {
// ✅ Clear error messages with possible localization
context.diagnose(
Diagnostic(
node: node,
message: MacroDiagnosticMessage(
id: "invalid-target",
message: "This macro can only be applied to structs",
severity: .error
)
)
)
return []
}
// ✅ Check required arguments
guard let arguments = node.arguments else {
context.diagnose(
Diagnostic(
node: node,
message: MacroDiagnosticMessage(
id: "missing-args",
message: "Required arguments missing",
severity: .error
)
)
)
return []
}
// Implementation...
return []
}
}
// Structure for diagnostic messages
struct MacroDiagnosticMessage: DiagnosticMessage {
let id: String
let message: String
let severity: DiagnosticSeverity
var diagnosticID: MessageID {
MessageID(domain: "MyMacros", id: id)
}
}Menghasilkan kode yang mudah dibaca
// ❌ Hard-to-read generated code
let badCode: DeclSyntax = "public init(a:Int,b:String,c:Bool){self.a=a;self.b=b;self.c=c}"
// ✅ Properly formatted generated code
let goodCode: DeclSyntax = """
public init(
a: Int,
b: String,
c: Bool
) {
self.a = a
self.b = b
self.c = c
}
"""Kode yang dihasilkan harus seenak kode yang ditulis tangan, sebab pengembang akan memeriksanya melalui "Expand Macro".
Kesimpulan
Swift Macros adalah perangkat ampuh untuk menghapus boilerplate tanpa mengorbankan keamanan tipe statis. Teknologi ini memungkinkan:
Poin utama:
- ✅ Dua kategori: freestanding (
#) dan attached (@) - ✅ Lima peran attached: peer, accessor, member, memberAttribute, conformance
- ✅ Implementasi via swift-syntax dan manipulasi AST
- ✅ Pengujian wajib dengan
SwiftSyntaxMacrosTestSupport - ✅ Paket terpisah diperlukan untuk implementasi
- ✅ Debugging via "Expand Macro" di Xcode
- ✅ Pesan kesalahan eksplisit penting untuk pengalaman pengembang
Swift Macros sangat berguna untuk membuat conformance (Equatable, Codable), property wrapper tingkat lanjut, dan modernisasi API berbasis callback ke async/await.
Mulai berlatih!
Uji pengetahuan Anda dengan simulator wawancara dan tes teknis kami.
Tag
Bagikan
Artikel terkait

Combine vs async/await di Swift: Pola Migrasi Progresif
Panduan lengkap migrasi dari Combine ke async/await di Swift: strategi progresif, pola jembatan, dan koeksistensi paradigma di basis kode iOS.

Pertanyaan Wawancara Aksesibilitas iOS di 2026: VoiceOver dan Dynamic Type
Persiapkan wawancara iOS dengan pertanyaan kunci aksesibilitas: VoiceOver, Dynamic Type, trait semantik, dan audit.

Wawancara StoreKit 2: Manajemen Langganan dan Validasi Tanda Terima
Kuasai pertanyaan wawancara iOS tentang StoreKit 2, manajemen langganan, validasi tanda terima, dan implementasi pembelian dalam aplikasi dengan contoh kode Swift praktis.