Surprising behavior of non-optional @NSManaged properties

Core Data is not a first-class citizen in the Swift world. Its inherently dynamic nature is lurking at us through an attribute created specifically for it: @NSManaged. Let me show you how this dynamic nature caught me off guard. I ended up with a property having a value I’d never assigned to it!

@NSManaged means dynamic

Imagine we’re starting a new project and need a way to represent a user. We’ve got:

class User {
    var name: String
}

which doesn’t yet compile in this form. We have to either 1) add an initial value to the name property, or 2) add an initializer that sets the value of that property. Another option is to store the user’s data with Core Data. If we just inherit from NSManagedObject and add @NSManaged attribute, the code will … compile cleanly:

class User: NSManagedObject {
    @NSManaged var name: String
}

This is Core Data’s way of saying I’ll take it from here. Getter and setter for name are created dynamically by NSManagedObject class. We can confirm that fact by using @NSManaged on a class not inheriting from NSManagedObject:

class FakeUser: NSObject {
    @NSManaged var name: String
}
let fakeUser = FakeUser()
fakeUser.name = "John"

leading to -[FakeUser setName:]: unrecognized selector sent to instance 0x608000009fb0 exception. With all the safety Swift brings, we can hit a runtime error with as little as 5 lines of code, as long as it uses Core Data.

Peculiar non-optionals

Let’s go back on track, though. We initialize a user, passing an NSManagedObjectContext instance to it:

let user = User(context: context)
user.name = "John"
print(user.name) // => "John"

So far, so good. We add a method responsible for creating users, to make sure a user always has a name and forget about this code:

func createUser(in context: NSManagedObjectContext, withName name: String) -> User {
    precondition(name != "")

    let user = User(context: context)
    user.name = "John"
    return user
}

One day though, we notice that in some parts of the app, the user’s name is equal to an empty string:

print(user.name) // => ""

That’s weird: precondition call makes sure that the name is initially never set to an empty string. It’s also never changed by other parts of the codebase!

A few minutes (or more honestly hours) of debugging later, we notice that the name is an empty string only on instances that were deleted from a context and the context was saved. (Deletion of managed objects is unrelated to ARC, so we have objects living in memory even though they are already treated as deleted.)

Because the name property is dynamic, we can’t check who changes its value to "". Let’s attack from a different angle by changing its type to optional:

@NSManaged var name: String?

In this case, after a User object was deleted and a context was saved, the value of name is correct: nil, not an empty string. Which leads us to the conclusion: returned value depends on the way the property is declared. When it’s non-optional, NSManagedObject does what it can to never return nil, substituting a default value instead.

(UPDATE: Sep 20, 2017: As pointed out on Reddit replacement of nil with an empty string is actually caused by bridging from NSString to String.)

The same silent substitution happens for these types too:

  • numeric types use a value equal to 0
  • Data uses an empty Data instance

The substitution doesn’t happen for other types. In these three cases we get nils when the type system doesn’t expect them, often causing crashes somewhere inside stdlib:

  • Date
  • UUID
  • URI

It’s worth mentioning, that the substitution happens even when there’s no default value set for an attribute in the xcdatamodel file.

Relationships

This behavior spans relationships too. Let’s say we also have an Event class:

class Event: NSManagedObject {
    @NSManaged var timestamp: Date
    @NSManaged var user: User
}

and run this code:

let event = Event(context: context)
print(event.user.name)

Can you guess what happens? We didn’t set up the user relationship, so we should get either a nil or a fatal error, right? Well, we don’t. An empty string is printed in this case too!

This is completely counter-intuitive when compared to the normal Swift code: event.user is nil, yet event.user.name isn’t.

Possible solution

The safest approach is to make all @NSManaged properties optional but it’s not great as far as a codebase’s readability goes.

The specific approach I’m considering switching to is:

  1. Optionals for @NSManaged properties which are optional in the regular Swift sense.
  2. Implicitly unwrapped optionals for @NSManaged properties which should be nil only twice in their lives:
    • before the first time their value is set
    • after a managed object they belong to was deleted

Implicitly unwrapped optionals will make some call-sites look worse but they’ll allow us to fail early with the familiar:

fatal error: unexpectedly found nil while unwrapping an Optional value

instead of having an unexpected value propagating through the program.

Conclusion

In cases like this, it becomes clear that Core Data predates Swift. There are some really rough edges around it. If breaking changes were possible on the framework level, I’d propose for Core Data to:

  • trap on reads when a value behind a non-optional attribute/relationship is nil, at least in the debug configuration, or
  • don't allow @NSManaged properties to be declared as non-optional