# Swift Macros : exemples pratiques de métaprogrammation > Guide complet sur Swift Macros : création de macros freestanding et attached, manipulation de l'AST avec swift-syntax, et exemples pratiques pour réduire le boilerplate. - Published: 2026-03-11 - Updated: 2026-03-28 - Author: SharpSkill - Tags: swift, ios, macros, metaprogrammation, swift-syntax - Reading time: 16 min --- Les Swift Macros, introduites avec Swift 5.9 et Xcode 15, représentent une révolution dans la façon d'écrire du code Swift. Cette fonctionnalité permet de générer du code à la compilation, éliminant le boilerplate tout en conservant la sécurité du typage statique. Contrairement aux macros C préprocesseur, les macros Swift sont type-safe, intégrées au compilateur et supportées par les outils de développement. > **Ce que couvre ce guide** > > Ce guide explore la création de macros Swift de A à Z : des concepts fondamentaux aux implémentations avancées, avec des exemples de code fonctionnels prêts à être adaptés dans tout projet iOS. ## Comprendre les types de macros Swift Swift propose deux catégories principales de macros, chacune avec des cas d'usage distincts. Les macros freestanding s'utilisent de manière autonome comme des expressions ou déclarations, tandis que les macros attached se rattachent à des déclarations existantes pour les modifier ou les enrichir. ### Macros Freestanding : expression et déclaration Les macros freestanding commencent par le symbole `#` et peuvent soit retourner une valeur (expression), soit créer de nouvelles déclarations. Voici un exemple concret de macro expression : ```swift // MacroUsage.swift // Macro freestanding expression - génère une valeur let buildInfo = #buildDate // Expansion → "2026-03-11 10:30:45" // Macro freestanding avec arguments let message = #stringify(1 + 2) // Expansion → "1 + 2 = 3" // Macro freestanding declaration - crée des déclarations #makeCase("success", "failure", "pending") // Expansion → // case success // case failure // case pending ``` La différence fondamentale entre expression et déclaration réside dans le résultat : une expression produit une valeur, une déclaration produit du code structurel (types, fonctions, variables). ### Macros Attached : les cinq rôles Les macros attached commencent par `@` et se placent devant une déclaration. Swift définit cinq rôles distincts pour ces macros : ```swift // AttachedMacroRoles.swift // @attached(peer) - ajoute des déclarations au même niveau @AddAsync func fetchUser(id: Int) -> User { ... } // Expansion → ajoute func fetchUserAsync(id: Int) async -> User // @attached(accessor) - ajoute getters/setters @UserDefault("theme") var currentTheme: String // Expansion → ajoute get { UserDefaults.standard.string(...) } // @attached(member) - ajoute des membres à un type @AutoEquatable struct Point { var x: Int var y: Int } // Expansion → ajoute static func == (lhs: Point, rhs: Point) -> Bool // @attached(memberAttribute) - applique des attributs aux membres @CodableKeys struct Config { var apiUrl: String var timeout: Int } // Expansion → ajoute @CodingKey("api_url") devant apiUrl // @attached(conformance) / @attached(extension) - ajoute des conformances @Hashable struct User { var id: Int var name: String } // Expansion → ajoute extension User: Hashable { ... } ``` Ces rôles peuvent être combinés pour créer des macros puissantes qui transforment le code de plusieurs façons simultanément. ## Configuration du projet pour créer des macros La création de macros Swift nécessite un Swift Package avec une structure spécifique. Le package dépend de swift-syntax, la bibliothèque officielle pour manipuler le code Swift sous forme d'arbre syntaxique abstrait (AST). ### Structure du Package.swift ```swift // Package.swift // swift-tools-version: 5.9 import PackageDescription let package = Package( name: "MyMacros", platforms: [.macOS(.v10_15), .iOS(.v13)], products: [ // Bibliothèque exposant les macros au projet principal .library( name: "MyMacros", targets: ["MyMacros"] ), // Exécutable pour tester les macros .executable( name: "MyMacrosClient", targets: ["MyMacrosClient"] ) ], dependencies: [ // Dépendance obligatoire pour les macros .package( url: "https://github.com/apple/swift-syntax.git", from: "509.0.0" ) ], targets: [ // Plugin de compilation contenant l'implémentation .macro( name: "MyMacrosPlugin", dependencies: [ .product(name: "SwiftSyntax", package: "swift-syntax"), .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), .product(name: "SwiftCompilerPlugin", package: "swift-syntax") ] ), // Target exposant les déclarations de macros .target( name: "MyMacros", dependencies: ["MyMacrosPlugin"] ), // Client de test .executableTarget( name: "MyMacrosClient", dependencies: ["MyMacros"] ), // Tests unitaires .testTarget( name: "MyMacrosTests", dependencies: [ "MyMacrosPlugin", .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax") ] ) ] ) ``` Cette configuration sépare clairement la déclaration des macros (ce que le code client voit) de leur implémentation (exécutée à la compilation). > **Organisation recommandée** > > Trois fichiers minimum sont nécessaires : MyMacros.swift pour les déclarations, MyMacrosPlugin.swift pour les implémentations, et MyMacrosTests.swift pour les tests. Cette séparation facilite la maintenance. ## Création d'une macro Expression Les macros expression génèrent une valeur utilisable dans le code. Voici comment créer une macro `#unwrap` qui unwrappe un optionnel avec un message d'erreur personnalisé incluant le nom de la variable. ### Déclaration de la macro ```swift // MyMacros.swift import Foundation /// Macro qui unwrappe un optionnel avec un message d'erreur explicite /// Usage: let value = #unwrap(optionalValue) /// Expansion: guard let optionalValue else { fatalError("...") }; optionalValue @freestanding(expression) public macro unwrap(_ value: T?) -> T = #externalMacro( module: "MyMacrosPlugin", type: "UnwrapMacro" ) ``` La signature déclare que la macro prend un optionnel et retourne la valeur non-optionnelle. `#externalMacro` pointe vers l'implémentation dans le plugin. ### Implémentation avec swift-syntax ```swift // UnwrapMacro.swift import SwiftSyntax import SwiftSyntaxMacros import SwiftCompilerPlugin public struct UnwrapMacro: ExpressionMacro { public static func expansion( of node: some FreestandingMacroExpansionSyntax, in context: some MacroExpansionContext ) throws -> ExprSyntax { // Récupère le premier argument passé à la macro guard let argument = node.argumentList.first?.expression else { throw MacroError.missingArgument } // Extrait le nom de la variable pour le message d'erreur let variableName = argument.description.trimmingCharacters( in: .whitespacesAndNewlines ) // Génère le code d'expansion // Utilise une closure immédiatement exécutée pour encapsuler le guard return """ { guard let value = \(argument) else { fatalError("Failed to unwrap '\\(\(literal: variableName))' - value was nil") } return value }() """ } } // Erreurs personnalisées pour les macros enum MacroError: Error, CustomStringConvertible { case missingArgument case invalidSyntax(String) var description: String { switch self { case .missingArgument: return "La macro nécessite un argument" case .invalidSyntax(let message): return "Syntaxe invalide: \(message)" } } } ``` La méthode `expansion` reçoit le nœud AST représentant l'appel de macro et le contexte de compilation. Elle retourne une `ExprSyntax` contenant le code généré. ### Enregistrement du plugin ```swift // MyMacrosPlugin.swift import SwiftCompilerPlugin import SwiftSyntaxMacros @main struct MyMacrosPlugin: CompilerPlugin { // Liste toutes les macros fournies par ce plugin let providingMacros: [Macro.Type] = [ UnwrapMacro.self, // Autres macros à ajouter ici ] } ``` Ce point d'entrée informe le compilateur des macros disponibles dans ce plugin. ## Création d'une macro Attached Member Les macros member ajoutent des membres (propriétés, méthodes, types imbriqués) à un type existant. Voici une macro `@AutoInit` qui génère automatiquement un initialiseur memberwise. ### Déclaration et implémentation complète ```swift // MyMacros.swift /// Génère automatiquement un initialiseur avec tous les stored properties @attached(member, names: named(init)) public macro AutoInit() = #externalMacro( module: "MyMacrosPlugin", type: "AutoInitMacro" ) ``` ```swift // AutoInitMacro.swift 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] { // Vérifie que la macro est appliquée à une struct ou class guard declaration.is(StructDeclSyntax.self) || declaration.is(ClassDeclSyntax.self) else { throw MacroError.invalidSyntax( "@AutoInit ne peut être appliqué qu'aux structs et classes" ) } // Collecte les stored properties let properties = declaration.memberBlock.members .compactMap { $0.decl.as(VariableDeclSyntax.self) } .filter { isStoredProperty($0) } // Génère les paramètres de l'initialiseur 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) // Vérifie si la propriété a une valeur par défaut if binding.initializer != nil { return "\(name): \(typeName) = \(binding.initializer!.value)" } return "\(name): \(typeName)" } // Génère les assignations dans le corps de l'init 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)" } // Construit l'initialiseur complet let initDecl: DeclSyntax = """ public init(\(raw: parameters.joined(separator: ", "))) { \(raw: assignments.joined(separator: "\n ")) } """ return [initDecl] } // Vérifie si une variable est une stored property (pas computed) private static func isStoredProperty(_ variable: VariableDeclSyntax) -> Bool { guard let binding = variable.bindings.first else { return false } // Une computed property a un accessor block avec get/set if let accessor = binding.accessorBlock { // Si c'est un bloc avec des accesseurs explicites, c'est computed if accessor.accessors.is(AccessorDeclListSyntax.self) { return false } } // let ou var sans accesseur = stored property return true } } ``` ### Utilisation de la macro AutoInit ```swift // UserModel.swift @AutoInit struct User { let id: UUID var name: String var email: String var isActive: Bool = true } // Code généré automatiquement : // public init(id: UUID, name: String, email: String, isActive: Bool = true) { // self.id = id // self.name = name // self.email = email // self.isActive = isActive // } // Utilisation let user = User(id: UUID(), name: "Alice", email: "alice@example.com") // isActive utilise la valeur par défaut ``` Cette macro élimine le boilerplate des initialiseurs, particulièrement utile pour les modèles de données avec de nombreuses propriétés. ## Macro Attached Peer pour génération async Les macros peer ajoutent des déclarations au même niveau que la déclaration annotée. Voici une macro `@AddAsync` qui génère une version async d'une fonction completion-based. ```swift // MyMacros.swift /// Génère automatiquement une version async d'une fonction avec completion handler @attached(peer, names: suffixed(Async)) public macro AddAsync() = #externalMacro( module: "MyMacrosPlugin", type: "AddAsyncMacro" ) ``` ```swift // AddAsyncMacro.swift 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] { // Vérifie que c'est une fonction guard let funcDecl = declaration.as(FunctionDeclSyntax.self) else { throw MacroError.invalidSyntax( "@AddAsync requiert une fonction" ) } let functionName = funcDecl.name.text let asyncFunctionName = "\(functionName)Async" // Analyse les paramètres pour trouver le completion handler let parameters = funcDecl.signature.parameterClause.parameters // Filtre les paramètres (exclut le completion handler) var regularParams: [String] = [] var completionType: String? = nil for param in parameters { let paramType = param.type.description // Détecte un completion handler (closure avec Result ou valeur simple) if paramType.contains("->") && paramType.contains("Void") { // Extrait le type de retour du 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( "Aucun completion handler trouvé" ) } // Génère les arguments pour l'appel interne let callArgs = parameters.dropLast().map { param in let name = param.firstName.text return "\(name): \(name)" }.joined(separator: ", ") // Génère la fonction async 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] } // Extrait le type de retour depuis un type Result private static func extractCompletionReturnType(from type: String) -> String { // Pattern simplifié - en production, utiliser l'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" } } ``` ### Démonstration de la macro AddAsync ```swift // NetworkService.swift class NetworkService { @AddAsync func fetchUser( id: Int, completion: @escaping (Result) -> Void ) { // Implémentation avec 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() } // Génère automatiquement : // 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) // } // }) // } // } } // Utilisation moderne avec async/await let user = try await networkService.fetchUserAsync(id: 42) ``` > **Limitations des macros peer** > > Le nom de la fonction générée doit être déclaré dans `names:` de l'attribut `@attached`. Ici, `suffixed(Async)` indique que la fonction générée aura le suffixe "Async" ajouté au nom original. ## Tests unitaires des macros Tester les macros est essentiel car elles génèrent du code qui sera compilé. Swift fournit `SwiftSyntaxMacrosTestSupport` pour faciliter ces tests. ```swift // MyMacrosTests.swift import SwiftSyntaxMacros import SwiftSyntaxMacrosTestSupport import XCTest @testable import MyMacrosPlugin final class MyMacrosTests: XCTestCase { // Dictionnaire des macros à tester 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 ne peut être appliqué qu'aux structs et classes", line: 1, column: 1 ) ], macros: testMacros ) } } ``` Les tests vérifient l'expansion correcte du code et les messages d'erreur appropriés pour les usages invalides. ## Macro avancée : Observable Property Wrapper Cette macro combine plusieurs rôles pour créer un système d'observation de propriétés avec notifications automatiques. ```swift // MyMacros.swift /// Ajoute l'observation automatique des changements de propriété @attached(accessor) @attached(peer, names: prefixed(_)) public macro Observable() = #externalMacro( module: "MyMacrosPlugin", type: "ObservableMacro" ) ``` ```swift // ObservableMacro.swift import SwiftSyntax import SwiftSyntaxMacros // Implémente les deux rôles : accessor et 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)" // Génère les accesseurs get et set 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 // Génère la propriété de stockage privée let initializer = binding.initializer.map { " \($0)" } ?? "" let storageDecl: DeclSyntax = """ private var \(raw: storageName): \(raw: typeName)\(raw: initializer) """ return [storageDecl] } } ``` ### Utilisation du pattern Observable ```swift // ViewModel.swift @Observable class UserViewModel { @Observable var name: String = "" @Observable var age: Int = 0 @Observable var isActive: Bool = true // Code généré pour chaque propriété : // private var _name: String = "" // var name: String { // get { // access(keyPath: \.name) // return _name // } // set { // withMutation(keyPath: \.name) { // _name = newValue // } // } // } } ``` Ce pattern est utilisé par Apple dans le nouveau framework Observation de Swift 5.9+. ## Debugging et inspection des macros Xcode offre plusieurs outils pour debugger les macros et comprendre le code généré. ### Expansion dans Xcode ```swift // DebuggingMacros.swift // Clic droit sur l'appel de macro → "Expand Macro" // Affiche le code généré inline @AutoInit struct Product { let id: UUID var name: String var price: Decimal } // Pour voir l'expansion : // 1. Clic droit sur @AutoInit // 2. Sélectionner "Expand Macro" // 3. Le code généré s'affiche inline, permettant inspection et debug ``` ### Logging pendant le développement ```swift // DebugMacro.swift public struct DebugMacro: ExpressionMacro { public static func expansion( of node: some FreestandingMacroExpansionSyntax, in context: some MacroExpansionContext ) throws -> ExprSyntax { // Affiche l'AST du nœud pour comprendre la structure print("=== DEBUG MACRO ===") print("Node: \(node)") print("Arguments: \(node.argumentList)") // Dump complet de l'arbre syntaxique dump(node) // Continue avec l'expansion normale return "42" } } ``` ### Explorer l'AST avec swift-ast-explorer L'outil en ligne [swift-ast-explorer.com](https://swift-ast-explorer.com) permet de visualiser l'arbre syntaxique de n'importe quel code Swift. C'est indispensable pour comprendre comment naviguer dans les nœuds AST lors de l'implémentation de macros. ## Bonnes pratiques pour les macros Swift La création de macros maintenables requiert de suivre certaines conventions et d'éviter les pièges courants. ### Validation et messages d'erreur ```swift // ValidationBestPractices.swift public struct ValidatedMacro: MemberMacro { public static func expansion( of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, in context: some MacroExpansionContext ) throws -> [DeclSyntax] { // ✅ Valider le contexte d'utilisation guard declaration.is(StructDeclSyntax.self) else { // ✅ Messages d'erreur clairs avec localisation possible context.diagnose( Diagnostic( node: node, message: MacroDiagnosticMessage( id: "invalid-target", message: "Cette macro ne peut être appliquée qu'aux structs", severity: .error ) ) ) return [] } // ✅ Vérifier les arguments requis guard let arguments = node.arguments else { context.diagnose( Diagnostic( node: node, message: MacroDiagnosticMessage( id: "missing-args", message: "Arguments requis manquants", severity: .error ) ) ) return [] } // Implémentation... return [] } } // Structure pour les messages de diagnostic struct MacroDiagnosticMessage: DiagnosticMessage { let id: String let message: String let severity: DiagnosticSeverity var diagnosticID: MessageID { MessageID(domain: "MyMacros", id: id) } } ``` ### Générer du code lisible ```swift // ReadableCodeGeneration.swift // ❌ Code généré difficile à lire let badCode: DeclSyntax = "public init(a:Int,b:String,c:Bool){self.a=a;self.b=b;self.c=c}" // ✅ Code généré avec formatage correct let goodCode: DeclSyntax = """ public init( a: Int, b: String, c: Bool ) { self.a = a self.b = b self.c = c } """ ``` Le code généré doit être aussi lisible que du code écrit manuellement, car les développeurs l'inspecteront via "Expand Macro". ## Conclusion Les Swift Macros représentent un outil puissant pour éliminer le boilerplate tout en conservant la sécurité du typage statique. Cette technologie permet de : **Points clés à retenir :** - ✅ Deux catégories : freestanding (`#`) et attached (`@`) - ✅ Cinq rôles attached : peer, accessor, member, memberAttribute, conformance - ✅ Implémentation via swift-syntax et manipulation de l'AST - ✅ Tests obligatoires avec `SwiftSyntaxMacrosTestSupport` - ✅ Package séparé requis pour les implémentations - ✅ Debugging via "Expand Macro" dans Xcode - ✅ Messages d'erreur explicites essentiels pour l'UX développeur Les macros Swift sont particulièrement utiles pour générer des conformances (Equatable, Codable), créer des property wrappers avancés, et moderniser des API callback-based vers async/await. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/fr/blog/ios/swift-macros-exemples-pratiques-metaprogrammation