Does Optional solve a problem that Result does not anymore?

I moved some code* into extensions of the protocol below today and it got me thinking. Aside from question mark sugar, are there reasons we still have Optional other than historical ones? I have not thought about this for very long, but it seems like Optional might as well just be a typealias for this:

extension Result: ExpressibleByNilLiteral where Failure == Optional<Success>.UnwrapError {
  public init(nilLiteral: ()) {
    self = .failure(.init())
  }
}

public extension Optional {
  /// Represents that an `Optional` was `nil`.
  struct UnwrapError: Error & Equatable {
    public init() { }
  }
}
let none: Result<Int, _> = nil

(Optional.UnwrapError would be called something else if Optional didn't exist.)


Note: You can't actually turn either of them into a real property wrapper yet. [1] [2]

/// A type that represents a `get throws` accessor.
public protocol ThrowingPropertyWrapper<WrappedValue> {
  associatedtype WrappedValue
  var wrappedValue: WrappedValue { get throws }
}

extension Optional: ThrowingPropertyWrapper {
  /// - Throws: `UnwrapError`
  public var wrappedValue: Wrapped {
    @inlinable get throws {
      switch self {
      case let wrapped?: return wrapped
      case nil: throw UnwrapError()
      }
    }
  }
}

extension Result: ThrowingPropertyWrapper {
  /// - Throws: `Failure`
  public var wrappedValue: Success {
    @inlinable get throws { try get() }
  }
}

* E.g.

infix operator =?: AssignmentPrecedence

public extension ThrowingPropertyWrapper {
  /// Assign only non-throwing values.
  static func =? (wrappedValue: inout WrappedValue, self: Self) {
    try? wrappedValue = self.wrappedValue
  }
}

var value = "🎻"
value =? "🎸" as Optional
value =? Result { "🪕" }

Optional<T> is arguably isomorphic to Result<T, Never> (rather than a Result with an error type), but As for what Optional can do that Result can't — nothing comes to mind, as Result is a more general type. If you're willing to ignore any historical baggage, ignore ABI stability and source compatibility, and be willing to reimplement all of the optimizations that are currently associated with Optional (which may not exist for Result<T, Never>; I'm legitimately not sure), then theoretically, you could replace Optional with a typealias.

It doesn't sound like you're proposing this as an idea, but if you were interested in getting rid of Optional entirely in favor of Result, you would need to contend with the increased mental overhead of using Result everywhere (e.g., while writing this, I had to double-check the order of the generic parameters to Result [<Success, Failure>, or <Failure, Success>?] — which happen to be the reverse of, say, Either from Haskell). Optional is also likely a better name than Result for storage purposes, for instance.

2 Likes

if it's Result<T, Never> how to represent none?

I'd say:

enum None: Error { case none }
var value: Result<Int, None> = .failure(.none)

Obviously success and failure are not ideal names, but yeah, I guess we could have lived with that in theory.

Ah, right — of course. You and @anon9791410 are both right; I was thinking about the consuming side of switching on Result (which is possible to do with Result<T, Never>), but you could never construct an instance unless the parameter were lazy. My mistake!

1 Like

Optional<T> is isomorphic to Result<T, Void>. And if you want to go further, Bool is isomorphic to Optional<Void>, which is isomorphic to Result<Void, Void>. There are tradeoffs to modeling them all as specializations of the same type or as separate types.

24 Likes

And to reduce entities even further, Void could be modelled by an enum with a single value.

You can go quite great distances having just an enum and a tuple.

4 Likes

do dynamic casts for Result<T, Void> unify .failure cases?

Joe’s not talking about isomorphisms that preserve the semantics or even the validity of every existing language operation on the respective types (e.g., you of course can’t use a value of type Option<()> as the condition of an if statement), but for a given isomorphism you could consistently redefine/reimplement whatever functionality you wanted.

2 Likes

The idea of mapping Optional<T> to Result<T, Void> is that the error in this case is a) fixed and b) trivial, so that matching a .failure(()) case can be ‘emulated’ in the Optional case by matching .none and immediately constructing the empty tuple if it’s really needed for some reason. The Error value itself is meaningless.

You can imbue all of these types and values with additional informal meaning beyond their formal structure, and the isomorphism may not preserve that (e.g., I don’t actually consider .none to ‘truly’ represent an error case the way .failure does—something could be missing for all kinds of reasons that aren’t ‘really’ failures).

2 Likes

let’s back up here. when i try to declare a

let _:Result<Int, Void>

i get a compiler error: type 'Void' does not conform to protocol 'Error', because tuples can’t conform to anything besides Sendable.

we couldn’t remove the Failure:Error constraint on Result without breaking source, no?

(i suppose we could add a Voidoid to the standard library that’s just an empty struct, and that could conform to things. but that just seems silly.)

1 Like

No, sometimes it's helpful to just know which Optional type threw.

public extension Any? {
  /// The wrapped value, whether `Wrapped` is an `Optional` or not.
  /// - Throws: `Any?.UnwrapError` when `nil`,
  ///   or  `Any??.UnwrapError` when wrapping another `Optional` that is `nil`.
  var doublyUnwrapped: Wrapped {
    get throws {
      switch self {
      case let doubleWrapped?? as Self?:
        return doubleWrapped
      case _?:
        throw Self?.UnwrapError()
      case nil:
        throw UnwrapError()
      }
    }
  }
}

Yeah, if you really wanted to define the type you need the error type to be able to conform to protocols, which (currently) excludes tuples. As you note you’d really need to define an empty struct to get a type that’s valid Swift today, so the use of Result<T, Void> is a bit hand-wavy.

the thing is, i think that the error type should (statically) carry the Wrapped type of the original optional, so the actual result type would look like

let _:Result<T, NilOptionalError<T>>

and at this point i feel like that’s just too many angle brackets to be a desirable replacement for T?

2 Likes

If NilOptionalError<T> is trivial then you still have an isomorphism, because you could construct NilOptionalError<T>() immediately upon matching a .none in the Optional case.

I don’t think anyone is seriously proposing any “replacement” here, or suggesting that such replacement would be desirable. The original question was whether Optional solves any problems that Result doesn’t. The language chooses to model Optional as a separate type rather than a specialization of Result with a trivial error type, and provides different ergonomics and APIs between the types. Swift could have chosen to model things differently (since the types are isomorphic), but it’s a choice that comes with tradeoffs, as Joe mentions.

2 Likes

And of course, Void is isomorphic to Optional<Never>, which brings us full circle...

4 Likes

Yeah, programmers tend to use "isomorphic" rather loosely as a fancy way of saying that two things have roughly equivalent semantics, but Joe here is using the term with a specific technical meaning--two objects in a category are isomorphic if there is an invertible morphism between them. So Optional<T> and Either<T, Void> are isomorphic because we can define a Swift function f that transforms an Optional into an Either, and another function g which transforms an Either into an Optional, and these functions f and g are inverses of each other, in the sense that their compositions are the identity on Optional and the identity on Either, depending on the order in which you compose them. Another example is that (Int, (String, Float)) and (Int, String, Float) are isomorphic types, but they're certainly not the same type.

14 Likes

I'm not sure if I totally understand the question, because you've basically written this just above:

If the error type isn't totally fixed but is still fixed per wrapped/success type, then you've still effectively turned Result from a generic type with two type parameters into a type with one type parameter, e.g.,

typealias Optional<T> = Result<T, NilOptionalError<T>>

I agree with that, but ? already ought to work better with Result, even if fully subsuming Optional, including reappropriating ? in the form you mentioned, would be a bad idea.

let print = Result { { Swift.print($0) } }
(try? print.get())?("🖨️")

:see_no_evil: Why is this not

print?("🖨️")

Maybe Result is not special, and it's just time that try? … turned into …? for all chains with throwing expressions, or types that wrap them.


You two know the history better than I do. From a more external perspective, it seems more like Optional doesn't make use of errors because it predates errors. I didn't see developers really start annotating frequently with throws, rather than try?-ing and else return-ing until the recent days of concurrency—the Swift 1.x style of Optional had a lot of inertia.

Result really ought to have a

var success:Success? { get } 

that just returns the payload value if the result is a success(_:) case. unfortunately any time this idea comes up, it inevitably gets bogged down in the “case paths” evolution quicksand, so we’re kind of stuck with

(try? result.get())

for now.

this doesn’t really line up with my memory; throws has been around for a very long time and i definitely remember seeing a lot of it before concurrency became a thing. then again this is all completely anecdotal so who knows…

Yeah. While Optional (pre 1.0) predates Swift's error handling (Swift 2), error handling has been used since then.