After enabling stricter Swift concurrency checks, we started seeing main-actor isolation errors from @MainActor initializers that used default parameter values. This post explains why default parameter expressions are nonisolated and how to fix the pattern by moving defaults into the initializer body.

This is Part 4 in our Advanced SwiftUI: Lessons From Mistakes series:

Prerequisites

You should be comfortable with:

  • Basic Swift and initializers.
  • The idea of the main actor (@MainActor).
  • How default parameter values in initializers work.

You do not need to be a Swift Concurrency expert.


Summary

After enabling stricter Swift concurrency checks, we started seeing errors like:

Main actor-isolated static property 'shared' can not be referenced from a nonisolated context
Call to main actor-isolated initializer 'init()' in a synchronous nonisolated context

They pointed at the PrinterService initializer:

@MainActor
class PrinterService: ObservableObject, PrinterServiceProtocol {
    let logger: LoggerProtocol
    let statusProvider: PrinterStatusProvider

    init(
        logger: LoggerProtocol = Logger.shared,
        statusProvider: PrinterStatusProvider = SDKPrinterStatusProvider()
    ) {
        self.logger = logger
        self.statusProvider = statusProvider
    }
}

Even though PrinterService itself is @MainActor, the compiler complained that the default parameter values were accessing main-actor–isolated resources from a nonisolated context.

We fixed this by:

  • Making the initializer parameters optional without default expressions.
  • Providing the actual defaults inside the initializer body, which does run on the main actor.

Result:

  • The warnings/errors disappeared.
  • Call sites still get the same ergonomics (PrinterService() works as before).
  • The pattern is now safe to reuse in other @MainActor-isolated types.

What broke

Once we annotated our service type and related types with @MainActor, the compiler started flagging the initializer as unsafe:

Main actor-isolated static property 'shared' can not be referenced from a nonisolated context
Call to main actor-isolated initializer 'init()' in a synchronous nonisolated context

The relevant code:

@MainActor
class PrinterService: ObservableObject, PrinterServiceProtocol {
    let logger: LoggerProtocol
    let statusProvider: PrinterStatusProvider

    init(
        logger: LoggerProtocol = Logger.shared,
        statusProvider: PrinterStatusProvider = SDKPrinterStatusProvider()
    ) {
        self.logger = logger
        self.statusProvider = statusProvider
    }
}

At a glance this seems harmless:

  • Logger.shared is a simple static singleton.
  • SDKPrinterStatusProvider() is a plain struct initializer.
  • PrinterService is @MainActor, so its initializer should be main-actor–isolated too, right?

The subtle bit is where Swift evaluates default parameter values.


Why Swift complains: default parameters vs. actor isolation

Key rule:

Default parameter expressions are evaluated in a nonisolated context.

When you write:

@MainActor
class Foo {
    init(bar: Bar = Bar.shared) { ... }
}

Swift does not treat Bar.shared as running under the Foo initializer’s actor isolation. Instead, it evaluates the default expression in a context that has no actor isolation.

That means:

  • If Bar.shared (or anything it touches) is @MainActor, you are effectively calling a main-actor–isolated API from a nonisolated context.
  • Under Swift 6 concurrency checking, this is an error.

In our case:

  • PrinterService is @MainActor.
  • The initializer parameters had default expressions that depended (directly or indirectly) on main-actor–bound behavior.
  • The compiler correctly refused to allow this cross-actor access.

Even though Logger itself is not @MainActor, relying on Logger.shared and other shared resources from inside default parameter expressions is fragile once actor isolation is in play.


The concrete fix

We changed PrinterService’s initializer to:

@MainActor
class PrinterService: ObservableObject, PrinterServiceProtocol {
    let logger: LoggerProtocol
    let statusProvider: PrinterStatusProvider

    init(
        logger: LoggerProtocol? = nil,
        statusProvider: PrinterStatusProvider? = nil
    ) {
        self.logger = logger ?? Logger.shared
        self.statusProvider = statusProvider ?? SDKPrinterStatusProvider()
    }
}

What changed:

  • Parameters are now optional and their defaults are just nil.
  • The initializer body (which is main-actor–isolated) fills in the real defaults:
    • logger ?? Logger.shared
    • statusProvider ?? SDKPrinterStatusProvider()

Why this is safe:

  • The initializer body is executed under the @MainActor of PrinterService.
  • Accessing Logger.shared and constructing SDKPrinterStatusProvider() from there respects actor isolation.
  • There is no longer any main-actor work hiding inside a default parameter expression.

Call sites stay the same:

// Uses Logger.shared and SDKPrinterStatusProvider() internally
let service = PrinterService()

// Override just the logger
let service = PrinterService(logger: MockLogger())

// Override both
let service = PrinterService(
    logger: MockLogger(),
    statusProvider: FakePrinterStatusProvider(...)
)

We now use the same pattern in other @MainActor types that depend on shared singletons.


General guidance for @MainActor initializers

When working with @MainActor classes and protocols (like PrinterServiceProtocol and PrinterSettingsStoreProtocol), follow these rules:

  1. Avoid doing any work with @MainActor-isolated APIs in default parameter values.

    • Bad:

      init(logger: LoggerProtocol = Logger.shared) { ... }
    • Better:

      init(logger: LoggerProtocol? = nil) {
          self.logger = logger ?? Logger.shared
      }
  2. Prefer simple optionals for dependencies.

    • Make dependencies optional in the initializer signature.
    • Provide the actual default instance inside the initializer body.
  3. Think about who owns the dependency.

    • Shared singletons (Logger.shared) are fine as defaults, but inject them from inside the actor.
    • For testability, always allow the caller to override with a mock.
  4. Keep @MainActor boundaries clear at call sites.

    • If you construct PrinterService from non-UI code, either:
      • Mark the caller as @MainActor, or
      • Create it via await MainActor.run { PrinterService() }.

Lessons learned

  1. Default parameters are not “just syntactic sugar.”

    • Where and how they are evaluated matters for concurrency.
    • Actor isolation rules apply to default expressions just like any other code.
  2. Central singletons (Logger.shared) are convenient but need discipline.

    • They are easy to reach for inside default parameters.
    • Under Swift 6, that can turn into a hard error once @MainActor enters the picture.
  3. @MainActor types should keep initialization logic simple.

    • Do all main-actor-dependent work in the initializer body.
    • Keep parameters plain (optionals, simple value defaults) so the compiler doesn’t have to reason about cross-actor calls during default evaluation.
  4. Document these patterns.

    • This post exists so future changes to PrinterService, PrinterSettingsStore, or other @MainActor services don’t accidentally reintroduce the same warnings.

FAQ

Why do @MainActor initializers with default parameter values trigger main-actor isolation errors?
Default parameter expressions are evaluated in a nonisolated context, so referencing main-actor–isolated properties or types from those defaults effectively calls main-actor code from outside the actor, which Swift flags as unsafe under strict concurrency checking.
What is the safe pattern for providing defaults in @MainActor initializers?
Make the initializer parameters optional or omit defaults in the signature, then assign the desired defaults inside the initializer body, which runs on the main actor. Callers keep a convenient API while the default logic stays actor-safe.

Welcome to The infinite monkey theorem

Somewhere a monkey just typed Shakespeare in TypeScript. Be the first to read the masterpieces (and the hilarious misfires) landing on the blog.

Subscribe to The infinite monkey theorem

We fling fresh posts—no banana peels attached—straight to your inbox.