Swift Macros: Hướng Dẫn Toàn Diện Từ @Observable Đến Custom Macro trong iOS 2026

Swift Macros (Swift 5.9+) cho phép metaprogramming tại compile time. Tìm hiểu @Observable, @Model, #Preview, #Predicate và cách tự tạo custom macro với SwiftSyntax cho iOS 2026.

Swift Macros iOS 2026: Complete Tutorial

Swift Macros là một trong những tính năng mạnh mẽ nhất được Apple giới thiệu từ Swift 5.9 — và thành thật mà nói, lần đầu tiên mình đọc về chúng, mình đã nghĩ "thêm một thứ phức tạp nữa để học". Nhưng sau khi thực sự dùng chúng trong dự án, mình thay đổi quan điểm hoàn toàn. Đến năm 2026, chúng đã trở thành phần không thể thiếu trong bộ công cụ của mọi iOS developer. Nếu bạn từng dùng @Observable, @Model, hay #Preview, bạn đã đang sử dụng macros mà không hay biết. Bài viết này sẽ đưa bạn từ cơ bản đến nâng cao: hiểu macros là gì, phân loại chúng, cách khai thác các built-in macros của Apple, và cuối cùng là tự xây dựng một custom macro hoàn chỉnh bằng SwiftSyntax.

Swift Macros Là Gì?

Swift Macros là cơ chế metaprogramming tại compile time. Không giống C macros — chỉ là text substitution đơn thuần — Swift macros hoạt động trên Abstract Syntax Tree (AST), cấu trúc cây mô tả mã nguồn Swift ở dạng có cấu trúc. Chúng sinh ra code Swift hợp lệ, được kiểm tra kiểu (type-checked) đầy đủ trước khi biên dịch.

Điểm quan trọng cần nhớ: macros không ảnh hưởng đến runtime. Toàn bộ quá trình diễn ra tại compile time. Code được sinh ra là code Swift bình thường — hoàn toàn có thể xem và kiểm tra trong Xcode (và đây là điều rất tuyệt, chúng ta sẽ nói ngay sau đây).

Tại Sao Cần Macros?

Trước khi có macros, iOS developer phải dùng nhiều giải pháp để giảm boilerplate, nhưng mỗi cách đều có hạn chế riêng:

  • Protocol extensions: không thể thêm stored properties hoặc tùy biến behavior theo từng property riêng lẻ
  • Property wrappers: không thể thêm members mới vào một type
  • Code generation tools (Sourcery, gyb): thêm build complexity, khó debug, phụ thuộc vào công cụ ngoài

Macros lấp đầy những khoảng trống này. Chúng có thể thêm computed properties, initializers, protocol conformances, và bất kỳ code nào — tất cả được validate tại compile time, không cần công cụ ngoài hay runtime overhead. Nghe có vẻ lý tưởng, phải không? Thực ra là vậy thật.

Hai Loại Macro: Freestanding và Attached

Swift phân macros thành hai loại chính, phân biệt bằng ký hiệu đầu tiên. Đây là điểm dễ nhầm nhất khi mới học, nên hãy chú ý.

1. Freestanding Macros — Ký Hiệu #

Freestanding macros bắt đầu bằng ký hiệu # và đứng độc lập, thay thế cho một expression hoặc declaration trong code.

// Freestanding expression macro — sinh ra một giá trị
let result = #stringify(x + y)
// Kết quả: tuple (x + y, "x + y")

// Freestanding declaration macro
#warning("Đây là cảnh báo compile-time")

Có hai roles cho freestanding macros:

  • @freestanding(expression) — sinh ra một expression (đơn vị code thực thi và trả về giá trị)
  • @freestanding(declaration) — sinh ra một hoặc nhiều declarations

2. Attached Macros — Ký Hiệu @

Attached macros bắt đầu bằng @ và được gắn vào một declaration (class, struct, property, function...). Điểm khác biệt quan trọng — và cũng là lý do chúng mạnh hơn nhiều — là chúng có thể đọc thông tin từ declaration đó để sinh code phù hợp theo ngữ cảnh.

@Observable
class UserViewModel {
    var name: String = ""
    var email: String = ""
    var profileImageURL: URL?
}

Attached macros có năm roles:

  • @attached(peer): thêm declarations song song với declaration hiện tại
  • @attached(accessor): thêm getters/setters cho property
  • @attached(memberAttribute): thêm attributes cho các members của type
  • @attached(member): thêm new members vào bên trong type
  • @attached(conformance): thêm protocol conformances cho type

Một macro có thể kết hợp nhiều roles đồng thời. @Observable, chẳng hạn, sử dụng cả ba roles member, memberAttribute, và conformance cùng lúc — khá ấn tượng cho một annotation đơn giản trông như vậy.

Các Built-in Macros Quan Trọng Của Apple

Phần này là trọng tâm thực hành. Hầu hết dự án iOS 17+ sẽ dùng ít nhất hai hoặc ba macros dưới đây hàng ngày.

@Observable — State Management Thế Hệ Mới

@Observable là macro được sử dụng nhiều nhất trong SwiftUI hiện đại, thay thế hoàn toàn cặp đôi ObservableObject + @Published. Yêu cầu iOS 17+ và Swift 5.9+.

import Observation

@Observable
class ShoppingCart {
    var items: [CartItem] = []
    var totalPrice: Double = 0
    var couponCode: String = ""

    @ObservationIgnored
    var sessionId: String = UUID().uuidString // Property này không được track
}

struct CartView: View {
    @State private var cart = ShoppingCart()

    var body: some View {
        VStack {
            List(cart.items) { item in
                Text(item.name)
            }
            Text("Tổng: \(cart.totalPrice, format: .currency(code: "VND"))")
        }
    }
}

Khi expand macro trong Xcode, @Observable sinh ra toàn bộ observation tracking infrastructure — observation registrar, stored properties với access tracking, và conformance tới protocol Observable. Điểm vượt trội so với ObservableObject: SwiftUI chỉ re-render view khi property mà view đó thực sự đọc thay đổi, cải thiện hiệu suất đáng kể với views phức tạp. Để tìm hiểu sâu hơn về cách @Observable hoạt động với UIKit, xem hướng dẫn UIKit Observable và Observation Tracking trong iOS 26.

Khi cần tạo binding từ observable class trong view, dùng @Bindable:

struct ProfileEditView: View {
    @Bindable var viewModel: UserViewModel

    var body: some View {
        TextField("Tên", text: $viewModel.name)
        TextField("Email", text: $viewModel.email)
    }
}

@Model — SwiftData Persistence Không Boilerplate

@Model là macro cốt lõi của SwiftData, framework persistence thế hệ mới của Apple thay thế Core Data cho hầu hết dự án iOS 17+. So với việc phải viết tay NSManagedObject subclass ngày trước, đây là một bước tiến lớn đến mức khó tin.

import SwiftData

@Model
class Task {
    var title: String
    var isCompleted: Bool
    var createdAt: Date
    var priority: Priority

    @Relationship(deleteRule: .cascade)
    var subtasks: [Subtask] = []

    init(title: String, priority: Priority = .medium) {
        self.title = title
        self.isCompleted = false
        self.createdAt = .now
        self.priority = priority
    }
}

@Model tự động sinh: conformance tới PersistentModel, hashability, schema metadata cho SwiftData, và observation support để SwiftUI cập nhật tự động. Toàn bộ được thực hiện tại compile time — không cần code thủ công hay subclass phức tạp như Core Data. Để hiểu sâu hơn về SwiftData và cách dùng @Model với model inheritance, đọc bài Hướng Dẫn SwiftData Toàn Diện: Từ Cơ Bản Đến Model Inheritance iOS 26.

#Preview — Preview Code Ngắn Gọn Hơn

#Preview thay thế PreviewProvider struct cũ kỹ, giúp code preview ngắn gọn và linh hoạt hơn nhiều. Thật ra mình không ngờ đây lại là một trong những cải tiến mình thích nhất trong Swift 5.9.

// Cách cũ — trước Swift 5.9
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

// Cách mới — với #Preview macro
#Preview {
    ContentView()
        .modelContainer(for: Task.self, inMemory: true)
}

// Preview với tên và điều kiện
#Preview("Dark Mode — iPad") {
    ContentView()
        .preferredColorScheme(.dark)
}

// Preview với trait collection
#Preview(traits: .sizeThatFitsLayout) {
    TaskRowView(task: .sample)
}

#Predicate — Queries An Toàn Kiểu Dữ Liệu

#Predicate chuyển đổi Swift closure thành query expression được tối ưu hóa bởi database, với full type checking tại compile time. Nếu viết sai kiểu dữ liệu, compiler báo lỗi ngay — không phải runtime crash. Đây chính xác là loại tính năng mà bạn không biết mình cần cho đến khi đã dùng một lần.

let searchText = "họp"

let predicate = #Predicate<Task> { task in
    task.title.localizedStandardContains(searchText)
    && !task.isCompleted
    && task.priority == .high
}

let descriptor = FetchDescriptor(
    predicate: predicate,
    sortBy: [SortDescriptor(\Task.createdAt, order: .reverse)]
)
let urgentTasks = try context.fetch(descriptor)

#URL — Compile-time URL Validation

#URL validate URL strings tại compile time, loại bỏ force unwrap nguy hiểm và runtime crash:

// Nguy hiểm — có thể crash tại runtime nếu string sai
let badURL = URL(string: "https://api.example.com/v1")!

// An toàn — validated hoàn toàn tại compile time
let apiURL = #URL("https://api.example.com/v1") // Non-optional URL

Xem Macro Expansion Trong Xcode

Xcode cung cấp tính năng rất hữu ích để học và debug macros: xem chính xác code được sinh ra. Đây là bước mình khuyến khích mọi người làm ít nhất một lần — nó sẽ thay đổi cách bạn hiểu macros.

  1. Trong editor, right-click vào tên macro (ví dụ: @Observable hoặc @Model)
  2. Chọn "Expand Macro" từ context menu
  3. Xcode sẽ hiển thị toàn bộ code được sinh ra inline, có thể scroll và đọc

Đây là cách tốt nhất để hiểu cơ chế bên trong và debug khi macro không hoạt động như mong đợi. Với @Observable, bạn sẽ thấy hàng chục dòng code được sinh ra — từ observation registrar cho đến từng property được wrapped với access tracking. Khá choáng ngợp lần đầu, nhưng rất hữu ích.

Tạo Custom Macro Từ Đầu

Bây giờ đến phần nâng cao: tự xây dựng macro. Chúng ta sẽ tạo macro @AutoInit — tự động sinh memberwise initializer với các tham số theo đúng thứ tự properties được khai báo trong struct. Đây là use case thực tế và đủ đơn giản để học, nhưng đủ phức tạp để minh họa đầy đủ quy trình.

Bước 1: Tạo Swift Package

Macros bắt buộc phải được triển khai trong Swift Package riêng biệt (đây là yêu cầu bắt buộc, không phải tùy chọn). Tạo package với cấu trúc ba targets:

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

let package = Package(
    name: "AutoInit",
    platforms: [.macOS(.v14), .iOS(.v17)],
    products: [
        .library(name: "AutoInit", targets: ["AutoInit"]),
    ],
    dependencies: [
        .package(
            url: "https://github.com/swiftlang/swift-syntax",
            from: "601.0.0"
        ),
    ],
    targets: [
        // 1. Implementation — compiler plugin (không expose ra ngoài)
        .macro(
            name: "AutoInitMacros",
            dependencies: [
                .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
                .product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
            ]
        ),
        // 2. Library — public API mà người dùng import
        .target(
            name: "AutoInit",
            dependencies: ["AutoInitMacros"]
        ),
        // 3. Tests
        .testTarget(
            name: "AutoInitTests",
            dependencies: [
                "AutoInitMacros",
                .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
            ]
        ),
    ]
)

Bước 2: Khai Báo Macro (Public API)

// Sources/AutoInit/AutoInit.swift
/// Tự động sinh memberwise initializer cho struct.
@attached(member, names: named(init))
public macro AutoInit() = #externalMacro(
    module: "AutoInitMacros",
    type: "AutoInitMacro"
)

Bước 3: Triển Khai Với SwiftSyntax

Đây là phần thực sự thú vị. SwiftSyntax cung cấp API để duyệt và tạo AST nodes — về cơ bản, bạn đang viết code để sinh code.

// Sources/AutoInitMacros/AutoInitMacro.swift
import SwiftSyntax
import SwiftSyntaxMacros
import SwiftCompilerPlugin

public struct AutoInitMacro: MemberMacro {
    public static func expansion(
        of node: AttributeSyntax,
        providingMembersOf declaration: some DeclGroupSyntax,
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {

        // Chỉ áp dụng cho struct
        guard declaration.is(StructDeclSyntax.self) else {
            throw MacroError.onlyStructs
        }

        // Thu thập tất cả stored properties (var hoặc let không có accessor)
        let properties: [(name: String, type: String)] = declaration
            .memberBlock.members
            .compactMap { member -> (String, String)? in
                guard
                    let variable = member.decl.as(VariableDeclSyntax.self),
                    let binding = variable.bindings.first,
                    let identifier = binding.pattern.as(IdentifierPatternSyntax.self),
                    let typeAnnotation = binding.typeAnnotation,
                    binding.accessorBlock == nil // Bỏ qua computed properties
                else { return nil }

                return (
                    identifier.identifier.text,
                    typeAnnotation.type.description.trimmingCharacters(in: .whitespaces)
                )
            }

        // Sinh danh sách parameters
        let params = properties
            .map { "\($0.name): \($0.type)" }
            .joined(separator: ", ")

        // Sinh body assignments
        let assignments = properties
            .map { "self.\($0.name) = \($0.name)" }
            .joined(separator: "\n        ")

        // Tạo init declaration
        let initDecl: DeclSyntax = """
        public init(\(raw: params)) {
            \(raw: assignments)
        }
        """

        return [initDecl]
    }
}

enum MacroError: Error, CustomStringConvertible {
    case onlyStructs
    var description: String {
        "@AutoInit chỉ có thể áp dụng cho struct, không dùng được với class hay enum."
    }
}

@main
struct AutoInitPlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [AutoInitMacro.self]
}

Bước 4: Test Macro Với assertMacroExpansion

Testing macros khác với testing code thông thường. Bạn cần kiểm tra output — tức là code được sinh ra — chứ không phải behavior tại runtime. assertMacroExpansion làm đúng việc đó:

// Tests/AutoInitTests/AutoInitTests.swift
import SwiftSyntaxMacros
import SwiftSyntaxMacrosTestSupport
import Testing
@testable import AutoInitMacros

let testMacros: [String: Macro.Type] = [
    "AutoInit": AutoInitMacro.self,
]

@Suite struct AutoInitTests {

    @Test func basicInitGeneration() {
        assertMacroExpansion(
            """
            @AutoInit
            struct Point {
                var x: Double
                var y: Double
                var label: String
            }
            """,
            expandedSource: """
            struct Point {
                var x: Double
                var y: Double
                var label: String

                public init(x: Double, y: Double, label: String) {
                    self.x = x
                    self.y = y
                    self.label = label
                }
            }
            """,
            macros: testMacros
        )
    }
}

Bước 5: Sử Dụng Custom Macro Trong App

import AutoInit

@AutoInit
struct Product {
    var id: UUID
    var name: String
    var price: Double
    var category: String
    var isAvailable: Bool
}

// Macro tự sinh:
// public init(id: UUID, name: String, price: Double, category: String, isAvailable: Bool)

let product = Product(
    id: UUID(),
    name: "AirPods Pro",
    price: 6_990_000,
    category: "Audio",
    isAvailable: true
)

Best Practices Khi Làm Việc Với Swift Macros

Sau khi dùng macros trong thực tế một thời gian, đây là những điều mình học được — một số theo cách khó khăn:

  • Chỉ dùng khi thực sự cần thiết: Macros thêm build complexity và maintenance cost. Nếu protocols, generics, hay property wrappers giải quyết được vấn đề, hãy ưu tiên dùng chúng trước. Mình đã viết một custom macro rồi sau đó nhận ra một protocol extension đơn giản cũng đủ dùng.
  • Luôn test expansion output: Dùng assertMacroExpansion từ SwiftSyntaxMacrosTestSupport để verify chính xác code được sinh ra. Đây là cách duy nhất để đảm bảo macro hoạt động đúng theo mọi trường hợp.
  • Emit error messages rõ ràng: Khi macro nhận input không hợp lệ, dùng context.diagnose để emit compiler error với thông tin cụ thể, giúp người dùng biết chính xác cần sửa gì.
  • Document hành vi của macro: Vì code được sinh ra không hiển thị mặc định trong editor, hãy comment rõ macro làm gì, sinh code gì, và có constraints gì để teammates không bị bối rối.
  • Giữ macro implementation đơn giản: Logic trong macro implementation nên tập trung vào code generation, không nên chứa business logic phức tạp. Business logic thuộc về runtime code.

Khi Nào Nên Tạo Custom Macro?

Custom macros phù hợp nhất cho:

  • Boilerplate lặp lại nhiều lần không thể giải quyết bằng protocols hay generics
  • Compile-time validation: URLs, regex patterns, SQL queries, format strings
  • Conformance tự động: sinh Codable, Equatable, Hashable tùy biến cho patterns cụ thể
  • Code generation từ type structure: đọc properties, methods của một type và sinh code phụ thuộc vào chúng

Tránh dùng macros cho boilerplate nhỏ, một-lần-dùng, hoặc khi một extension hay property wrapper đơn giản đã đủ. Build time và debugging complexity không xứng với lợi ích nhỏ.

Câu Hỏi Thường Gặp

Swift Macros khác gì so với C Macros?

C macros là text substitution đơn giản, không có type checking và rất khó debug. Swift Macros hoạt động trên Abstract Syntax Tree (AST), được type-checked đầy đủ tại compile time, và sinh ra code Swift hợp lệ. Bạn có thể xem code được sinh ra trong Xcode qua "Expand Macro", điều không thể làm với C macros.

@Observable có thể thay thế hoàn toàn @Published không?

Có, với iOS 17+ và macOS 14+. @Observable không cần @Published — mọi stored property tự động được track. Dùng @ObservationIgnored cho properties không muốn track. Hiệu suất tốt hơn vì SwiftUI chỉ re-render khi property mà view đó thực sự đọc thay đổi, không re-render toàn bộ view khi bất kỳ property nào thay đổi như trước.

Làm thế nào để xem code mà macro sinh ra trong Xcode?

Right-click vào tên macro trong Xcode editor (ví dụ: @Observable), sau đó chọn "Expand Macro" từ context menu. Xcode sẽ hiển thị toàn bộ code được sinh ra inline ngay trong editor. Đây là cách hiệu quả nhất để hiểu macro hoạt động như thế nào và debug các vấn đề bất ngờ.

Swift Macros có làm chậm thời gian biên dịch không?

Có thể, nhưng thường không đáng kể với macros được viết tốt. Macro expansion chạy như compiler plugin trong quá trình build. Xcode 26 và Swift 6.2 có nhiều cải tiến caching giúp giảm overhead đáng kể. Với các built-in macros của Apple như @Observable, impact gần như không đo được trong thực tế.

Có thể dùng Swift Macros với iOS 15 hoặc 16 không?

Swift Macros (Swift 5.9+) có thể dùng với deployment target iOS 15+, vì chúng chạy tại compile time và không có ràng buộc iOS version. Tuy nhiên, các built-in macros như @Observable@Model yêu cầu iOS 17+ vì chúng phụ thuộc vào Observation framework và SwiftData ra mắt cùng iOS 17.

Về Tác Giả Daniel Okafor

Daniel is a former Spotify iOS engineer (2019-2024) who worked on the Now Playing surface and the in-app podcast player. He shipped the SwiftUI rewrite of the lyrics view to over 600 million users and contributed several fixes upstream to swift-collections. His writing tends toward the unglamorous corners of iOS work: build-time regressions in Xcode 16, why SwiftData still isn't ready for production sync scenarios, and how to instrument a real app with os_signpost without drowning in noise. He spent two years before Spotify at a fintech startup in Berlin building a banking app on top of Solaris API. Daniel now freelances out of Lisbon and maintains a small open-source library for type-safe deep links in SwiftUI. He has 9 years of native iOS experience.