Inconsistent didSet behavior when a value is wrapped by a property wrapper

I find calling a mutating method on a value wrapped by a property wrapper always causes its didSet called, even if the mutating function doesn't actually write the value. This behavior is different if the value isn't wrapped.

import Foundation
import Combine

let defaultValue = 1

struct Foo {
    var x: Int = defaultValue {
        didSet {
            print("[Regular] Changed! oldValue: \(oldValue), newValue: \(x)")

    mutating func test(_ value: Int) {
        if x != value {
            x = value

// test 1: a baseline test. The value isn't wrapped. DidSet isn't called (as expected).
var foo = Foo()

struct SampleWrapper1 {
    private var foo: Foo
    init(wrappedValue: Foo) { = wrappedValue
    var wrappedValue: Foo {
        get { return foo }
        set { foo = newValue }

struct Baz {
    @SampleWrapper1 var foo = Foo() {
        didSet {
            print("[Baz] Changed! oldValue: \(oldValue), newValue: \(foo)")

// test 2: the value is wrapped by a dummy property wrapper. Calling mutating method always causes its didSet called, even if the method doesn't write the value.
var baz = Baz()
// Output: [Baz] Changed! oldValue: Foo(x: 1), newValue: Foo(x: 1)

class Bar: ObservableObject {
    @Published var foo = Foo() {
        didSet {
            print("[Published] Changed! oldValue: \(oldValue.x), newValue: \(foo.x)")

// test 3: this is similar to test 2, except that it uses Publisher property wrapper.
var bar = Bar()
Just(defaultValue).sink { value in
// output: [Published] Changed! oldValue: 1, newValue: 1

Test1 output nothing, which is what I expected. Test2 and test3, however, shows that didSet gets called even if the wrapped value isn't overwritten. I wonder if this is by design or is it a bug?

I noticed this behavior in a scenario like test3. I didn't expect the publisher emitted value, but it emitted. It took me a while to identify the root cause.

There is no inconsistency related to property wrappers. You can write a struct Boo without property wrappers that behaves just like Baz:

struct Boo {
    var foo = Foo() {
        didSet {
            print("[Bar] Changed! oldValue: \(oldValue), newValue: \(foo)")

var boo = Boo()
// [Boo] Changed! oldValue: Foo(x: 1), newValue: Foo(x: 1)

When you call a mutating method on a member of a struct (after the struct is initialized), then that member is mutated and didSet is called. This is a fundamental thing to understand about mutating.

By calling test, you are mutating (and, in my example,, but you're not mutating (nor, in my example,

By contrast, you can manipulate directly, setting both and = defaultValue
// [Regular] Changed! oldValue: 1, newValue: 1
// [Bar] Changed! oldValue: Foo(x: 1), newValue: Foo(x: 1)

Notice also that it doesn't matter what value you set: the accessor is called didSet, not didChange.


Thanks, what you said all make sense to me. One thing that confused me was that the Foo.test() didn't actually write to the storage but didSet still got called. Based on what you said and the experiments, that's the expected behavior. For example, if I change Foo.test() to an empty function, didSet will still be called in your test.

So my test 1 is a red herring. When Foo.test() get called, it's the Foo value is change and its didSet got called (as shown in your test). Whether Foo.x's didSet got called depends on the code in Foo.test().

BTW, I just checked the Swift Programming Guide. I think the following text in it might be a bit inaccurate:

You have the option to define either or both of these observers on a property:

willSet is called just before the value is stored.
didSet is called immediately after the new value is stored.

The exact meaning of "value is stored" in the text is not clear. Based on the above discussion I guess it should mean "the call of setter", but it's very easy for people to misunderstand it as "actually saving the value to storage".

EDIT: Or maybe an empty mutating function should be considered as writing a new value? That's very likely the right way to think about it :)