Anpassade Swift-makron med SwiftSyntax: Komplett Guide till att Skriva Egna Makron i Swift 6.2

Skriv dina egna Swift-makron med SwiftSyntax i Swift 6.2 och Xcode 26. Genomgång av paketstruktur, freestanding vs attached, diagnostik och testning, med två kompletta exempel du kan köra direkt.

Swift-makron: SwiftSyntax Guide 2026

Uppdaterad: 7 juli 2026

Ett anpassat Swift-makro är en kompileringstid-transformation som tar ett syntaxträd som indata och producerar Swift-kod som utdata, exekverad i en isolerad kompilatorplugin. Sedan Swift 5.9 skriver du dem med biblioteken SwiftSyntax, SwiftSyntaxMacros och SwiftCompilerPlugin. Den här guiden går igenom hela vägen: paketstruktur, de sju makrorollerna, diagnostik, testning med assertMacroExpansion, och två fullständiga exempel som du kan köra direkt i Swift 6.2 och Xcode 26.

  • Ett Swift-makro körs i en separat kompilatorplugin (inte i din app) och tar emot samt returnerar syntaxträd från SwiftSyntax.
  • Det finns två grundsorter: @freestanding (som ersätter sitt anrop) och @attached (som lägger till kod bredvid det den dekorerar).
  • Ett makropaket kräver tre mål: biblioteket som exponerar makrot, kompilatorpluginen som implementerar det, och (helst) ett testmål med SwiftSyntaxMacrosTestSupport.
  • Använd MacroExpansionContext.diagnose med DiagnosticMessage för att ge riktiga felmeddelanden istället för fatalError.
  • Testa expansionen med assertMacroExpansion, som jämför källtext mot förväntad genererad kod istället för AST:er, så testerna är läsbara.
  • I Swift 6.2 körs makron under strikt concurrency; håll din implementering statelös och undvik globala variabler i pluginen.

Vad är ett anpassat Swift-makro?

Ett anpassat Swift-makro är en kompileringstid-transformation som körs innan typkontrollen är klar och som genererar Swift-källkod, vilken sedan kompileras som om du hade skrivit den för hand. Ingången är alltid ett SyntaxNode, ett omutabelt syntaxträd från biblioteket SwiftSyntax, och utgången är antingen ett uttryck, en deklaration, medlemmar eller en extension, beroende på makrots roll.

Sedan Swift 5.9 släpptes 2023 har makron varit ett förstaklassskoncept, men det tog ärligt talat några versioner innan verktygen kändes vuxna. I Swift 6.2 är API:et stabilt: SwiftSyntax 601.0.x fungerar mot Xcode 26, kompilatorpluginen körs alltid utanför processen (även när du bygger från kommandoraden), och Xcode ber om användarens medgivande första gången ett makro laddas, samma modell som för byggverktyg. Om du har följt Objective-C-arvet i Swift så är det här kanske det första riktigt nya som språket har fått som inte har någon rimlig motsvarighet i klassisk Cocoa-kod. Preprocessorn i Objective-C var textbaserad; makron i Swift arbetar på faktiska syntaxträd, vilket är hela poängen med varför de går att typkolla.

Det du inte kan göra med makron: läsa filsystemet, göra nätverksanrop, importera andra Swift-moduler för att inspektera deras typer, eller hålla mutabelt tillstånd mellan expansioner. Kompilatorpluginen är avsiktligt tunn. Om du redan har läst min genomgång av Observation-ramverket och @Observable-makrot, så har du faktiskt redan sett vad ett välskrivet @attached(memberAttribute)-makro kan åstadkomma i praktiken.

Paketstruktur för ett makropaket

Ett Swift-makro bor alltid i ett Swift-paket, inte i en Xcode-projektfil, eftersom det behöver deklarera en CompilerPlugin-produkt. Xcode 26 har en mall (File → New → Package → Swift Macro) som skapar hela strukturen åt dig. Om du föredrar att göra det för hand ser Package.swift ut så här:

// swift-tools-version: 6.0
import PackageDescription
import CompilerPluginSupport

let package = Package(
    name: "SwiftCraftedMacros",
    platforms: [.iOS(.v17), .macOS(.v14)],
    products: [
        .library(name: "SwiftCraftedMacros", targets: ["SwiftCraftedMacros"]),
    ],
    dependencies: [
        .package(url: "https://github.com/swiftlang/swift-syntax", from: "601.0.0"),
    ],
    targets: [
        // 1. Publik yta: det här är det appen importerar.
        .target(
            name: "SwiftCraftedMacros",
            dependencies: ["SwiftCraftedMacrosPlugin"]
        ),
        // 2. Kompilatorplugin: implementationen.
        .macro(
            name: "SwiftCraftedMacrosPlugin",
            dependencies: [
                .product(name: "SwiftSyntax", package: "swift-syntax"),
                .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
                .product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
            ]
        ),
        // 3. Tester: kör mot förväntad expansion.
        .testTarget(
            name: "SwiftCraftedMacrosTests",
            dependencies: [
                "SwiftCraftedMacrosPlugin",
                .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
            ]
        ),
    ]
)

Tre mål, tre roller: biblioteket exponerar deklarationen (@freestanding(expression) macro stringify<T>(_ value: T) -> (T, String) = #externalMacro(module: "SwiftCraftedMacrosPlugin", type: "StringifyMacro")), pluginen innehåller expansionen, och testerna verifierar utfallet. Hoppa inte över testmålet. Du kommer ångra det. Makron är notoriskt lätta att bryta genom en till synes oskyldig ändring.

Skillnaden mellan @freestanding- och @attached-makron

Det finns två övergripande smaker av makron och sju roller totalt. Skillnaden är enkel att lära sig, men lätt att glömma bort i redigeringsstunden.

Egenskap@freestanding@attached
Anropas som#minMacro(arg)@MinMacro ovanför en deklaration
Rollerexpression, declarationpeer, accessor, memberAttribute, member, extension
Ersätter sitt anrop?Ja, ersätter #anrop-nodenNej, lägger till kod bredvid målet
Ser typinformation?Nej, bara syntaxNej, bara syntax (men extension-rollen får typnamnet)
Vanligt användningsfallDSL:er, #warning-liknande, kodgenerering på uttrycksnivåHärledd Codable, mockning, boilerplate
Får läggas till en typ?Inte som ändring, kan bara infogasJa, kan skapa medlemmar, accessorer, protokollkonformans

Regeln jag använder själv: om resultatet är koden (till exempel att expandera #url("https://…") till en typkontrollerad URL), välj @freestanding. Om resultatet är ett tillägg till en typ eller egenskap (till exempel att generera init(from decoder:)), välj @attached. En @attached(extension)-roll är särskilt användbar när du vill lägga till en protokollkonformans utan att röra den ursprungliga typens deklaration.

Skriva ett @freestanding(expression)-makro

Det klassiska "hello world"-makrot är #stringify. Det tar ett uttryck och returnerar en tupel med både värdet och källkoden som en sträng. Här är hela implementationen:

// Sources/SwiftCraftedMacrosPlugin/StringifyMacro.swift
import SwiftSyntax
import SwiftSyntaxMacros

public struct StringifyMacro: ExpressionMacro {
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) throws -> ExprSyntax {
        guard let argument = node.arguments.first?.expression else {
            throw MacroError.missingArgument
        }
        return "(\(argument), \(literal: argument.description))"
    }
}

enum MacroError: Error, CustomStringConvertible {
    case missingArgument
    var description: String {
        switch self {
        case .missingArgument: "stringify kräver exakt ett argument"
        }
    }
}

Det som är värt att titta noga på är strängbokstavssyntaxen "(\(argument), \(literal: argument.description))". Det där är inte en vanlig Swift-sträng. Det är en ExprSyntax-bokstav, tack vare att SwiftSyntax definierar ExpressibleByStringInterpolation. Interpolationen \(literal:) serialiserar värdet som en giltig Swift-strängbokstav (den lägger till citattecken och escapar). Utan den skulle en indata som innehöll ett citattecken producera trasig kod. Jag lärde mig det på hårda vägen när jag skickade in en URL med en query-parameter och hela bygget dog i timmarna innan en release.

Sedan registrerar du makrot i din plugin:

// Sources/SwiftCraftedMacrosPlugin/Plugin.swift
import SwiftCompilerPlugin
import SwiftSyntaxMacros

@main
struct SwiftCraftedMacrosPlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [
        StringifyMacro.self,
    ]
}

Och exponerar det i biblioteksmålet:

// Sources/SwiftCraftedMacros/Stringify.swift
@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String) =
    #externalMacro(module: "SwiftCraftedMacrosPlugin", type: "StringifyMacro")

Anropssidan ser då ut som let (v, s) = #stringify(2 + 3), där s blir strängen "2 + 3". Simpelt, men det illustrerar hela flödet: syntax in, syntax ut, kompilatorn interpolerar resultatet på anropsplatsen.

Skriva ett @attached(member)-makro

Ett mer verklighetsnära exempel är ett @AutoInit-makro som genererar en synlig init för en struct. Jag har sett människor kopiera-klistra samma initierare tio gånger i en modul. Det här ersätter det.

// Sources/SwiftCraftedMacrosPlugin/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] {
        guard let structDecl = declaration.as(StructDeclSyntax.self) else {
            throw MacroError.notAStruct
        }

        let storedProperties: [(name: String, type: String)] = structDecl.memberBlock.members
            .compactMap { $0.decl.as(VariableDeclSyntax.self) }
            .filter { $0.bindingSpecifier.tokenKind == .keyword(.let)
                   || $0.bindingSpecifier.tokenKind == .keyword(.var) }
            .flatMap { varDecl -> [(String, String)] in
                varDecl.bindings.compactMap { binding in
                    guard let ident = binding.pattern.as(IdentifierPatternSyntax.self)?
                            .identifier.text,
                          let type = binding.typeAnnotation?.type.description
                              .trimmingCharacters(in: .whitespaces)
                    else { return nil }
                    return (ident, type)
                }
            }

        let params = storedProperties
            .map { "\($0.name): \($0.type)" }
            .joined(separator: ", ")
        let assigns = storedProperties
            .map { "self.\($0.name) = \($0.name)" }
            .joined(separator: "\n        ")

        let initDecl: DeclSyntax = """
        public init(\(raw: params)) {
            \(raw: assigns)
        }
        """
        return [initDecl]
    }
}

Notera MemberMacro-protokollet och de tre parametrarna: node är själva attributet (@AutoInit), declaration är den struct, class eller enum som attributet sitter på, och context används för diagnostik och för att skapa unika identifierare. Vi filtrerar bort beräknade egenskaper genom att bara plocka ut lagrade VariableDeclSyntax-noder med en typannotation, för utan typ vet vi inte parameterlistan.

Biblioteksdeklarationen:

@attached(member, names: named(init))
public macro AutoInit() =
    #externalMacro(module: "SwiftCraftedMacrosPlugin", type: "AutoInitMacro")

names: named(init) är obligatoriskt. Kompilatorn måste veta i förväg vilka namn ett attached-makro får introducera, annars vägrar den att expandera det. För vildkort-scenarier finns arbitrary, men det slår av vissa optimeringar och bör undvikas. Om du bygger något större värt att strukturera som en serie makron, kolla in mönstret jag använde i SwiftData-guiden för iOS 26, där @Model gör exakt det här fast med fem samtidiga roller.

Diagnostik och felmeddelanden

Att kasta MacroError.notAStruct som ovan fungerar, men användaren får ett fel utan filplats och utan förslag. MacroExpansionContext tillhandahåller diagnose(_:) som skickar riktiga diagnostiska meddelanden med filkoordinater, fixa-det-här-förslag och note-referenser. Så här ser det ut i praktiken:

import SwiftDiagnostics

struct MustBeStructError: DiagnosticMessage {
    let message = "@AutoInit kan bara läggas på en struct"
    let diagnosticID = MessageID(domain: "SwiftCraftedMacros", id: "must-be-struct")
    let severity: DiagnosticSeverity = .error
}

// Inne i expansion(...):
guard let structDecl = declaration.as(StructDeclSyntax.self) else {
    context.diagnose(Diagnostic(
        node: Syntax(declaration),
        message: MustBeStructError()
    ))
    return []
}

Skillnaden i editor-upplevelse är natt och dag. Felmeddelandet visas nu direkt under raden i Xcode, med rätt filnamn och kolumn. Om du vill vara riktigt hjälpsam, para ihop diagnostiken med en FixItMessage så att användaren kan applicera en åtgärd med ett klick. Apples egna makron gör det överallt. @Observable föreslår till exempel att du byter class mot final class när det passar, som Apples officiella Observable-dokumentation beskriver.

Så testar du Swift-makron

Testverktyget SwiftSyntaxMacrosTestSupport tillhandahåller funktionen assertMacroExpansion, som tar in källkod och förväntad utdata som strängar och jämför dem token för token. Skillnaden mot att jämföra AST:er är att felmeddelandet blir läsligt: du ser en diff av två textbitar, inte en dump av ett träd. Ett test för #stringify ser ut så här:

import SwiftSyntaxMacrosTestSupport
import XCTest
@testable import SwiftCraftedMacrosPlugin

final class StringifyMacroTests: XCTestCase {
    func testStringifyExpandsAdditionExpression() {
        assertMacroExpansion(
            """
            let result = #stringify(2 + 3)
            """,
            expandedSource: """
            let result = (2 + 3, "2 + 3")
            """,
            macros: ["stringify": StringifyMacro.self]
        )
    }
}

Nyckelvärdet i macros-ordboken är namnet som används på anropsplatsen (utan # eller @), inte den fullt kvalificerade typen. Det gör att du kan testa flera makron med lokala aliaser om du behöver, vilket är praktiskt i integrationstester.

För @AutoInit-makrot vill du testa både lyckade och misslyckade fall. För felfallen kontrollerar du diagnostikmeddelanden:

func testAutoInitRejectsClass() {
    assertMacroExpansion(
        """
        @AutoInit
        class NotAStruct {
            let name: String
        }
        """,
        expandedSource: """
        class NotAStruct {
            let name: String
        }
        """,
        diagnostics: [
            DiagnosticSpec(message: "@AutoInit kan bara läggas på en struct", line: 1, column: 1)
        ],
        macros: ["AutoInit": AutoInitMacro.self]
    )
}

Kör testerna med swift test från paketets rotmapp, eller från Xcode 26 direkt. På Apple Silicon tar ett fullständigt makrotestpaket typiskt 3–5 sekunder att köra, vilket är snabbt nog för att köra i förändringsvakten (watch mode). För en bredare genomgång av testverktygen i det moderna Swift-ekosystemet, se min tidigare artikel om Swift Testing-ramverket. @Test-makrot där är i sig ett bra exempel på ett väldesignat @attached(peer)-makro.

Vanliga fallgropar och prestanda

Efter att ha skrivit ett dussintal produktionsmakron finns det ett antal saker jag ser gå fel om och om igen. De vanligaste:

1. Att förlita sig på typinformation

Makron ser bara syntax, inte typer. Om användaren skriver @AutoInit på en struct som ärver från en annan modul via en typalias, ser ditt makro bara typealias-namnet, inte den underliggande typen. Om ditt makro behöver typinformation för att fungera korrekt, dokumentera begränsningen tydligt i en kommentar och validera det du kan.

2. Att glömma access-modifierare

Om målet är public och du genererar en init utan att kopiera modifieraren, blir initieraren internal. Kompilatorn klagar då på att en public typ har en mindre synlig initierare. Lös det genom att inspektera declaration.modifiers och matcha vad du hittar där.

3. Statliga variabler i pluginen

Kompilatorpluginen körs som en separat process, men den kan hålla globalt tillstånd inom en byggsession. Det är en fälla. Under inkrementell kompilering är ordningen på anrop icke-deterministisk, och du får omöjligt-att-diagnostisera flakighet. Håll all logik ren och statelös. Jag råkade själv ut för det här på ett tidigare projekt, och det tog en hel eftermiddag innan jag insåg att ordningen bara var slumpartad mellan bygg.

4. Prestanda vid stora paket

Varje makroexpansion kräver en pipe-round-trip mellan kompilatorn och pluginen. På ett paket med tusentals expansioner märks det. Håll din expansion-funktion enkel: undvik regex, undvik onödiga String-allokeringar, och föredra SyntaxNode-traversering framför att konvertera till text. Apples egen dokumentation om SwiftSyntaxMacros-modulen har en bra genomgång av vilka noder som är billiga att jobba mot.

5. Att inte versionsfrysa swift-syntax

Om en användare av ditt paket har en annan version av swift-syntax i sitt eget projekt kan Swift Package Manager välja fel version. Ange alltid en snäv intervall (from: "601.0.0") och släpp nya versioner av ditt makropaket när swift-syntax gör ett major bump. Se swift-syntax release notes för mappningstabellen mellan Swift-versioner och paketversioner.

Vanliga frågor

Kan Swift-makron generera nya typer?

Ja, ett @freestanding(declaration)-makro får introducera nya typer, och ett @attached(peer)-makro får skapa en syskontyp bredvid den dekorerade deklarationen. Du måste dock deklarera namnen i förväg via names:-argumentet på attributet, om de inte är helt slumpartade. Då används arbitrary, men det slår av vissa optimeringar.

Är Swift-makron säkra att använda i produktion?

Ja. Kompilatorpluginen körs isolerad utanför kompilatorprocessen, utan tillgång till filsystem eller nätverk. Xcode ber dessutom om användarens medgivande första gången ett makro laddas från ett paket. All genererad kod går genom vanlig typkontroll, så makron kan inte "smyga" in osäker kod.

Fungerar Swift-makron på Linux?

Ja, sedan Swift 5.9 fungerar makron på både Apple-plattformar och Linux, förutsatt att swift-syntax-versionen matchar din Swift-toolchain. På Linux körs kompilatorpluginen som en Linux-binär, vilket innebär att du måste bygga paketet på samma plattform där du använder det.

Vad är skillnaden mellan ett makro och en property wrapper?

En property wrapper är en runtime-mekanism som transformerar hur en egenskap läses och skrivs, medan ett makro är en kompileringstid-transformation som genererar Swift-källkod. Makron är mer flexibla (de kan skapa initierare, protokollkonformans och hela typer), men de är också svårare att skriva och testa. En bra tumregel: om du bara vill omsluta ett värde, använd en property wrapper. Om du vill undvika boilerplate på deklarationsnivå, använd ett makro.

Kan jag felsöka ett Swift-makro?

Ja, men inte i själva byggkontexten. Det enklaste sättet är att köra dina tester under lldb (swift test -Xswiftc -g) och sätta brytpunkter i expansionsmetoden. För mer avancerad felsökning kan du köra kompilatorpluginen manuellt via kommandoraden och pipe:a in en syntaxdump med swift-syntax-verktyget.

Lukas Müller
Om Författaren Lukas Müller

iOS developer and Swift author since the Objective-C days. Spends his evenings on side projects and his mornings on SwiftUI internals.