[Pitch] Introduce `let-else` syntax as alternative for single expression `guard-let-else`

Guards are a powerful part of the Swift language, but they come with a cognitive cost — they break the continuity of a run of statements. For a multiple expression guard, this is perfectly acceptable, because the guard itself looks and acts like its own block of related code. But if you are using a guard to simply unwrap an optional, it leads to an unfortunate break in the flow of the code.

Take this example:

guard let store = sharedNotesStore else { throw Error.noSharedNotesStoreAvailable }
let blobs: [SharedNote.Blob] = [.init("ABC")]
guard let data = section.propertyDictionary.json else { throw Error.serializationFailure }
let key = UUID().uuidString
guard let section = section(withIdentifier: noteIdentifier, data: data) else { throw Error.noSectionFoundForNote }
let sharedNote = SharedNote(store: store, section: section, key: key, blobs: blobs)

This code just converts data from one type to the next. It is very common in the real world. But as it is, it is not so easy to read. Although most of the lines amount to a let declaration, the error handling, in the form of the guard's, reduces the readability. There is no left alignment of let keywords — the guard's jump in — making a quick scan of the code tricky.

What if the guard keyword could be left out in cases where you have a simple, single guard-let-else combination? In effect, the option of using a let-else, which behaves the same way, but which is syntactically more similar to a standard let.

With this approach, the code above would look like this.

let store = sharedNotesStore else { throw Error.noSharedNotesStoreAvailable }
let blobs: [SharedNote.Blob] = [.init("ABC")]
let data = section.propertyDictionary.json else { throw Error.serializationFailure }
let key = UUID().uuidString
let section = section(withIdentifier: noteIdentifier, data: data) else { throw Error.noSectionFoundForNote }
let sharedNote = SharedNote(store: store, section: section, key: key, blobs: blobs)

IMO this is much more readable. It's a small change, but this type of code pervades real world software projects, and this small change would make a lot of swift code that much more readable.

9 Likes

The cognitive load of identifying guard statements without the keyword seems much higher than with the keyword. You're not "just" unwrapping optionals; those are unique potential points of failure and being able to immediately identify them is important for (at least) those not writing the code.

Is the massive source breakage worth it for the sake of vertically aligning lets?

28 Likes

Disagree (of course). In fact, dropping the guard makes things much easier to read in terms of unwrapping too. The “else” is placed very close to where you would normally have an unwrap operator like !. Perfect.

This also is a completely backward compatible change. The variant with “guard” included would still be possible. So it would not break any old code, or require any changes.

3 Likes

-1 for me.

This adds a special case syntax for very limited gain.
A more flexible approach I would prefer was proposed in Make Never the bottom type: in the "Future directions" notes, the author proposes making the throw statement have a type of Never, enabling something like this:

let x = optionalValue ?? throw SomeError()
35 Likes

Personally, I think a small, incremental change, which stays syntactically close to what Swift already has, is more likely to be successful. What you propose is basically a Perl like approach. I think that has been proposed quite a few times before.

A compromise might be to make the braces optional in this case. Then you get something very close to what you are proposing, without completely changing the semantics of the language. Namely

let x = optionalValue else throw SomeError()
1 Like

I would also like to see Never as a bottom type, and indeed comparable syntax can be achieved today with generic helper functions for throwing and the various ways of crashing.

However, there are other ways to exit a guard scope, namely return, break, continue, and fallthrough. Those keywords cannot go on the right-hand side of an “??” operator even with the feature you describe.

9 Likes

I would also like to see Never as a bottom type, and indeed comparable syntax can be achieved today with generic helper functions for throwing and the various ways of crashing.

Example for the reader, working today in Swift 5.3
func throwing<T>(_ error: Error) throws -> T { throw error }

struct SomeError: Error {}

let optionalValue: Int? = nil

let x = try optionalValue ?? throwing(SomeError())

However, there are other ways to exit a guard scope, namely return , break , continue , and fallthrough . Those keywords cannot go on the right-hand side of an “ ?? ” operator even with the feature you describe.

I don't see any problem for break, continue and fallthrough:

Ideas that turned out to be wrong
let optionalValues: [Int?] = [1, 2, nil, 4, nil, 6]

for value in optionalValues {

//    guard let unwrappedValue = value else { break }
    let unwrappedValue = value ?? break // Possible if `break is Never`

    // ...
}

return is a bit harder to wrap your head around, because it might return a value of a different type than the value that you're trying to assign:

func returnAString() -> String {
    let optionalValues: [Int?] = [1, 2, nil, 4, nil, 6]

    for value in optionalValues {

//        guard let unwrappedValue = value else { return "a" }
        let unwrappedValue: Int = value ?? return "a" // Possible if `(return x) is Never`

        // ...
    }

    return "b"
}

That said, there are all possible future directions of the "Never as the bottom type" pitch, that probably deserve a pitch each and few rounds of review.

My point is that I would prefer to see a single solution that enables these use cases and more at the type system level, instead of introducing another almost equivalent way of spelling a guard.

The problem is that they would be evaluated inside the “??” function itself, because its right-hand side is an autoclosure.

4 Likes

I’m in agreement with others here that losing the guard is not great. If what you want is readability would it be possible with a linter to align lets in any scope with a guard let so they all line up like this:

guard let store = sharedNotesStore else { throw Error.noSharedNotesStoreAvailable }
      let blobs: [SharedNote.Blob] = [.init("ABC")]
guard let data = section.propertyDictionary.json else { throw Error.serializationFailure }
      let key = UUID().uuidString
guard let section = section(withIdentifier: noteIdentifier, data: data) else { throw Error.noSectionFoundForNote }
      let sharedNote = SharedNote(store: store, section: section, key: key, blobs: b

That seems like the best of all worlds - the clarity of retaining the guard and the quick scanability to see that it’s just a long list of let’s.

2 Likes

I find the use of guard very helpful both when I'm writing and reading code and think I would find the cognitive cost to be higher if any let or var assignment could potentially be an early exit.

I realize that indentation, spaces between lines, putting else clauses on the same or different lines is a matter of personal preference. But I think that some approaches make guard statements read with more continuity than others.

In the example provided, I would probably group the guard statements together, so that all of the things that need to be successful to proceed happen in one conceptual run of code.

guard let store = sharedNotesStore else { throw Error.noSharedNotesStoreAvailable }
guard let data = section.propertyDictionary.json else { throw Error.serializationFailure }
guard let section = section(withIdentifier: noteIdentifier, data: data) else { throw Error.noSectionFoundForNote }

let blobs: [SharedNote.Blob] = [.init("ABC")]
let key = UUID().uuidString
let sharedNote = SharedNote(store: store, section: section, key: key, blobs: blobs)

In the midst of a method, I also tend to add a line of whitespace before a guard statement, so that it stands out as a precondition for proceeding.

For me, one of the benefits of guard is that I know immediately that this is code that will exit early in some way. Even if the else clause is on the same line as shown in the example code, you are certain that you will find it at end of the line by seeing that initial guard keyword.

With the proposed change, for longer lines, you need to scan pretty far to the right to discover that this line of code can have bigger consequences than simply assignment. For each and every let or var declaration, you never know if maybe there's an early exit without scanning almost the entire line.

let section = section(withIdentifier: noteIdentifier, data: data) else { throw Error.noSectionFoundForNote }

I also think that if let and guard let both communicate that optional binding is occurring and that what is happening is not simply an assignment.

I can absolutely see the lack of flow in the code example provided, but I think this proposed change would reduce the clarity of all variable and constant declarations by making them ambiguous until the developer scanned the entire line. So, I am -1 for this proposal.

20 Likes

Thanks for the feedback, James.

I too group guards like you show, but that is more a workaround to the restrictions in place. I am effectively forced to order my declarations in a certain way to achieve something readable. The fact you even do that grouping demonstrates the exact problem I feel exists. If the guards interlaced with the other let's were readable, you wouldn't group like that. And, of course, in some cases, the interdependencies of the variables may even make that difficult or impossible anyway.

I would argue we already have this in many places in Swift, just not in guards. Eg. a try can be pretty far into an expression, and also leads to an early exit.

Is there really that much difference between

let x = try something()

and

let x = somethingOptional() else throw Error.wasNil

1 Like

That, to me, sounds like an argument that throwing statements should require a leading keyword, not that the guard keyword should be optional. (That is to say, potentially non-linear flow control should be obvious.)

Anyway, a throwing helper function that you can use on the right of ?? solves this problem today, without requiring compiler changes. I use such a function in several of my projects.

3 Likes

Why not? It seems perfectly straightforward that you would group guards together since they have a similar semantic meaning. From a code organization perspective it seems more correct to group things that behave similarly together than to interleaved them with things that behave differently...

2 Likes

You don't group things by the fact that they might produce errors. That would be a very poor way to organize code. You order things according to a logical process, and the possible errors are by products that can occur throughout.

Sure, that solves one case, but only the throwing one. There are many other 'early returns' covered by a guard.

Here's a quote from a discussion about a different requested language feature, because I, curmudgeon, feel it applies here:

We have separate categories on the forums for those types of discussion.

I wish the general discussion on this forum was centered more on "how do I achieve what I want to do with the existing language features"

That goes in Using Swift

rather than "here's a new language feature that solves my immediate problem today, what does everything think of adding it to the language?"

That goes in Swift Evolution

• • •

It seems rather odd to lament that Swift Evolution is being used for Swift Evolution things, rather than for Using Swift things.

5 Likes

I suppose the lament is more specifically that community members tend to jump too quickly from "I have a problem" to #evolution directly, when a #swift-users discussion ("how do I achieve [...] with the existing language features") would often be an appropriate first step. I'm definitely guilty of this myself. :grinning_face_with_smiling_eyes:

12 Likes

In this case, I know how to do it with the language as it currently is. Have been doing that for many years. I lament that something so common is not more readable. I have these statements literally everywhere, and guards for something so simple, when we have nice elegant syntax like optional chaining, is a bit of a syntactical shame.

1 Like

I'm not in favour of this pitch. Personally I would write it like this:

guard let store = sharedNotesStore else {
    throw Error.noSharedNotesStoreAvailable
}

let blobs: [SharedNote.Blob] = [.init("ABC")]

guard let data = section.propertyDictionary.json else { 
    throw Error.serializationFailure
}

let key = UUID().uuidString

guard let section = section(withIdentifier: noteIdentifier, data: data) else {
    throw Error.noSectionFoundForNote
}

let sharedNote = SharedNote(
    store: store, 
    section: section, 
    key: key, 
    blobs: blobs
)

In my opinion the guard word is super important as is the else block, which, IMO, deserves to be indented in its own line so the alternate path is easily visible. IMO, code should flow more vertically than horizontally and each new scope deserves a new line and indent instead of being shoved aside as if it didn't deserve much attention.

7 Likes