Optional.unwrap() to replace Optional.map()

I'm sure this has been brought up many times, but I couldn't find any. I find the use of Optional.map(_:) to be very problematic.

The Problem

  1. It's not the behavior you want: urlString.map(URL.init) returns a URL??, not a URL?.
  2. It gets confused with Sequence.map(_:): optionalStringArray.map { $0.count } is Int?, not [Int]?.
  3. It's not a good word. The word map is associated with arrays and collections more generally, not a single value.

The Proposal

I propose the use of map(_:) and flatMap(_:) for Optional be deprecated and replaced with unwrap(_:) and unwrapPreservingOptional(_:). unwrap(_:) would replace flatMap(_:) and unwrapPreservingOptional(_:) would replace map(_:).

  1. urlString.unwrap(URL.init) returns a URL? as expected.
  2. urlString.unwrapPreservingOptional(URL.init) could handle the unusual case of wanting a URL?? returned.
  3. optionalStringArray.unwrap { $0.count } clearly produces a Int?.
  4. optionalStringArray.map { $0.count } gives a warning.
  5. optionalStringArray?.map { $0.count } clearly produces a [Int]?.

Help

What do you all think of this idea? I'm not sure what to do next even if there was strong support for this kind of change.

2 Likes

You want Optional.flatMap, which flattens the extra layer of optionality. Unfortunately map (and flatMap, etc.) are terms of art, and used across various APIs in Swift, so a change isn't really possible.

16 Likes

In practice, I end up using flatMap(_:) every time the compiler complains. I'm not sure that's the best way to write code. It's less astonishing if the simpler term performs the expected behavior. That's why in my write-up unwrap(_:) replaces flatMap(_:).

As far as being an established term of art is a reason to keep the status quo, I don't know why it's not possible to change. I feel that Swift 6 has demonstrated that Swift is willing to make hard choices to improve the language.

Improving readability and understandably should always be a goal. From time to time, I find myself needing to type check uses of map(_:) to see if it's a sequence or optional. In my work, I've had to explain what Optional.map(_:) does to many Android, C#, and typescript developers. In general, overloaded terms with slightly different meanings has code smell.

In my understanding...

map

  • Foo<T>.map takes (T) -> U and returns Foo<U>.
  • Bar<T1, T2>.map takes (T1) -> U and returns Bar<U, T2>.

Examples:

  • Sequence<Element>.map takes (Element) -> T and returns Sequence<T>.
  • Optional<Wrapped>.map takes (Wrapped) -> T and returns Optional<T>.
  • Result<Success, Failure>.map takes (Success) -> NewSuccess and returns Result<NewSuccess, Failure>

flatMap

  • Foo<T>.flatMap takes (T) -> Foo<U> and returns Foo<U>.
  • Bar<T1, T2>.flatMap takes (T1) -> Bar<U, T2> and returns Bar<U, T2>.

Examples:

  • Sequence<Element>.flatMap takes (Element) -> Sequence<T> and returns Sequence<T>.
  • Optional<Wrapped>.flatMap takes (Wrapped) -> Optional<T> and returns Optional<T>.
  • Result<Success, Failure>.flatMap takes (Success) -> Result<NewSuccess, Failure> and returns Result<NewSuccess, Failure>

From that point of view, the fact that

is not strange, I guess.

(When Optional<String>.map takes (String) -> Optional<URL>, it will return Optional<Optional<URL>>.)

5 Likes

I agree. Optional.map(_:) and Optional.flatMap(_:) are mathematically sound. I don't think they are as intuitive as something like unwrap(_:). In addition, they overload the similar Sequence.map(_:) and Sequence.flatMap(_:), causing confusion when reading code.

I don't think most developers are as interested in mathematical purity as they are in clarity and utility.

Something similar happened when the overloaded Sequence.flatMap(_:) for ElementOfResult? was deprecated and replaced with Sequence.compactMap(_:). Clarity and utility over mathematical purity.

1 Like

Ew! Why does the compiler let you use a bare Array.init to refer to Array.init(repeating:count:).

I think you wanted one of these flatMaps:

print([1: 2].flatMap { [$0] })
print([1: 2].flatMap { [$0.key] })
print([1: 2].flatMap { [$0.value] })
1 Like

Yeah, it may be confusable that some Sequence<Element>'s map returns some (other) Sequence<Element> which defaults to Array<Element>.
It's consistent if we see them as just any Sequence<Element> types though. :upside_down_face:

I think what is a good word or not, comes down to familiarity. This will depend on where you're coming from. map is a term of art and I think it would be very surprising for experienced programmers coming to Swift, to find that map has a different name for each structure.

It's not related to collections or sequences or lists of elements, but it is a structure-preserving transformation of any type that is generic over some type parameter. It means "transform the contents, but keep the structure":

  • It can turn an optional Foo into an optional Bar
  • It can turn a promise of a Foo into a promise of a Bar
  • It can turn a stream of Foos into a stream of Bars
  • It can turn a Foo-result into a Bar-result
  • It can turn a tree structure of Foo-nodes, into a tree structure of Bars
  • It can turn a Foo-returning function into a Bar-returning function
  • It can turn a parser of Foos into a parser of Bars
  • It can turn a graph of Foos into a graph of Bars
  • It can turn a collection of Foos into a collection of Bars.

Likewise, flatMap is also not related to collections, but can perform transformations on contents and avoid nested structures.

zip even, can be extended to Optional, because it is not about turning pair of collections into a collection of pairs, but a pair of anything into an anything of pairs. So if you e.g. want to only consider a pair of optional values when they both have a value, you can zip then into an optional tuple.

Once this is understood, it is far easier to have a shared name, because you don't need to guess the name of the function based on the context. The same function always has the same name.

15 Likes

map and flatMap come from functional programming. There's a long list of prior art using these names. They have similar shapes:

map:     (Something<T>, f: ((T) -> U)          ) -> Something<U>
flatMap: (Something<T>, f: ((T) -> Something<U>) -> Something<U>

If you have something like func factors(x : Int) -> [Int], then map(myNumbers, factors) will give you a [[Int]].

The “flat” in flatMap comes from the fact that it “flattens” the result. That is, it turns a Something<Something<T>> into a Something<T>. (In the example above, a [[Int]] to a [Int]).

You can write a related function flatten that has the shape

flatten: (Something<Something<T>>) -> Something<T>

So flatMap for Array could just be implemented by

func flatMap<T, U>(
  x: [T], 
  f: (T) -> U
) -> [U] { 
  flatten(map(x, f))
}

---

APPENDIX (for NERDS ONLY):

These functions have other names in the larger computing world:

map == fmap

flatMap == bind

From Category Theory / Haskell / scores of functional programming languages…

Any type for which you can sensibly write a map function is said to be a “Functor”.
Any type for which you can sensibly write a flatMap function is said to be a “Monad”.

These terms get a scary :ghost: treatment around the internet programming web but this is the gist of it.

8 Likes

I see the beauty of what you are saying. It's a wonderful kind of beauty found in math. Over the years it's gotten harder for me to explain that beauty to developers who ask, "why optionalArray.map {…} is causing a compiler error." It's harder to justify why I need to lookup type information when I read property.map {…} to know what kind of map is being performed. I think understanding and clarity should trump beauty in code.

In this proposal, I hope for a few things. I hope make it easier for new developers to understand meta-programming in Swift. I hope to make Swift easier to pickup for developers of other languages. I hope to reduce the cognitive load while reading Swift code.

Sometimes education is the answer; however, when many people, including experienced developers, make the same mistake, it feels like it's a fight against human nature and not a simple matter of education.

1 Like

I totally agree! And yet, my conclusion is different than yours.

I think it's clear that map is always map. And I think it is unclear that map is sometimes called unwrap, then, select, transform etc. When a type clearly has a natural map function, why should I have to search for a domain specific name that may differ from context to context?

Agree! But I think the solution is better diagnostics. If optionalArray.map { ... } is giving a compiler error, where optionalArray?.map { ... } would not, I think the compiler should help the user understand why.

7 Likes

I think the problem is mostly with optional chaining, since it’s so common to combine optionals with other types that may also have their own map.

The optional chaining sigil ?. is very subtle and easy to miss.

You can of course also end up with types like Result<Array<...>> or Future<Optional<...>>, but those don’t get the same syntactic convenience that optional chaining gives us. That makes it easier to tell whether a function is being called on the container or on the payload.

Since optionals already receive a lot of special treatment from the syntax and compiler, maybe the real solution is for the compiler to provide even more affordances around optional chaining diagnostics?

1 Like

I'm not proposing a large number of methods. Specifically, I'm proposing Optional<Wrapped>.unwrap(_:) which I feel fits well. Optional.unwrapPreservingOptional(_:) got dragged along for compatibility.

Optionals are already treated specially with special binding rules, special chaining rules, special declaration rules, and special operators. There are even special methods like Optional.take() and special properties like Optional.unsafelyUnwrapped.

I feel it's more surprising that optionals are treated like a sequece type with regards to map(_:), but not filter(_:) or reduce(_:).

1 Like

And in many other languages.

It’s treated like a “container” type, not sequence. It will be more annoying to have different functions for same stuff if you’re applying functional programming paradigm.

1 Like

Again, map is not related to sequences.

Sequences happen to have map, because it is a functor. Most people come across sequence’s map first, and draw the incorrect conclusion that it therefore has to do with sequences, lists, collections. When learning about optional’s map, some people will justify its existence by imagining optional to be a «collection of zero or one element». It kinda works, but the analogy breaks fairly quickly.

But I do agree that it’s easy to accidentally get the wrong map when dealing with optional sequences, and that this in both unfortunate and common.

4 Likes

I totally agree. However, because they both return Optional<U>, why do we need map and flatMap with Optional <T>? Is there a compelling reason for having them?

The official documentation says:

map(_:)

Evaluates the given closure when this Optional instance is not nil, passing the unwrapped value as a parameter.

func map<E, U>(_ transform: (Wrapped) throws(E) -> U) throws(E) -> U? where E : Error,
flatMap(_:)

Evaluates the given closure when this Optional instance is not nil, passing the unwrapped value as a parameter.

func flatMap<E, U>(_ transform: (Wrapped) throws(E) -> U?) throws(E) -> U? where E : Error, U : ~Copyable

The official documentation provides this example:

let possibleNumber: Int? = Int("42")
let nonOverflowingSquare = possibleNumber.flatMap { x -> Int? in
    let (result, overflowed) = x.multipliedReportingOverflow(by: x)
    return overflowed ? nil : result
}
print (nonOverflowingSquare)
// Prints "Optional(1764)"

The above example can be written as:

let possibleNumber: Int? = Int("42")
if let number = possibleNumber {
    let (result, overflowed) = number.multipliedReportingOverflow (by: number)
    let nonOverflowingSquare = overflowed ? nil : result
    if let nonOverflowingSquare {
        print (nonOverflowingSquare)
        // Prints "1764"
    }
}

PS: Optional.unwrap would be as confusing as map and flatMap if it were available. :slight_smile:

Short answer: math. :upside_down_face:

Longer answer: if let is just language sugar, and it’s completely fine to only use it for Optional. But there is no if let for Result, Array or Publisher. Working the other way around—without special cases—once you recognise map and flatMap as the generic interface for “transform inside a context” and “chain contexts without nesting,” you use the same vocabulary for Optional, Array, Result, Combine, etc.

IMHO Swift is close to Scala, Rust, F#, OCaml. It’s functional under the hood, so having FP primitives makes sense.

Another point having those functions is you can actually chain them:

Int("42")
     .map { $0 + 1 }
     .flatMap { x -> Int? in
         let (result, overflowed) = x.multipliedReportingOverflow(by: x)
         return overflowed ? nil : result
     }
     .flatMap { print($0) }

In if let case more checks you add then less readable it becomes.


Btw a bit off topic now, but think these examples are not quite equivalent, and it's good to figure out why to understand whole FP point: printing is a side effect, e.g. easy to see if you put some async function there.

Something similar would be:

let nonOverflowingSquare  = Int("42")
     .map { $0 + 1 }
     .flatMap { x -> Int? in
         let (result, overflowed) = x.multipliedReportingOverflow(by: x)
         return overflowed ? nil : result
     }
     // Can't do: .flatMap { await printAndSend($0) }

// Explicit side effect
if let nonOverflowingSquare {
   async printAndSend(nonOverflowingSquare)
}

And another example should be:

let possibleNumber: Int? = Int("42")
var nonOverflowingSquare: Int?
if var number = possibleNumber {
    number += 1
    let (result, overflowed) = number.multipliedReportingOverflow(by: number)
    nonOverflowingSquare = overflowed ? nil : result
    // putting async function will couple everything here and hard to read at least
}

// better do separately for maintenance and readability 
if let nonOverflowingSquare {
        await printAndSend(nonOverflowingSquare)
        // Prints and sends "1849"
}

Two things:

  1. Adding var to number introduces mutation, which can lead to bugs (another side effect).
  2. Swift does have simple effect system with async and throws[1], and by using them you can look how it starts to punish you.

I think that's one of the reasons people love talking about side effects in FP languages—because tracking them at compile time helps you write more maintainable, readable code.

Overall, which paradigms to include in a language and why is a complex topic, but understanding how to use these constructions is crucial to understand why they are in the language in the first place.


  1. Whether it expands to cover other effects, or whether Optional gains async overloads, is an open question. ↩︎

2 Likes

For the same reason with need Array<T>.map and Array<T>.flatMap.
They're useful :grinning_face_with_smiling_eyes:

5 Likes

It looks about the same to me. I think Optional.unwrap(_:) is easier to understand, but it gets lost in the details of computing the square in a safe way.

let possibleNumber: Int? = Int("42")
let nonOverflowingSquare = possibleNumber.unwrap { x -> Int? in
    let (result, overflowed) = x.multipliedReportingOverflow(by: x)
    return overflowed ? nil : result
}
print (nonOverflowingSquare)

However, creating a method safely squaring the an Int would be nice.

func checkedSquare(_ x: Int) -> Int? {
    let (result, overflowed) = x.multipliedReportingOverflow(by: x)
    return overflowed ? nil : result
}

Then, I think unwrap(_:) looks really good.

let possibleNumber: Int? = Int("42")
let nonOverflowingSquare = possibleNumber.unwrap { checkedSquare($0) }
print (nonOverflowingSquare)

or even better

let possibleNumber: Int? = Int("42")
let nonOverflowingSquare = possibleNumber.unwrap(checkedSquare(_:))
print (nonOverflowingSquare)

I'm interested in being able to quickly understand the code while reading it. Large or complex closures really slow down the reading process.

As a side note, large or complex closures can be a pain to unit test also.

I think it's the transform function, not you, that decides which function to call. I assume you meant the following code:

func increase(_ n: Int) -> Int {
    n + 1
}

func increaseOrNil(_ n: Int) -> Int? {
    n + 1
}

let x: Int? = 1
print(x.map(increase) ?? "")
print(x.flatMap(increase) ?? "") // This compiles 

It compiles only because compiler performs an implicit Optional promotion, otherwise it wouldn't compile.


Swift doesn't support using unspecialized generic type in protocol. If that's possible in future (from what I read in the forum it's not entirely impossible), it would be possible to define protocols like Functor and Monad. The it would be easy to understand that map and flatmap are common methods of these patterns.