Optional `ExpressibleByWrappedValue` superpowers

What does the standard library do to make wrapped values compatible with optionals? I want the same for my type:

enum MyOptional<Wrapped> {
    case none
    case some(Wrapped)
}

struct S {}
var x: MyOptional<S> = S() // wanted
var x: MyOptional<S> = .some(S()) // have to do this instead
2 Likes

The ability to implicitly wrap values of type T into Optional<T>.some(...) is hard-coded in the compiler and is not a library feature that other type authors are able to adopt.

6 Likes

I see. Could we change this, e.g. rewiring the compiler logic to check for a special "init" or a "protocol", and make Optional to implement this init / adopt that protocol – it will not change optional behaviour but would allow other types to take advantage of this machinery.

I’ve wanted this many times. It would be generally useful whenever you have an enum where one case wraps a simple value, and the other cases wrap more complex alternatives to that simple value, e.g.

enum Themeable<T> {
  case value(T)
  case themed(defaultValue: T, overrides: [Theme: T])
}

You can get quite a long way with conditional conformances to the ExpressibleBy protocols, but that obviously only works for wrapped types that conform to those protocols. A generalized ExpressibleByWrappedValue protocol would be the perfect solution.

I imagine that it would look something like:

protocol ExpressibleByWrappedValue {
  associatedtype Wrapped
  init(value: Wrapped)
}
3 Likes

I suspect it could get confusing to readers (and maybe authors too) if Optional syntax sugars were overloaded for other purposes. Kind of like how operator overloading - while sometimes useful - can be dangerous, especially for the most frequently-used and fundamental operators like +. Swift does allow operator overloading but some folks consider that a mistake, in retrospect.

At the least, you should consider what happens when your custom 'optional' type is itself wrapped in Optional, or contains an Optional (for MyOptional<Generic>-type situations), or is wrapped in itself.

Swift does Optional flattening implicitly - how would / should that work with custom 'optionals'?

2 Likes

You're asking for user-defined implicit conversions, which was most recently discussed here: Generalization of Implicit Conversions. This sort of feature would have serious implications on overload resolution performance, and I personally believe that this improvement:

is not worth the overload resolution complexity and type-checker performance cost that comes with it.

5 Likes

I don't think anybody actually wants this conflation. The design of Optional is antiquated and is now a thought polluter because it hasn't evolved along with other language features.

Optional and MyOptional both being property wrappers with throwing get accessors and non-throwing set accessors (that can't compile, currently) should be the current target.

@MyOptional var x = S()
@Optional var s = S()

i have only ever reached for custom optionoids because i wanted to either:

  1. write extensions on it with Wrapped bound to a generic type, or
  2. provide some specialized API that i want to document as part of my library, instead of it being lost in a pile of undiscoverable dark API in an extension to an external type.

we don’t really have great solutions for either of these situations right now. but i do not think ExpressibleByWrappedValue is the right solution for either of them.

1 Like

I didn't want to reinvent Optional specifically (although at times I'm indeed reaching out for a similar type with either "indirect" support or with more states than just two). I was mainly after the superpower to achieve the same result for my own type.


For example given a type hierarchy like this
struct Repository {
    enum User {
        struct Owner {
            let name: String
            var percentOfShare: Int
            other fields ...
        }

        struct Admin {
            enum ClearanceLevel {
                case minimal, normal, top
            }
            let name: String
            let clearanceLevel: ClearanceLevel
            other fields ...
        }

        struct EndUser {
            enum TrustLevel {
                case newbie, regular, senior, veteran
            }
            let id: String
            let likes: Int
            let dislikes: Int
            let trustLevel: TrustLevel
            other fields ...
        }

        struct Tester {
            let testerId: Int
            other fields ...
        }
        
        case owner(Owner)
        case admin(Admin)
        case endUser(EndUser)
        case tester(Tester)
    }
    let users: [User]
}

instead of having to write:

let repository = Repository(
    users: [
        .admin(
            .init(
                name: "Adam",
                clearanceLevel: .top,
                other fields ...
            )
        ),
        .tester(
            .init(
                testerId: 42,
                other fields ...
            )
        ),
        .endUser(
            .init(
                id: "1766372",
                likes: 127,
                dislikes: 12,
                trustLevel: .newbie,
                other fields ...
            )
        )
    ]
)

I'l like to have a simpler:

let repository = Repository(
    users: [
        .admin(
            name: "Adam",
            clearanceLevel: .top,
            other fields ...
        ),
        .tester(
            testerId: 42,
            other fields ...
        ),
        .endUser(
            id: "1766372",
            likes: 127,
            dislikes: 12,
            trustLevel: .newbie,
            other fields ...
        )
    ]
)

without first having to change from:

        case owner(Owner)
        case admin(Admin)
        case endUser(EndUser)
        case tester(Tester)

to a less convenient:

        case owner(name: String, percentOfShare: Int, other fields ...)
        case admin(name: String, clearanceLevel: ClearanceLevel, other fields ...)
        case endUser(id: String, likes: Int, dislikes: Int, trustLevel: TrustLevel, other fields ...)
        case tester(testerId: Int, apps: [App], other fields ...)

With Owner, Admin, etc explicit types the internal code structure is more clean, as I can store them in internal structures, pass them around, encapsulate logic in them, etc. I guess I can have the two parallel structures, keep them in sync and convert one to another when needed:

// in one place:
case endUser(id: String, likes: Int, dislikes: Int, trustLevel: TrustLevel, other fields ...)

// in another place:
struct EndUser {
    let id: String
    let likes: Int
    let dislikes: Int
    let trustLevel: TrustLevel
    other fields ...
}

// keep the two in sync

But I found that redundancy error prone and suboptimal.


The concern about the potential type-checker performance cost is understandable, although I wonder if that cost is to be paid only in cases when this functionality is actually used and doesn't affect performance when it's not used.

Putting aside the type checker costs, and only thinking about the inherent value when trying to write maintainable software, I can say that I'm conflicted about this concept of implicit conversion. The ancient reptilian part of my brain suggests that this is not a good idea: the possibility of implicitly converting from one type to another, that is, in a place where I need type A I can pass type B, can be the source of all sorts of sneaky bugs.

But in my personal experience I can say that I've found several cases where this wouldn't have been a problem at all, and in fact it would have helped with maintainable code.

Here's a couple of examples.


Suppose that I defined a type to express the relationship between 2 layout constraints, some like:

struct ConstraintRelation {
  case atLeast(Int)
  case exactly(Int)
  case atMost(Int)
}

(ignore for a second that this is better expressed by a struct with 2 properties, one of which an enum)

Where needed, I can write relation: .atLeast(42) or relation: .exactly(43).

Now, to make things simpler and more direct, I can leverage the ExpressibleByIntegerLiteral protocol, so instead of writing .exactly(43) I can just write 43: this is valuable, because just 43 clearly expresses that it's "exactly" 43, and the .exactly(...) part is just noise. Thus:

extension ConstraintRelation: ExpressibleByIntegerLiteral {
  init(integerLiteral value: Int) {
    self = .exactly(value)
  }
}

This is nice, I can just write relation: 43. But in some cases, instead of having an hardcoded literal, I have a constant defined somewhere, let myRelationValue: 43... well, I cannot use the simplified version now, like relation: myRelationValue, and I'm forced to "wrap" it again with relation: .exactly(myRelationValue).

This would be solved by an ExpressibleByWrappedValue that yields implicit conversion, for example:

protocol ExpressibleByWrappedValue {
  associatedtype Wrapped
  init(value: Wrapped)
}

Note that the protocol wouldn't probably be enough in this case, because a better definition for ConstraintRelation would be Double value instead of Int, and a conformance to both ExpressibleByIntegerLiteral and ExpressibleByFloatLiteral, so the wrapped value would have to be "both" Int and Double.

Also note that this would be solved with type unions for generic constraints.


At a domain boundary we often convert things into other things, because the same underlying concept is modeled differently in different domains.

For example suppose that, in a certain domain Foo, a user action is modeled via the following enum:

// domain Foo

enum UserAction {
  case didSucceed
  case didFail
  case didCancel
}

These are the cases that the domain is interested in, and the model focuses on those.

In another domain Bar, we care about different things for user action:

enum UserAction {
  case didEnter
  case didComplete
  case didCancel
}

When the even crosses domains, we would need to do the conversion manually:

// `userAction:` requires `Bar.UserAction`
userAction: {
  switch someFooUserAction {
  case .didSucceed, .didFail:
    return .didComplete

  case .didCancel
    return .didCancel
  }
}()

We could of course extend Bar.UserAction with a initializer that takes a Foo.UserAction (and this is what we actually do), but in every place where the conversion takes place, we would need to call it:

// `userAction:` requires `Bar.UserAction`
userAction: .init(someFooUserAction)

If ExpressibleByWrappedValue was possible, we could directly use

// `userAction:` requires `Bar.UserAction`
userAction: someFooUserAction

If this pattern repeats often for the same conversion, the savings become substantial. Also, note the .init(...) part doesn't really matter, it's just "plumbing" to support the conversion.

Couldn’t you do that with static factory methods on Repository that take the elements required to init the Owner, Admin, EndUser, and Tester associated values?

Like so?

extension Repository.User {
    static func owner(name: String, percentOfShare: Int) -> Self {
        .owner(.init(name: name, percentOfShare: percentOfShare))
    }
    static func admin(name: String, clearanceLevel: Admin.ClearanceLevel) -> Self {
        .admin(.init(name: name, clearanceLevel: clearanceLevel))
    }
    static func endUser(id: String, likes: Int, dislikes: Int, trustLevel: EndUser.TrustLevel) -> Self {
        .endUser(.init(id: id, likes: likes, dislikes: likes, trustLevel: trustLevel))
    }
    static func tester(testerId: Int) -> Self {
        .tester(.init(testerId: testerId))
    }
}

I could, it's just a boilerplate and error prone (there's a typo above).

1 Like

i have endless reams of these sorts of syntactical shims in my code, this seems like something i would want to actively move away from.

1 Like

Seems like an excellent opportunity to generalize it and refactor as an attached macro that could be leveraged to replace all your boilerplate (and avoid typos).

the silly but quite real reason why i haven't done that is because macros are only available on Apple platforms.

I hope that macros don't get [ab]used to cover up language and library problems.

While I certainly don't think macros ought to be the end-all-be-all of language evolution, for answering the issue of "I find myself writing boilerplate for this specific pattern quite frequently" macros generally seem far preferable to a proliferation of bespoke source generation features for every situation.

1 Like

Numerically macros almost certainly should be used more often than language changes. Of course. And they might be the optimum solution in this case too. I just meant to say that just because macros can do a thing doesn't mean they should have to.

I don't have a more specific argument or examples prepared, this is just the sentiment in short based on years of using macros, preprocessors, code generators, etc.

There is a fine line between appropriate and unnecessary repetition in a language. Things like @tera's suggestion for essentially duplicating the initialisers as static methods, while well-meaning, make me think something's fundamentally wrong if the language requires such boilerplate for such a basic - and arguably common - task.

Or put another way: code examples and patterns like that smell a lot like Java. Which alarms me deeply.

1 Like

writing boilerplate is only half the equation, you still need to read the boilerplate back via symbol documentation. even if macros were available on linux (they are not), i imagine we would have to choose between hiding all the synthesized API from documentation, duplicating the same doccomment over and over with the macro, or just leaving the sugar APIs completely undocumented, none of which seem like great user experiences.

by the way, i disagree that the intended use case for macros is to generate boilerplatey APIs. i personally view macros as a means of generating large API surfaces quickly. for example, defining a type for every HTML element. i’m sure macros could be used to fill in boilerplate, but i don’t think that’s what they should be used for.

3 Likes

That’s temporary, right? Macros are a Swift feature made through this community, so I’d expect they’ll eventually be for all supported platforms, right?