How to use if and switch as expressions in Swift

⋅ 5 min read ⋅ Swift Swift Evolution

Table of Contents

To set a constant from a condition, we used to have two options, and neither was good.

We could declare a var, then assign to it.

let bullet: String
if isRoot && count == 0 {
bullet = ""
} else if count == 0 {
bullet = "- "
} else {
bullet = "▿ "
}

Or we could chain ternaries and give up on reading it later.

let bullet = isRoot && count == 0 ? "" : count == 0 ? "- " : "▿ "

SE-0380, introduced in Swift 5.9, gives us a third.

let bullet = if isRoot && count == 0 { "" }
else if count == 0 { "- " }
else { "▿ " }

Where we can use them

An if or switch can now produce a value in three places.

Assigning to a variable or declaring one:

let width = switch scalar.value {
case 0..<0x80: 1
case 0x80..<0x0800: 2
default: 4
}

Returning from a function, property, or closure:

func width(_ x: Unicode.Scalar) -> Int {
switch x.value {
case 0..<0x80: 1
case 0x80..<0x0800: 2
default: 4
}
}

Note there is no return in that function body. A single switch expression as the whole body gets the same implicit-return treatment a single expression always did.

As the value of a var at declaration, which is the case that replaces the assign-later pattern above.

Pattern matching works too, so if let composes with it:

let name = if let user { user.displayName } else { "Guest" }

If that reads oddly, I wrote about the shorthand if let it relies on.

You can easily support sarunw.com by checking out this sponsor.

Sponsor sarunw.com and reach thousands of iOS developers.

Each branch is one expression

This is the rule that trips people up. Every branch must be a single expression.

// error: a statement, then an expression
let bullet = if count == 0 {
print("empty")
"- "
} else {
"▿ "
}

Adding return does not rescue it:

let bullet = if count == 0 {
print("empty")
return "- " // error: cannot use 'return' to transfer
// control out of 'if' expression
} else {
"▿ "
}

return means "return from the enclosing function", so it can't also mean "this is the branch's value". There is no keyword that does the second thing.

So when a branch needs a statement before its value, we stop using the expression form. Back to a var:

let bullet: String
if count == 0 {
print("empty")
bullet = "- "
} else {
bullet = "▿ "
}

Or, if we are returning from a function anyway, an ordinary if statement with a return in each branch — which has always worked and is not this feature at all:

func bullet(for count: Int) -> String {
if count == 0 {
print("empty")
return "- "
} else {
return "▿ "
}
}

There is one exception. A branch may have multiple statements if it ends by throwing or otherwise never returns.

let bullet = if count == 0 {
"- "
} else {
logger.error("unexpected count")
fatalError("unreachable")
}

A Never-typed branch also doesn't participate in type checking, so this infers Int rather than failing:

let x = if .random() { 1 } else { fatalError() }  // x is Int

An if expression needs an else

A statement if is free to do nothing. An expression if has to produce a value on every path, so the else is mandatory.

let x = if condition { 1 }             // error: missing else
let x = if condition { 1 } else { 2 } // ok

For switch, the usual exhaustiveness rules already guarantee this, so nothing new applies.

Branches are type checked independently

This is the second surprise, and the one worth remembering, because the error message doesn't explain itself well.

Each branch is type checked on its own, and only then are the results compared. There is no bidirectional inference across branches.

let x = if p { 0 } else { 1.0 }  // error

We might expect 0 to become a Double because the other branch is one. It doesn't. 0 type checks independently as Int, 1.0 as Double, the two disagree, and the compiler stops.

The fix is to supply the type from outside:

let y: Double = if p { 0 } else { 1.0 }  // ok

This is the main difference from the ternary operator, which does infer across both sides. Converting a ternary to an if expression is not always a mechanical swap.

The same rule bites with nil:

let a = if p { nil } else { 2 }             // error
let b: Int? = if p { nil } else { 2 } // ok

What is still not allowed

The proposal deliberately kept the scope narrow, so if and switch are expressions only in those three positions — not everywhere an expression can appear.

They cannot be used as a sub-expression or as a function argument:

foo(if p { 1 } else { 2 })     // error
let x = (if p { 1 } else { 2 }) + 1 // error

We cannot chain onto the result either:

let x = if p { "a" } else { "b" }.uppercased()  // not what you want

And in a result builder — a SwiftUI body, for instance — nothing changes. Result builders already handled if and switch through buildEither, and this proposal doesn't touch that.

When I reach for it

The clear win is the let that used to require a var, or a two-stage declare-then-assign. That pattern shows up constantly and the expression form removes real noise from it.

I'd keep the ternary for genuinely small binary choices. count == 1 ? "item" : "items" does not get better as a three-line if.

And I'd stop at the single-expression boundary rather than bending code to fit inside it. If a branch wants a log line or an intermediate value, the old form is still the readable one — that is a limitation of the feature, not a failure of the code.

You can easily support sarunw.com by checking out this sponsor.

Sponsor sarunw.com and reach thousands of iOS developers.

Summary

Swift 5.9 lets if and switch produce values when assigning, declaring, or returning.

Each branch must be a single expression, an if expression must have an else, and branches are type checked independently — so mixed numeric literals need an explicit type annotation in a way the ternary operator never did.

They are not full expressions yet: no use as a function argument, sub-expression, or method receiver. Within those limits, the feature deletes a lot of var-then-assign boilerplate.


Read more article about Swift, Swift Evolution, or see all available topic

Enjoy the read?

If you enjoy this article, you can subscribe to the weekly newsletter.
Every Friday, you'll get a quick recap of all articles and tips posted on this site. No strings attached. Unsubscribe anytime.

Feel free to follow me on Twitter and ask your questions related to this post. Thanks for reading and see you next time.

If you enjoy my writing, please check out my Patreon https://www.patreon.com/sarunw and become my supporter. Sharing the article is also greatly appreciated.

Become a patron Buy me a coffee Tweet Share
Previous
How to add cases to a public enum without breaking your API

Adding a case to a public enum is a source-breaking change for anyone switching over it. Swift's @nonexhaustive attribute finally lets packages opt out of that.

← Home