[Pitch] Init Wrappers

Pitch: Initialization Wrappers

Introduction

Swift provides property wrappers for intercepting access to individual properties and macros for generating code at compile time. However, there is currently no mechanism for intercepting the initialization of an entire value and participating in its initialization process.

This proposal introduces init wrappers, an attribute that can be applied to an initializer declaration. An initialization wrapper acts as a customizable initialization mechanism for a type, allowing library authors to participate in object creation while preserving the language's initialization guarantees. All wrapper dispatch and property coverage verification happens at compile time, with no runtime reflection required.

Motivation

The class constraint imposed by type-safe DI

The most direct motivation for this proposal is a limitation typical of dependency injection use cases: you must accept mutable state, and often reference semantics, as a compromise, especially if you're using property wrappers, and/or key paths to guarantee type safety. We end up using a lot more classes than we might normally in these scenarios as a result.

One reason for that is, Swift property wrappers support an _enclosingInstance subscript that allows the wrapper to reach back into the enclosing type to resolve its value. This is the mechanism that makes scope-aware, key-path-driven DI possible:

public static subscript<Wrapper: ..., Enclosing: ...>(
    _enclosingInstance instance: Enclosing,
    wrapped wrappedKeyPath: ReferenceWritableKeyPath<Enclosing, Value>,
    storage storageKeyPath: ReferenceWritableKeyPath<Enclosing, Wrapper>
) -> Value {
    get {
        instance[keyPath: storageKeyPath].resolve(
            instance: instance,
            keyPath: wrappedKeyPath
        )
    }
    set {
        instance[keyPath: storageKeyPath].override = .some(newValue)
    }
}

Notice the constraint: ReferenceWritableKeyPath. This subscript overload only exists for reference types. The compiler has no equivalent path for value types, and this is not an oversight—a struct passed to a subscript is a copy, so any writes to storageKeyPath would be discarded. The only way to make this pattern work today is:

final class ReportService: Injectable {
    var scope: Scope

    @Inject var database: Database
    @Inject var cache: Cache

    // Explicit default—used when the scope has no Analytics
    @Inject var analytics: Analytics = Analytics(enabled: false)

    init(scope: Scope) { self.scope = scope }
}

This works, but the design is dictated by a language limitation rather than by intent. The properties are var not because mutability is desired, but because the _enclosingInstance mechanism requires a reference type and a mutable storage key path. The class is load-bearing infrastructure, not a meaningful semantic choice.

Why inout doesn't rescue structs

A natural question is whether the _enclosingInstance subscript could be extended to support inout passing of mutable structs, avoiding the class requirement. This does not resolve the underlying problem.

Even if the subscript were extended to accept an inout struct, making the struct mutable enough for property wrapper resolution to work during initialization would require var properties throughout. That defeats the central goal: using DI to produce immutable value types whose properties are set exactly once, during initialization, and cannot be modified afterward. A mutable struct with var properties is not an immutable value—it is a class with value semantics stapled on.

The deeper issue is that property wrapper resolution via _enclosingInstance is fundamentally a post-initialization mechanism. The enclosing instance already exists and is being accessed. Immutable let properties cannot participate in this model at all, because they cannot be written after the initializer exits. Even for var properties, property wrapper resolution is reactive—it responds to access—rather than constructive. It cannot drive initialization.

What is actually needed

What DI frameworks need is a way to participate in initialization before the value is sealed—a phase where all stored properties can be assigned exactly once, under the framework's control, with full type safety, and without requiring the target type to be a class or to expose mutable state. That is precisely what initialization wrappers provide.

With this proposal, the ReportService example above could be expressed as a struct:

struct ReportService {
    let scope: Scope
    let database: Database
    let cache: Cache
    let analytics: Analytics

    @Injectable
    init(scope: Scope)
}

The properties are let. The type is a struct. The framework still resolves dependencies via key paths—but it does so during the initialization phase, not by reaching back into an already-live instance. The class was never the right tool; it was the only tool available.

Other benefits for existing DI libraries

Init Wrappers might also benefit popular DI libraries like PointFree's Dependencies, which must currently maintain mutable properties on structs to provide dependency injection:

struct Feature {
  @Dependency(\.apiClient) var apiClient
  @Dependency(\.continuousClock) var clock
  @Dependency(\.uuid) var uuid
  // ...
}

(Source: Dependencies documentation on Github.)

They use a KeyPath-Value store to match dependencies within the property wrapper, but property wrappers can only be used on mutable vars.

Whereas with Init Wrappers, perhaps the following might be preferable:

struct feature {
    let apiClient: APIClient
    let clock: Clock
    let uuid: UUID
    @Dependency init()
}

Other motivating use cases

Beyond DI, many frameworks need to initialize immutable value types from external sources:

  • Serialization and deserialization systems
  • Configuration providers
  • Test fixture generators
  • Persistence frameworks

Today, such systems typically require one of the following:

  • Public memberwise initializers (exposes internals)
  • Mutable properties (sacrifices immutability)
  • Generated boilerplate (maintenance burden)
  • Unsafe memory manipulation (correctness risk)
  • Custom code generation tools (build complexity)

In each case, the real constraint is the same one that affects DI: there is no language-supported hook for participating in initialization before a value is sealed, so frameworks are forced to either compromise the type's design or work around the language entirely.

Proposed Solution

Allow attributes to be applied to initializer declarations:

struct User {
    let id: UUID
    let name: String

    @Injectable
    init()
}

When User() is invoked, control is transferred to the initialization wrapper.

Conceptually:

let user = User()

becomes:

let user = Injectable.make(User.self)

The wrapper receives a compile-time-generated initialization context describing the target type and is responsible for producing a fully initialized instance.

Compile-Time Property Coverage

Swift's compiler already walks a type's stored properties for several existing features:

  • Memberwise initializer synthesis enumerates stored properties to generate parameter lists and assignment bodies
  • Codable synthesis walks stored properties to emit encode and decode bodies, verifying that all non-optional properties are handled
  • Definite initialization (DI) analysis already understands what "fully initialized" means for a type and enforces it at every exit point of an initializer

Initialization wrappers build directly on this existing infrastructure. The compiler already has the machinery to enumerate stored properties, know their types and key paths, and determine whether a given initialization path covers all required properties. This proposal exposes that capability to wrapper authors through a structured compile-time context rather than adding new analysis from scratch.

The compiler verifies at the call site—not at runtime—that a wrapper's make implementation satisfies full property coverage for the target type. A wrapper that fails to initialize any non-optional, non-defaulted stored property is a compile-time error.

initialization Context

Initialization wrappers receive a type-specific initialization context generated entirely at compile time:

protocol InitializationWrapper {
    static func make<T>(
        _ type: T.Type,
        using context: initializationContext<T>
    ) throws -> T
}

The context exposes compiler-generated metadata, including:

  • Typed key paths to each stored property
  • Static type information for each property
  • Default values where declared
  • Optional framework-specific metadata supplied via additional attributes

No runtime type introspection is involved. The initializationContext for a given type T is fully described by what the compiler already knows about T at the point of the wrapper attribute.

Immutable Property Initialization

A key goal is enabling frameworks to initialize immutable stored properties during initialization.

Today, WritableKeyPath cannot be used to assign values to let properties after initialization, which is consistent with Swift's immutability guarantees.

Initialization wrappers do not mutate an already-initialized value. Instead, they operate during a dedicated initialization phase—analogous to the phase the compiler already enforces for memberwise and synthesized initializers—in which all stored properties are initialized exactly once before the value becomes visible to user code. The compiler's existing definite initialization analysis enforces this: each property path may be written exactly once, and the initialization phase must cover all required properties before the call returns.

Conceptually:

context.initialize(\.id, with: generatedID)
context.initialize(\.name, with: resolvedName)

After initialization completes:

let user = User()

the resulting value is fully immutable and the initialization context is discarded.

Example: Dependency Injection

struct ViewModel {
    let logger: Logger
    let network: NetworkClient

    @Injectable
    init()
}

The wrapper resolves dependencies using compile-time key path metadata:

let vm = ViewModel()

without requiring a manually-written initializer or a runtime lookup by string name.

Example: Fixture Generation


struct User {
    let id: UUID
    let name: String
    let age: Int

    @FixtureInitialized
    init()
}

Tests can generate fully-populated immutable instances without needing memberwise initialization, mutable properties, or runtime introspection.

Non-Wrapped Properties

We'd also propose that Init Wrappers can handle this situation:

struct Foo {
    let alice: Int
    let bob: String
    let bill: Person
    @Wrapped init(bob: String) { self.bob = bob }
}

Here, the compiler would only look to the Wrapped property wrapper to satisfy alice and bill.

Concurrency and the Actor Model

Initialization wrappers are designed to compose cleanly with Swift's structured concurrency and actor model.

initialization happens on the caller's executor

Because the wrapper's make method is invoked synchronously at the call site—conceptually replacing the init call—it inherits the caller's isolation context. If ViewModel() is called on the @MainActor, the wrapper's make call runs on the main actor's executor. No extra annotation is required for the common case.

Global actor-isolated types

When a type is annotated with a global actor, the compiler already enforces that its stored properties are only accessed from that actor. Initialization wrappers respect this: the make method will be required to match the isolation of the type being initialized. A @MainActor-isolated struct with an @Injectable init() will require Injectable.make to be @MainActor or nonisolated with appropriate Sendable constraints on the values it produces.

@MainActor
struct AppState {
    let config: Config
    let router: Router

    @Injectable
    init()
}

The compiler enforces that the wrapper satisfies the actor isolation requirements of AppState, just as it would for a hand-written @MainActor init.

async wrappers

Many real DI and persistence use cases are inherently asynchronous—resolving a dependency may require an async lookup. Initialization wrappers can be declared async, mirroring how Swift allows async initializers today:

protocol AsyncInitializationWrapper {
    static func make<T>(
        _ type: T.Type,
        using context: initializationContext<T>
    ) async throws -> T
}

At the call site, this is handled naturally:

let vm = await ViewModel()

The compiler enforces that an async wrapper is only used from an async context, consistent with how async init works today.

Sendable and cross-actor passing

The initialized value itself is subject to the same Sendable rules as any other value. If a wrapper initializes a value on one actor and the value is then passed to another, the type must conform to Sendable. This is not a special constraint introduced by initialization wrappers—it is the same rule that applies to any value crossing an actor boundary. Wrappers do not weaken or bypass these guarantees.

The initialization context is not Sendable

The initializationContext<T> is scoped to a single initialization call and is not intended to escape. It would be a non-Sendable type, preventing it from being passed across actor boundaries or captured in a closure that outlives the initialization phase. This ensures the context cannot be used to perform deferred or concurrent writes to the properties of a value that has already been sealed.

Relationship to Macros

Swift macros (specifically @attached(member)) can already generate init bodies today. Initialization wrappers are complementary rather than redundant:

  • Macros generate source code that is then compiled normally—they are a code generation tool
  • Initialization wrappers are a dispatch point backed by compile-time-verified coverage—they allow library types to participate in initialization without the calling module needing to expand or inspect generated source

The two features can work together: a macro could emit the @WrapperName init() declaration itself, with the wrapper handling the actual initialization logic.

Future Directions

Initialization wrappers could integrate naturally with:

  • Dependency injection systems
  • Serialization and deserialization frameworks
  • Test data generation
  • SwiftData-style persistence frameworks

Because the feature is grounded in existing compiler infrastructure—memberwise synthesis, Codable synthesis, and definite initialization analysis—it provides a stable foundation for these directions without requiring new compiler analysis phases.

Related Past Pitches and Proposals

[Pitch] Type Wrappers

Type Wrappers is the closest prior proposal in ambition. It also aims to intercept the initialization of a type and allow a wrapper to participate in initialization. The shared motivation is real: both proposals stem from the observation that frameworks often need to do work during object initialization that the language does not currently support.

The key difference is scope and weight. Type Wrappers proposes a comprehensive mechanism that wraps the entire type—storage, access, and initialization—which gives it broad power but also introduces significant surface area and complexity. Initialization wrappers are deliberately narrower: they intercept only the initialization phase, leaving access patterns to existing tools like property wrappers. This makes the feature easier to reason about, easier to implement, and less likely to interact unexpectedly with other language features. Where Type Wrappers is a new kind of type-level abstraction, initialization wrappers are closer to a structured extension of existing initializer synthesis.

[Proposal—Accepted] Init Accessors (SE-0400)

Init Accessors is directly relevant and already accepted. It allows a computed property to declare an init accessor, giving it the ability to participate in initialization and enabling property wrappers to work with let-backed storage in init bodies. This solves one specific pain point—property wrappers that need to initialize backing storage—but it does so at the level of individual properties, not at the level of the whole initialization call.

Initialization wrappers and Init Accessors are complementary. Init Accessors address the case where you have an init body and want individual properties within it to behave specially. Initialization wrappers address the case where you want the entire init to be delegated to an external authority—no init body at all, or a partial one—with the wrapper responsible for covering all stored properties. A DI framework using Init Accessors still needs a written (or generated) init body; one using initialization wrappers does not.

[Pitch] Allow Property Wrappers on Let Declarations

This pitch addresses the limitation that property wrappers cannot currently be applied to let properties. It shares the same underlying frustration as this proposal: the combination of property wrappers and immutability is poorly supported. If this pitch were accepted, it would remove one friction point for DI frameworks—@Inject let database: Database would be legal—but it would not resolve the deeper _enclosingInstance / ReferenceWritableKeyPath constraint that forces class usage. Property wrappers on let declarations still resolve their values reactively, post-initialization. They cannot be driven by a wrapper that is given control of the whole initialization phase.

Accepting both proposals would be coherent: this proposal handles the initialization side, and allowing property wrappers on let handles the access side.

[Pitch] Compositional Initialization

An earlier pitch by the same author as this proposal. Compositional Initialization explored letting types declare that their initialization should be composed from smaller, independently-defined pieces, with the compiler assembling the pieces into a complete init. The motivation is closely related: reducing boilerplate, enabling frameworks to participate in init, and supporting immutable types.

This proposal can be seen as a more focused successor. Where Compositional Initialization was concerned with the structure of init bodies, initialization wrappers are concerned with delegating the entire init to a well-typed external construct. The earlier pitch informed the design thinking here, particularly around how coverage verification should work.

[Proposal] Flexible Memberwise Initialization (SE-0018)

SE-0018 is a long-standing accepted-but-not-implemented proposal that would extend Swift's memberwise initializer synthesis to be more configurable—allowing access control over individual parameters, default values, and suppression of certain properties. It addresses the ergonomics of the synthesized memberwise init but does not provide a hook for frameworks to participate in initialization.

SE-0018 and this proposal operate at different levels. SE-0018 is about making the compiler-generated memberwise init more expressive. Initialization wrappers are about replacing or augmenting the init entirely. If SE-0018 were implemented, it would reduce some of the boilerplate that pushes developers toward the patterns initialization wrappers are meant to replace, but it would not make initialization wrappers unnecessary.

[Proposal—Accepted] Property Wrappers (SE-0258)

The direct predecessor to this proposal in spirit. SE-0258 established the _enclosingInstance subscript mechanism that enables scope-aware property wrappers—and in doing so, drew a clear line at the class boundary that this proposal aims to erase. Much of the motivation section of this pitch is a direct consequence of the design choices in SE-0258: the ReferenceWritableKeyPath constraint is not a bug in property wrappers, it is a fundamental consequence of how _enclosingInstance works, and initialization wrappers are the correct place to handle the struct case rather than stretching the property wrapper model further.

[Proposal—Accepted] Attached Macros (SE-0389)

SE-0389 introduced @attached(member) macros, which can generate init bodies. As discussed in the Relationship to Macros section above, macros are a code generation tool while initialization wrappers are a dispatch mechanism. Macros can prototype some of the patterns this proposal targets, and the two features compose naturally. However, macros require the generated source to be valid Swift that passes normal type-checking, which means a macro-generated init body for a DI framework still needs to know at macro-expansion time what dependencies to inject—it cannot defer that decision to runtime or to a separate framework-provided resolver.

Conclusion

Initialization wrappers provide a language-supported mechanism for participating in value initialization while preserving Swift's initialization model and immutability guarantees. The core motivation is concrete: type-safe, key-path-driven DI frameworks are today structurally forced to use classes, because _enclosingInstance property wrapper resolution requires ReferenceWritableKeyPath and operates post-initialization. Neither making structs mutable nor extending the subscript to support inout resolves this, because both approaches sacrifice the immutability that makes value types worth using in the first place.

Rather than introducing new compiler machinery, this feature surfaces capabilities the compiler already exercises for memberwise initializers and Codable synthesis, making them available to wrapper authors in a structured, compile-time-verified way. The result is a powerful framework primitive that enables patterns currently difficult or impossible to express without code generation, unsafe APIs, or mutable state—and does so entirely at compile time.

This doesn't read to me as something that needs an entirely new mechanism and the section about why can't it be done with macros is somewhat light and not detailed -- you might want to expand on this, or rather, work backwards -- what are macros missing that would enable you to write such macros if you wanted to? Consider combining a macro on the type level and on the initializer as well perhaps.

2 Likes

Why Init Wrappers Cannot Be Implemented as a Macro

It's because macros are governed by the same compiler limitation as writing an init by hand.

The macro is just automating what you could type yourself. Init wrappers provide something you cannot express by hand or by macro: a generic construction protocol that the compiler verifies covers all stored properties without knowing which type is being constructed at the time the protocol is written.

That's because a macro is a compile-time text transformation. Macros run before type-checking and produce Swift source code that then gets compiled normally and type-checked normally.

Init wrappers on the other hand are a new compiler feature that enables something you can't code today, a runtime dispatch point that the compiler verifies but does not eliminate.

In detail...

1. Macros must know the answer at expansion time

A macro expands once, producing static source code. For a DI macro to generate an init body, it must decide at expansion time which dependencies to inject and how. It can inspect the stored properties of the type syntactically, but it cannot defer the question of where the values come from to the framework at runtime.

Compare:

What a macro can generate:


// Macro sees `let apiClient: APIClient` and emits:

init() {

self.apiClient = Container.shared.resolve(APIClient.self)

}

What an init wrapper does:


// The wrapper's construct() is called at runtime with a live context:

static func construct(_ type: ViewModel.Type, using context: ConstructionContext<ViewModel>) -> ViewModel {

context.initialize(\.apiClient, with: self.scope.resolve(context.keyPath(\.apiClient)))

}

The macro has to hard-code the resolution strategy into the generated source. The wrapper receives a live scope object and can make runtime decisions—consult a registry, check a cache, apply overrides set up in a test, resolve asynchronously. A macro cannot express any of that because it does not exist at the time the macro runs.

2. Macros cannot participate in the caller's execution context

When you write let vm = ViewModel(), an init wrapper's construct method is invoked at that call site with whatever context the caller has established—a specific DI scope, a test override environment, an actor's executor. The call site is live code.

A macro-generated init body is just an init body. It runs with no special relationship to the caller beyond what a normal init has. There is no mechanism for the caller to pass a live context into a macro-generated init without that context being explicitly threaded through as a parameter—which defeats the point of transparent construction.

3. The verification problem is different in kind

An init wrapper requires the compiler to verify that construct will fully initialize all stored properties of T for any T it might be applied to. This is a constraint on a generic method across all future uses.

A macro generates a specific init body for a specific type, and the compiler just type-checks that body normally. The macro itself is not responsible for proving coverage—the generated code either compiles or it doesn't. But that means the macro has to get it right for each type individually, with no language-enforced contract that the macro author has to satisfy.

In other words: with a macro, if you forget to handle a property, you get a compile error in the generated code for that type. With an init wrapper, the protocol contract construct must satisfy is expressed once and verified for every application of the wrapper. The wrapper author writes a proof once; the macro author has to get it right every time.

4. Macros cannot introduce new initialization semantics for let properties

A macro-generated init body is subject to exactly the same rules as a hand-written one. It can assign let properties during init—that is normal Swift. But it cannot express the construction-phase semantics that init wrappers require: the idea that an external entity is performing those assignments through key paths, with the compiler tracking that each path is written exactly once.

If a macro generates self.apiClient = wrapper.resolve(\.apiClient), that is a normal property assignment in a normal init body. It works for var and for let (in an init). But if the wrapper needs to conditionally assign, or assign in a loop over property descriptors, or build the assignments programmatically at runtime—the macro cannot generate that. The macro has to know the structure statically and emit explicit assignments for each property.

Again, it's the same limitation as if you wrote it yourself.

5. Macros can't intercept the call site

A macro applied to a type or to an init declaration transforms the declaration. It does not change what ViewModel() means at the call site—that still resolves to the generated init, which runs synchronously in the normal Swift init dispatch path.

An init wrapper replaces the call site's semantics. ViewModel() becomes Injectable.make(ViewModel.self, using: context). That distinction matters for:

  • Async initialization—await ViewModel() only works if the call site understands the init is async. A macro cannot make an existing init() async at the call site without changing the declaration, which changes every call site.

  • Throwing construction—same issue.

  • Scope injection—the wrapper receives a live scope object. A macro-generated init has no way to receive one without an explicit parameter.

6. Macros at a function body can't see the instance variables

A macro might be able to do this:

@Injectable
struct ViewModel {
    let logger: Logger
    let network: NetworkClient
}

But it can't do this:

struct ViewModel {
    let logger: Logger
    let network: NetworkClient
    @Injectable
    init()
}

And there are key differences between what you can do with the two:

  • With the former, the resolution strategy is baked in at expansion time.
  • There is no way to pass a different scope, container, or context at the call site. This is precisely the limitation described in point 1.
  • There's no way in the type wrapper/macro model to have a let that is instance-scoped—i.e., resolved from a specific object owned by this instance rather than the ambient task-local.
  • If you wrap the whole type, but you need to add an additional property to the object later, with an initializer that supplies that value, you're going to need a bigger macro. So there are scalability concerns there.
  • An init wrapper's construct method receives a live context object instead, which is the thing the macro model simply cannot express.

Hope that helps

1 Like