"Should not extend type you don't own with protocol you don't own" is the advice, but

First, what is the reason against doing so?

I want to store Date with @AppStorage by satisfying one of its requirements: where Value : RawRepresentable, Value.RawValue == String:

extension Date: RawRepresentable {
    // use TimeInterval(Double)
    // format is locale independent
    public var rawValue: String {
        String(describing: timeIntervalSinceReferenceDate)
    }

    public init?(rawValue: String) {
        guard let timeInterval = TimeInterval(rawValue) else {
            // this should never happen. But if it does, return nil @AppStorage var is set to initial value
            print("⚠️ Date failed to parse rawValue String \"\(rawValue)\" as TimeInterval aka Double")
            return nil
        }
        self.init(timeIntervalSinceReferenceDate: timeInterval)
    }
}

I have other types I want to store in @AppStorage, like Color.

Is this not good?

1 Like

It comes down to the “what if two people did this” problem. What if Apple adds its own RawRepresentable for Date in iOS 16? What if one of your dependencies does? It’d be bad if they picked a different string representation or a different raw type altogether. (I think you could make a good case that date.rawValue should give you timeIntervalSinceEpoch.)

5 Likes

What's the solution to make Date work with @AppStorage? Wrap it in a

struct ... where Value : RawRepresentable, Value.RawValue == String

?

Then all code would need to indirectly access the Date inside that struct.

Do you mean timeIntervalSince1970 instead of timeIntervalSinceReferenceDate?

That's the cost, yeah. It might be possible to solve this with nested property wrappers; a quick simplified attempt seems to work.

(code)
import SwiftUI

@propertyWrapper
struct Wrap {
	var wrappedValue: Int
}
extension Wrap: RawRepresentable {
	var rawValue: String {
		wrappedValue.description
	}
	init?(rawValue: String) {
		guard let value = Int(rawValue) else {
			return nil
		}
		wrappedValue = value
	}
}

struct Test {
	@AppStorage("x") @Wrap var x: Int = 5
}

Test().x = 6
print(UserDefaults.standard.object(forKey: "x"))

I meant "…SinceReferenceDate" but once again the fact that it's ambiguous sort of points out why you shouldn't do this: someone else might have different opinions.

2 Likes

Wonderful, PW composition works in this case!

I don't understand. As long as I'm consistent both going out and coming back, it should be fine?

What I think Jordan is saying is that this ambiguity is exactly what leads to conflicting definitions: one person might try to write a conformance in terms of timeIntervalSince1970, and another might want timeIntervalSinceReferenceDate, and the specifics of how those could collide can lead to serious issues.

(If you, today, wrote an implementation using timeIntervalSince1970, and Apple added a conformance in the future in terms of timeIntervalSinceReferenceDate, not only would your code no longer compile because of the redundant conformance, but you couldn't even switch over to the official implementation directly because it would conflict with the behavior all of your other code could be expecting.)

5 Likes

It's potentially even worse than not compiling. When your app is running without being recompiled on the new OS version, you'll have two different conformances to the protocol at once. Swift does an impressive job of supporting multiple conformances and using the correct one depending on the static context, but this does break in some cases. In this specific case it's pretty likely to work (since you can't dynamic cast to RawRepresentable), but if it doesn't then existing installs of your app will appear to work on the new OS version but the persisted dates will all be wrong.

2 Likes

Well then the compiler should not allow this, and force you to add @just_do_it_and_let_me_suffer_the_consequence

Can this also happen with extending types with my protocols that happen to get a name clash with other people protocols at some point?

protocol SuperProtocol { // mine!
    func superCall() // mine!
}
extension String: SuperProtocol {
    func superCall() { ... }
}

Some time later, first party or third party dependency introduces same named protocol "SuperProtocol" with:

  • (a) same named method (perhaps with different parameter types), or
  • (b) differently named method.

Will I be in compile time (and/or runtime) trouble?

Makes sense... Otherwise people would continue doing this.

Protocols with the same name from different libraries are different protocols, and there won't be issues with using existing compiled apps. To recompile with the new version of the library you may need to switch to fully-qualifying the protocol name (i.e. extension String: MyApp.SuperProtocol), which is annoying but not a major problem. If you want to declare conformances to both of the SuperProtocols in your code and they have overlapping requirements that need different implementations, then you have to use @_implements.

One of the "exciting" implications of Swift's namespace lookup rules is that adding a new public symbol should be a semver major version bump, but everyone ignores that.

3 Likes

The concept of "owing" the code is rather informal. You'd normally own a protocol when it's in the library you're writing, but many Swift frameworks are split into multiple sub-frameworks. Would SwiftNIO and SwiftNIO-SSL be able to "own" each other's protocols? They are on an entirely different repo, but maintain by the same group. Adding even more ceremony would only increase friction of splitting a large codebase (than it already has). What about generics? Do I own Collection if its Element is MyType?

Now, other languages have done that, e.g., Rust's Orphan Rule, but much of their entire ecosystems are designed around such restriction such as Rust having both From and Into. It's possible to do something similar in Swift, but it's a pretty big problem, not to mention source-compatibility issue that will surely ensue. (why yes, we have migrators, but if possible, I'd rather not.) One can pitch & play around with the idea, of course.

1 Like

It might also help prevent misuse of protocol conformances, so we’d be be getting something in return for that pain.

I think you could make a good case that the lack of ceremony in this very specific situation (a conformance where the module owns neither the type nor protocol) is actively harmful. It’s an additional commitment that is part of the conformance but is never spelled out.

Swift does not support types having multiple constrained conformances, so no - you can't base a protocol conformance-related ownership claim on generic constraints.

"Ownership" of the types is really just a shorthand for whether or not you are in a good position to ensure that protocol conformances are unique at runtime. As you say, it is not a perfect measure, but it may be enough to prevent casual misuse without inconveniencing the majority of developers.

3 Likes