How to use if and switch as expressions in Swift
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.
Offline Transcription: Fast, privacy-focus way to transcribe audio, video, and podcast files. No data leaves your Mac.
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.
Offline Transcription: Fast, privacy-focus way to transcribe audio, video, and podcast files. No data leaves your Mac.
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 ShareHow 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.