Adding Result II: Unconstrained Boogaloo

Unconstrained vs constrained could be resolved with Either<A, B> and Result as a type alias for Either<A, Error>. I am mostly writing this to be sure that it is mentioned but I really think that this would be the best way to go.

6 Likes

Either really has no buy in by the Swift community and so doesn't hold its own weight as a standard type. There's little point to adding it if 99.9% of usage would be as Result.

I can add it as an Alternative to the proposal if you really think it's necessary.

1 Like

I am not terribly interested in debating how much of 'no buy in' is the result of it not being easily available beyond stating that I use Either and that modeling Result in the described way solves the issue of "Unconstrained vs constrained" in a clear manner that has prior art.

7 Likes

I've added Either to the Alternatives section.

I'm typically in favor of having different types even if they have the same "shape" because type names also convey semantic information, so a user reading the code would see Result and understand that it's success vs. failure and see Either and understand that it's just one of two things.

But Either as a sum of two types feels kind of weak right now. What would be really nice is when we have variadic generics, supporting an Either<T...> as a generic building block. That's still not quite as simple (?) as it sounds though, because for the best ergonomics you'd want to synthesize a case for each type argument and it's not immediately clear how that would work.

9 Likes

While what you've described would be useful, I think it is completely unrelated to this except that it can be viewed–in a specific way–as a generalization of Either. I say unrelated because the completely finite scope of Either is part of the type. One of two types is in the box. The end. That can be explained with almost no effort and without technical terms. Variadics turn the type you're talking about into something close to a tuple. It probably should be as lightweight to utter as a tuple and is incredibly ad hoc as an idea.

Either can be an immensely useful type because, at its heart, it is just a Bool that can carry a bit of something along with 'yes' or 'no'. Proof of this is right there in Optional. I've lobbied for enum protocols to try to capture some of this and allow a basic shape to be represented with 'renamed' cases so that we can share useful functionality. Short of that, making Result a subtype of Either is my preference.

5 Likes

But what I described isn't a tuple at all; a tuple doesn't satisfy the invariant that only one of the values may be set. Variadic Either is still very much a finite type—you can't specialize it with an infinite number of types arguments, so while different specializations can have different numbers of types, each is still very much finite.

If Either<A, B> "can be explained with almost no effort and without technical terms", I don't see how it's a stretch at all to extend that to Either<A, B, C> or more. What else needs to be said than "Either<A, B> can hold a value of either A or B" and "Either<A, B, C> can hold a value of either A, or B, or C"? While the word "either" typically references "one of two things", using it to describe one of more than two things is also perfectly grammatical English.

Isn't your position here regarding Either in opposition to the one taken in the SetAlgebra thread where you said the far more obscure terms "intensional" and "extensional" are fine because "we need to give developers more credit than this" and "[y]ou are able to learn these terms"?

Either has been addressed in the proposal, so further discussion of it seems off topic for this thread. It could make an interesting separate discussion though, so feel free to start a new thread.

I don't quite get how this be both valuable or how you'd ensure the internal conformance with FP-esque algebras.

Plus why wouldn't Either<L, R> where R was an Enum with associated values suffice? Anyway as requested this line of discussion probably deserves a new thread.

While keeping the convenience APIs minimal, I think a map and flatMap implementation should be part of the base type, similar to Optional. On Alamofire's Result, map takes the value and returns Result, and there's a separate mapError to access the error value. antitypical/Result does the same thing (in addition to a ton of other convenience API), but I'm not sure if that's the appropriate signature. Opinions?

2 Likes

func map<NewValue>(_ transform: (Value) -> NewValue) -> Result<NewValue, Error>
func mapError<NewError>(_ transform: (Error) -> NewError) -> Result<Value, NewError>
func flatMap<NewValue>(_ transform: (Value) -> Result<NewValue, Error>) -> Result<NewValue, Error>

We could also have:

extension Result where Error == Swift.Error {
    func flatMap<NewValue>(_ transform: (Value) throws -> NewValue) -> Result<NewValue, Error>
}

Would we want to throw / rethrow as well, like Optional.map(_:)?
e.g. func map<NewValue>(_ transform: (Value) throws -> NewValue) rethrows -> Result<NewValue, Error>
Or would we want to swallow such error into another Result, as your extension seems to imply?

I don't think so. Result already carries error information. A map on Result that can throw just seems wrong to me unless the thrown error is compatible with the error type of the Result (thus the conditional extension).

Actually I‘d say we need it because it is the transformation that can fail which then can throw a totally different error type. The error of the result type does not necessarily have anything in common with the error of the transformation closure you apply. Besides in the throws / rethrows function you still can statically choose wether it will throw or not by passing either a throwing or non-throwing function to it.

I'm in agreement with this; the point is after all to extrapolate over two outcomes, not more.

Considering the mentioning of Scala's Try; have you considered the Match method i.e. a simplified type of if/else without having to fallback on if xxx.isSuccess { ... xxx.unwrap() .. }

Here is an example of what I meant above:

typealias NoError = Never

let start: Result<String, NoError> = ...

func convert(_ string: String) throws -> Int {
	guard
		let result = Int(string)
	else { throw SomeError.conversionFailed }
	return result
}

// This example requires a rethrowable `map`
let end1: Result<Int, NoError>
do {
	end1 = try start.map(convert)
} catch {
	/* do something */
	return
}

func extractCharacters(from string: String) -> [Character] {
	return string.map { $0 }
}

// This example does not require any error handling
let end2: Result<[Character], NoError> = start.map(extractCharacters(from:))

There is a significant difference between the error held by the error type and the error during the transformation. If that wasn't the fact here then we wouldn't have the same behavior on Optional because in case of an error it just could always return .none. However we do allow handling errors on transformations of the Optional type, which is similar to what I just showed above, where only the throwing transformation function requires explicit error handling. That said, I think Result should not ignore that fact but adopt these patterns established by the stdlib.

Furthermore I think the methods shouldn't be called map or flatMap in first place, bur rather mapValue and flatMapValue to follow the principles like compactMapValues on Dictionary.

I understand that this is technically possible and that it behaves identical to the signature I posted as long as a non-throwing transform is provided. On the other hand, I am not convinced it is a good idea to allow it. I don’t think I would want to read code that makes use of the ability to throw here. It intermixes two possible error paths in an awkward manner.

My question here is how would you transform a result value into a different value while also transforming the error? The methods mentioned above are only transforming one generic type parameter of the result at a time. Sure one can argue that you can extract the value from the result and then apply the transformation manually, but that breaks the cool chainability of multiple results.

do {
  result = try start
    .map(transformToThis)
    .map(transformToThat)
    .flatMap(evenMoreTransformation)

  // what if two of the transformation functions do throw?
} catch {
  // handle the transformation errors
}

You would need to provide two separate transform closures and it would be pointless to provide that as a primitive operation. Users can either chain map and mapError or just switch on the Result itself.

There is an operation on Result that isn't expressible by chaining maps and mapErrors, and it should probably be included with a stdlib Result:

extension Result {
  func fold<C>(ifSuccess: (Value) -> C, ifFailure: (Error) -> C) -> C {
    switch self {
    case let .success(value): return ifSuccess(value)
    case let .failure(error): return ifFailure(error)
    }
  }
}

This allows you to fold either case of a result into a single value. If you happen to use C = Result<NewValue, NewError> then you are recovering what you are looking for: the ability to simultaneously transform the value and error into a new result.

The fold name can be bikeshedded. Note that this function is the corresponding version of doing optional.map(f) ?? default in the optional world.

6 Likes