Private properties no longer break the memberwise initializer in Swift 6.4

⋅ 6 min read ⋅ Swift Swift Evolution Initialization

Table of Contents

Swift synthesizes a memberwise initializer for a struct when we don't declare one ourselves.

struct Post {
var title: String
var body: String
}

let post = Post(title: "Hello", body: "Hello, World!")

This works nicely until we add a private property.

struct Post {
var title: String
var body: String
private var id = UUID()
}

// error: 'Post' initializer is inaccessible due to 'private' protection level
let post = Post(title: "Hello", body: "Hello, World!")

Adding one private implementation detail takes away the initializer for the whole struct.

SE-0502: Exclude private initialized properties from memberwise initializer fixes this in Swift 6.4.

Why the initializer becomes private

The synthesized memberwise initializer includes every stored property that can be initialized, and its access level can't be higher than the least accessible property it touches.

In the example above, id is private, so the synthesized initializer is private too.

struct Post {
var title: String
var body: String
private var id = UUID()

// Synthesized before Swift 6.4:
// private init(title: String, body: String, id: UUID = UUID()) { ... }
}

The initializer still exists. We just can't call it from outside the enclosing declaration, which is rarely what we want.

Before Swift 6.4, the workaround was to write the initializer ourselves, repeating every property by hand and remembering to update it whenever the struct changes.

You can easily support sarunw.com by checking out this sponsor.

Sponsor sarunw.com and reach thousands of iOS developers.

What SE-0502 changes

SE-0502 removes those less accessible properties from the initializer instead of letting them drag its access level down.

struct Post {
var title: String
var body: String
private var id = UUID()

// Synthesized in Swift 6.4:
// internal init(title: String, body: String) { ... }
}

let post = Post(title: "Hello", body: "Hello, World!") // Now this works

id keeps its initial value, and the initializer stays internal.

The rule

There are two conditions. A property is excluded from the memberwise initializer when it is:

  1. Less accessible than the maximum access level of the initializer, and
  2. Has an initial value.

The maximum access level is the highest access level among the memberwise-initializable properties, capped at internal.

"Initial value" means either of these:

struct Post {
private var id = UUID() // An explicitly declared initial value
private var draft: Bool? // A default initialized value (nil)
}

An optional property counts because Swift already gives it a default value of nil.

Both conditions matter, so a few cases behave exactly as they do today.

A private property without an initial value

Swift has no value to fall back on, so the property stays in the initializer and the initializer stays private.

struct Post {
var title: String
private var id: UUID
}

// error: 'Post' initializer is inaccessible due to 'private' protection level
let post = Post(title: "Hello", id: UUID())

If we hit this, giving id an initial value is enough to opt into the new behavior.

Every property is private

Here the maximum access level is private, so no property is less accessible than it.

struct Post {
private var title = ""
private var id: UUID?

// Still: private init(title: String = "", id: UUID? = nil)
}

Nothing is excluded, and we still get a private memberwise initializer to use inside the declaration. This is deliberate: the proposal authors found it somewhat common for a type to define only private properties with initial values, and removing the initializer there would have been a much bigger break.

Note that mixing levels does change the outcome. If id above were fileprivate instead of private, the maximum access level becomes fileprivate and title gets excluded.

A private struct

When the type itself is private or fileprivate, its unannotated properties are effectively fileprivate as well.

fileprivate struct Post {
var title: String?
fileprivate var id: UUID?

// fileprivate init(title: String? = nil, id: UUID? = nil)
}

Both properties are at the same level, so both stay.

A public property

Since the memberwise initializer is never more than internal, a public property doesn't raise the ceiling.

public struct Post {
public var title = ""
var id = UUID()

// internal init(title: String = "", id: UUID = UUID())
}

The maximum access level is internal, and id is internal, so nothing is excluded.

The compatibility overload

Excluding properties is a source-breaking change for code that was calling the old initializer from inside the file.

struct Post {
var title: String
private var id = UUID()

func duplicate() -> Post {
Post(title: title, id: UUID()) // Was this an error now?
}
}

To keep this working, Swift 6.4 synthesizes both initializers: the new one, plus a compatibility overload with the old signature.

// New memberwise initializer
internal init(title: String)

// Compatibility overload
private init(title: String, id: UUID = UUID())

The compatibility overload has the old, restricted access level, so it only affects code in the same file. A future language mode may remove it, with a warning and a fix-it that writes out an explicit initializer for us.

There is one case the overload can't rescue. If we already declared an initializer in an extension that happens to match the new signature, it now collides with the synthesized one.

struct Post {
private var id = UUID()
var title: String
}

extension Post {
// Fine before Swift 6.4.
// Now a redeclaration of the synthesized init(title:).
init(title: String) {
self.init(id: UUID(), title: title)
}
}

If we run into this, SE-0546 is the companion change to look at, since it defines when an initializer in a same-file extension replaces the synthesized one instead of clashing with it.

Why this matters for macros

This is the case that motivated the proposal.

Property wrappers already get this behavior. The compiler knows to leave the private backing storage out of the memberwise initializer.

@propertyWrapper
struct Wrapper<T> {
var wrappedValue: T
}

struct Post {
@Wrapper private var id = UUID()
var title: String
}

let post = Post(title: "Hello") // Okay

Macros didn't. A macro that expands to a private backing property broke the memberwise initializer of any type it was attached to, which made it impossible to reimplement a property wrapper as a macro without also forcing everyone to hand-write their initializer.

The rule change lifts that restriction, and it does so in the general case rather than adding a special rule for macro-expanded properties. That keeps an important property of macros intact: pasting a macro expansion into our code doesn't change its meaning.

Trying it out

The feature ships in Swift 6.4. On an earlier toolchain that has the implementation, we can enable it with the experimental feature flag.

swiftc -enable-experimental-feature ExcludePrivateFromMemberwiseInit main.swift

You can easily support sarunw.com by checking out this sponsor.

Sponsor sarunw.com and reach thousands of iOS developers.

Summary

In Swift 6.4, a property is left out of the synthesized memberwise initializer when it is less accessible than the initializer's maximum access level and it has an initial value.

The practical result is that adding a private cached value, ID, or piece of internal state to a struct no longer takes the memberwise initializer away from the rest of the module.

The proposal doesn't give us full control over the memberwise initializer yet. Explicitly marking properties as included or excluded, along the lines of #memberInit(x, y), is listed as a future direction. But it does fix the default behavior, which is the part that surprised people most often.


Read more article about Swift, Swift Evolution, Initialization, or see all available topic

Enjoy the read?

If you enjoy this article, you can subscribe to the weekly newsletter.
Every Friday, you'll get a quick recap of all articles and tips posted on this site. No strings attached. Unsubscribe anytime.

Feel free to follow me on Twitter and ask your questions related to this post. Thanks for reading and see you next time.

If you enjoy my writing, please check out my Patreon https://www.patreon.com/sarunw and become my supporter. Sharing the article is also greatly appreciated.

Become a patron Buy me a coffee Tweet Share
Previous
How to declare a memberwise initializer in a Swift extension

SE-0546 proposes allowing us to declare a struct's memberwise initializer in a same-file extension. Let's see how it works.

← Home