Skip to content

happycodelucky/SwiftMemoizedMacro

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

21 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Memoized

Swift 6.0+ macOS 14+ | iOS 17+ Release
CI Maintained

A Swift macro that turns computed properties into dependency-tracked cached getters. The cached value is only recomputed when the specified dependency properties change.

Think useMemo from React, but as a Swift macro.

Installation

Swift Package Manager

Add to your Package.swift:

dependencies: [
    .package(url: "https://github.com/happycodelucky/SwiftMemoizedMacro.git", from: "0.5.1"),
],
targets: [
    .target(
        name: "YourTarget",
        dependencies: [
            .product(name: "Memoized", package: "SwiftMemoizedMacro"),
        ]
    ),
]

XcodeGen

Add the package to your project.yml:

packages:
  SwiftMemoizedMacro:
    url: https://github.com/happycodelucky/SwiftMemoizedMacro.git
    from: 0.5.0

targets:
  YourTarget:
    dependencies:
      - package: SwiftMemoizedMacro
        product: Memoized

Usage

Add @Memoizable to your type and use #memoized inside computed property getters.

Single Dependency

import Memoized

@Memoizable
@Observable
class Theme {
    var colorMode: ColorMode = .dark

    // Only recomputes when colorMode changes
    var resolvedPalette: Palette {
        #memoized(\Self.colorMode) {
            Palette.generate(mode: colorMode)
        }
    }
}

Multiple Dependencies

@Memoizable
@Observable
class Theme {
    var colorMode: ColorMode = .dark
    var accentHue: Double = 210
    var fontSize: CGFloat = 14

    // Recomputes when colorMode OR accentHue changes
    // Does NOT recompute when fontSize changes
    var resolvedPalette: Palette {
        #memoized(\Self.colorMode, \Self.accentHue) {
            Palette.generate(mode: colorMode, hue: accentHue)
        }
    }
}

Multi-line Computation

@Memoizable
@Observable
class Theme {
    var colorMode: ColorMode = .dark
    var accentHue: Double = 210

    var resolvedPalette: Palette {
        #memoized(\Self.colorMode, \Self.accentHue) {
            let mode = colorMode
            let hue = accentHue
            return Palette.generate(mode: mode, hue: hue)
        }
    }
}

In SwiftUI Views

@Memoizable
struct ContentView: View {
    @Environment(\.colorScheme) var colorScheme

    var progressStyle: LinearGradient {
        #memoized(\Self.colorScheme) {
            LinearGradient(
                colors: colorScheme == .dark
                    ? [Color.cyan, Color.green]
                    : [Color.accentColor, Color.teal],
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            )
        }
    }

    var body: some View {
        ProgressView(value: 0.7)
            .progressViewStyle(.linear)
            .tint(progressStyle)
    }
}

Structs

@Memoizable
struct Settings {
    var threshold: Int = 10

    var label: String {
        #memoized(\Self.threshold) {
            "Threshold: \(threshold)"
        }
    }
}

How It Works

The library uses two macros that work together:

@Memoizable (type-level)

Applied to a class or struct, generates shared memoization storage:

// Generates:
private let _memoized = MemoizedStorage()

MemoizedStorage is a reference type that holds per-property MemoizedBox caches keyed by dependency key paths. It exposes a memoize(for:deps:compute:) method that handles the full cache-check + compute pattern.

#memoized (expression-level)

Used inside a computed property getter, expands to a memoize() call on the shared storage:

// You write:
var palette: Palette {
    #memoized(\Self.colorMode) {
        Palette.generate(mode: colorMode)
    }
}

// Macro expands to:
var palette: Palette {
    _memoized.memoize(for: "colorMode", deps: self.colorMode) {
        Palette.generate(mode: colorMode)
    }
}

The memoize(for:deps:compute:) method handles the full cache-check pattern: it returns the cached value if deps match, or computes, caches, and returns a new value otherwise. The Value type is inferred from the closure's return type.

For multiple dependencies, the macro wraps them in an Equatable struct (Deps2, Deps3, Deps4) since Swift tuples cannot conform to protocols:

// Two deps expands to:
_memoized.memoize(for: "colorMode,accentHue", deps: Deps2(self.colorMode, self.accentHue)) {
    Palette.generate(mode: colorMode, hue: accentHue)
}

Struct Copy Behavior

MemoizedStorage is a reference type (class), which means struct copies share the same cache. This is important to understand:

var a = Settings(threshold: 10)
var b = a                        // shares the same MemoizedStorage

print(a.label)                   // computes and caches for threshold=10
print(b.label)                   // cache hit ✅ (same deps)

a.threshold = 20
print(a.label)                   // cache miss, recomputes for threshold=20
print(b.label)                   // cache miss, recomputes for threshold=10
print(a.label)                   // cache miss again (b overwrote the cache)

This is always safe — the dependency check guarantees you never get a stale or incorrect value. However, if two struct copies are independently mutated, they will cause cache thrashing (frequent recomputation) because they alternate overwriting each other's cached values.

In practice this rarely matters:

  • Classes have a single reference, so no copies exist
  • SwiftUI views are short-lived value types that aren't independently mutated after creation
  • Immutable struct copies share the cache beneficially (same deps = cache hits)

Cache thrashing only occurs when mutable struct copies are independently mutated and both actively access memoized properties — an uncommon pattern for the intended use cases.

Design Decisions

Why two macros (@Memoizable + #memoized) instead of one? Swift's macro system validates all source code before macro expansion. An @attached(accessor) macro on a stored property can't reference self in the initializer (the compiler rejects it before the macro runs). By using a freestanding #memoized expression macro inside a computed property getter — where self is already available — the compiler is happy and the macro can freely reference instance properties.

Why key paths instead of automatic tracking? Explicit deps mean zero runtime overhead for observation tracking, no withObservationTracking complexity, and clear visibility into what triggers invalidation. It's the same philosophy as React's useMemo dependency array.

Why a reference-type storage? MemoizedStorage and MemoizedBox are classes so the getter can update the cache without mutating self, making it work in both classes and structs (including SwiftUI views where body is a non-mutating getter).

Why not a property wrapper? Swift's @propertyWrapper has no access to self of the enclosing type — wrappedValue can't read sibling properties. The _enclosingInstance static subscript is class-only and uses non-public API, ruling out structs and SwiftUI views.

Why Deps2/Deps3/Deps4 instead of tuples? Swift tuples cannot conform to Equatable (or any protocol). The Deps wrapper types provide the same structure with Equatable conformance, supporting up to 4 dependencies.

About

A Swift macro that memoizes computed properties, caching results and recomputing only when dependencies change.

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages