Swift 5.5 async operation as Optional default

Viewed 461

I have an async function that returns an optional String.

func traverse(file:URL, for string:String) async throws -> String? {
...
}

I want to run this function and if nil, run it with different params like this:

async let langRes = try traverse(file: languageFile, for: string)
async let engRes = try traverse(file: englishFile, for: string)
let result = try await langRes ?? engRes

but the optional default engRes gives the error:

'async let' in an autoclosure that does not support concurrency

Something like this works fine:

let result = try await langRes ?? ""

It just doesn't like using an async function for the default/fallback.

I know I can do it like this:

var result = try await langRes
if result == nil {
    result = try await engRes
}

but it would be nice to use the optional chaining fallback ??

Do I have the syntax wrong or will it just not work?

2 Answers

You are perfectly correct: you can't use ?? the way you want to. The second member (after the ??) is indeed an autoclosure, so that it is not evaluated unless it has to be. But you cannot have that when using await. So you will just have to suck it up and use a more clumsy way of talking, just as you have suggested. Personally I would probably write:

    async let langRes = traverse(file: languageFile, for: string)
    async let engRes = traverse(file: englishFile, for: string)
    let result : String
    if let temp = try await langRes { result = temp }
    else if let temp = try await engRes { result = temp }
    else { result = "" }

Note, however, that you have to decide whether this is really a good use of async let, because what you are doing is launching two concurrent processes, for both langRes and engRes, regardless of whether the first one succeeds in getting you a non-nil result. It seems to me that what you want is probably actually successive await calls so that you don't even start the second one if the first one succeeds.

As you've noted in your comments to matt, this basic approach is not likely what you want. This always kicks off both requests, and so always has to wait for both to finish before returning (even if you never need them both).

You can get something like what you want this way, but it's pretty messy (which is why I suspect they haven't integrated it yet):

Create a new operator ¿¿ that combines async operations:

precedencegroup AsyncNilCoalescingPrecedence {
    associativity: right
    higherThan: NilCoalescingPrecedence
}

infix operator ¿¿ : AsyncNilCoalescingPrecedence

public func ¿¿<T>(optional: T?, defaultValue: () async throws -> T?) async rethrows -> T? {
    if let result = optional { return result } else { return try await defaultValue() }
}

Your default value should be in a closure so it doesn't run automatically:

let langRes = try await traverse(file: languageFile, for: string)
let engRes = { try await traverse(file: englishFile, for: string) }

And then you could do what you want (returning a String):

let result = try await langRes ¿¿ engRes ?? "default"

That said, I'd say that anything that tries to solve this with a ??-like operator is going to wind up being very subtle and easy to accidentally kick off tasks you didn't mean. Writing it matt's way makes it much more obvious that what you're doing is actually wrong.

Another similar approach would be:

let result: String
if let r = try await traverse(file: languageFile, for: string) { result = r }
else if let r = try await traverse(file: englishFile, for: string) { result = r }
else { result = "default" }

That said, I'd probably consider just extracting a loop instead:

func traverse(files: [URL], for string:String) async throws -> String {
    for file in files {
        if let result = try await traverse(file: file, for: string) { return result }
    }
    return "default"
}

let result = traverse([langRes, englishRes], for: string)

I believe it should be be possible to create something like:

let result = try await [langRes, englishRes].lazy
    .compactMap({try await traverse($0, for: string)})
    .first ?? "default"

But I don't think AsyncSequence is quite up to that yet, without adding a lot of extensions. IMO, mapping a Sequence with an async closure should create an AsyncMapSequence. But it currently doesn't. Also, it feels like it should be possible to call first on an AsyncSequence, but that's also not possible (you'd have to call first(where: {true}) or something silly like that as best I can tell).

(Agreed with matt that this is a great question. This is worth discussing on the Swift forum. It's the kind of thing that should have a clear pattern.)

Related