Pitch idea: Compiler warnings about explicit vs implicit returns

Pitch idea: Compiler warnings about explicit vs implicit returns

Motivation

Swift’s handling of implicit vs explicit return in single-expression functions and closures can lead to ambiguity—especially in the presence of @discardableResult and loosely typed closures.

Problem

Two related issues stand out:

  • Ambiguity with @discardableResult
    A function returning a value (e.g. Int) but marked @discardableResult can behave differently depending on context:
    • In a closure typed as Void, it may be treated as a discarded result (sometimes with a warning).
    • In an untyped closure, it may silently become the return value. This makes seemingly small changes (e.g. adding items.removeLast()) alter behavior in non-obvious ways.
  • Lack of explicit-return tooling support
    While Swift supports omitting return, there is currently no symmetrical, compiler-supported way to prefer explicit returns, and linter support is problematic in type imcoplete or ambiguous cases like the above. This leaves teams without a reliable way to enforce intent when they want clarity over brevity.

Proposal

  • Introduce a new compiler-supported option with three modes: default (current behavior), “prefer omitted returns” (similar to today’s linters in limited contexts), and “prefer explicit returns” (a new capability not currently supported by linters).
  • Make the compiler aware of @discardableResult when deciding return behavior, preferring explicit return to clarify intent.
  • Ensure consistent behavior across:
    • functions
    • closures (properly typed, mistyped, untyped)
    • (future) multi-expression bodies

Why Compiler-Level

Linters cannot reliably detect @discardableResult across file or module boundaries. The compiler already has this semantic knowledge (and uses it for unused result warnings), making it the correct place to enforce or guide this behavior.

Benefit

  • Eliminates subtle, context-dependent behavior
  • Makes intent explicit when it matters
  • Provides symmetry between “omit return” and “prefer explicit return” styles

A table comparing current behaviour and proposed modes is attached below where :white_check_mark: indicates success, :large_orange_diamond: - a warning, and :cross_mark: - an error.


@discardableResult func foo() -> Int { 0 }
func bar() -> Int { 0 }
//                                           OFF (AS TODAY)
//                                           |   PREFER IMPLICIT RETURNS (OMIT RETURNS IF POSSIBLE)
//                                           |   |    PREFER EXPLICIT RETURNS (ALWAYS PUT RETURNS)
//                                           ↓   ↓    ↓
func baz1() -> Int { foo() }             //  ✅  🔶  🔶 (OK | need return for clarity | want return)
func baz2() -> Int { bar() }             //  ✅  ✅  🔶 (OK | OK | want return)
func baz3() -> Int { return foo() }      //  ✅  ✅  ✅ (OK | OK | OK) 
func baz4() -> Int { return bar() }      //  ✅  🔶  ✅ (OK | unwanted return | OK)
func baz5() -> Void { foo() }            //  ✅  🔶  🔶 (OK | need return for clarity | want return)
func baz6() -> Void { bar() }            //  🔶  🔶  🔶 (unused result | unused result | want return
func baz7() -> Void { return foo() }     //  ❌  ❌  ❌ (wrong type | wrong type | wrong type)
func baz8() -> Void { return bar() }     //  ❌  ❌  ❌ (wrong type | wrong type | wrong type)

let baz9 = { foo() }                     //  ✅  🔶  🔶 (OK | need return for clarity | want return)
let baz10 = { bar() }                    //  ✅  ✅  🔶 (OK | OK | want return)
let baz11 = { return foo() }             //  ✅  ✅  ✅ (OK | OK | OK)
let baz12 = { return bar() }             //  ✅  🔶  ✅ (OK | unwanted return | OK)

let baz13: () -> Int = { foo() }         //  ✅  🔶  🔶 (OK | need return for clarity | want return)
let baz14: () -> Int = { bar() }         //  ✅  ✅  🔶 (OK | OK | want return)
let baz15: () -> Int = { return foo() }  //  ✅  ✅  ✅ (OK | OK | OK)
let baz16: () -> Int = { return bar() }  //  ✅  🔶  ✅ (OK | unwanted return | OK)
let baz17: () -> Void = { foo() }        //  ✅  🔶  🔶 (OK | need return for clarity | want return)
let baz18: () -> Void = { bar() }        //  🔶  🔶  🔶 (unused result | unused result | want return)
let baz19: () -> Void = { return foo() } //  ❌  ❌  ❌ (wrong type | wrong type | wrong type)
let baz20: () -> Void = { return bar() } //  ❌  ❌  ❌ (wrong type | wrong type | wrong type)

I disagree that calls like baz1 or baz13 should ever warn unless implicit returns are being warned on wholesale. @discardableResult is for functions where the side effect is the most important part and the return value may or may not be relevant. It's not for functions that should always be discarded, and it's wrong to warn when the result of such a function is used in a position with a matching explicit type.

And Baz5 and Baz17 should never warn at all. Not warning in such a situation is the entire point of having @discardableResult in the first place!

I do understand wanting a warning for baz9 though.

As a personal note, the only time I've ever been surprised by the presence of a non-discarded @discardableResult was in situations like:

x.withUnsafeBlahBlahBlah {
  someDiscardableFunction($0)
}

Which already produces a warning.