Conditions in C#: if/else, switch and Switch Expressions
The ways to make decisions in C#: why order matters in an if/else chain, braces and scope, a switch statement that forbids fall-through, the value-returning switch expression, and the relational and logical patterns of C# 9.
Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.
Guess First
Our bookstore picks the shipping fee from the order total. Above 200 shipping is free, above 100 it is discounted, everything else is standard. We wrote the code, it compiles, and there are no warnings. With a total of 250, what does it print?
decimal orderTotal = 250m;
string shipping;
if (orderTotal > 100m)
{
shipping = "Discounted";
}
else if (orderTotal > 200m)
{
shipping = "Free";
}
else
{
shipping = "Standard";
}
Console.WriteLine(shipping);
The answer is Discounted. 250 is greater than 100, so the first condition is true, that block runs, and the rest of the chain is never looked at. In an else if ladder the conditions are tried top to bottom and the first true one wins. Unless the narrower condition (> 200) moves to the top, the "Free" branch can never run for this code. In this post we look at every way to make a decision in C#: if, the switch statement, the switch expression and the patterns that got much stronger in C# 9.
if, else if and else
if evaluates the expression in parentheses; if the result is true, it runs the block. else if adds a new condition to try when the previous one did not hold, and else is the last door that runs when none of them held. The correct version of the example above orders the conditions from the narrowest to the widest: first > 200m, then > 100m, and else last.
In C# a condition must always be a bool. If you come from C, JavaScript or Python, you are used to a world where a non-zero number counts as "true"; C# does not allow it:
int stock = 3;
if (stock)
{
Console.WriteLine("In stock");
}
Program.cs(2,5): error CS0029: Cannot implicitly convert type 'int' to 'bool'
You have to state your intent: if (stock > 0). At first it looks like extra typing, but this rule removes the question "did you mean 0, null or an empty string?" up front. Anyone reading the code sees exactly what the condition checks.
Braces and Scope
If the block has a single line, the code compiles even without braces. The trouble starts on the day you add a second line:
int stock = 0;
if (stock > 0)
Console.WriteLine("In stock");
Console.WriteLine("Ships today");
Ships today
Stock is zero, yet "Ships today" was printed. Indentation means nothing to C#; without braces only the single statement right after the if belongs to it. The second Console.WriteLine always runs, whatever its indentation. That is why many teams have a "braces even for one line" rule, and the IDE0011 code style rule in IDEs exists exactly to enforce it.
Braces also open a scope. A variable you declare inside a block is gone when the block ends:
int stock = 4;
if (stock > 0)
{
string label = stock < 5 ? "Last copies" : "In stock";
Console.WriteLine(label);
}
Console.WriteLine(label);
Program.cs(9,19): error CS0103: The name 'label' does not exist in the current context
If you need the variable outside the block, declare it before the block. We also used the ?: operator for a small condition here; when you are choosing a single value it is shorter and more readable than if/else, but if you ever see three nested ?: operators, turn that code into an if or a switch.
The switch Statement
When you compare one value against many constants, an if/else if chain grows long and gets hard to read. The switch statement is made for this:
string category = "novel";
switch (category)
{
case "novel":
case "poetry":
Console.WriteLine("Literature shelf");
break;
case "science":
Console.WriteLine("Science shelf");
break;
default:
Console.WriteLine("General shelf");
break;
}
Literature shelf
case labels written one under the other share the same block: "novel" and "poetry" go to the same shelf. default runs when no case matches. Each section ends with break (or with a statement that leaves the flow, such as return or throw).
The "fall through from one case to the next" behavior people bring from C is forbidden in C#. If you leave a case section that contains code without a break, the compiler stops you:
int dayOfWeek = 6;
switch (dayOfWeek)
{
case 1:
Console.WriteLine("Monday: new arrivals");
case 6:
case 7:
Console.WriteLine("Weekend: free gift wrap");
break;
default:
Console.WriteLine("Regular day");
break;
}
Program.cs(5,5): error CS0163: Control cannot fall through from one case label ('case 1:') to another
This is a deliberate fix for a behavior that caused bugs in C and C++ for years. If you really want to continue into another section, you can write goto case 6;, but you will almost never see that in a real project; moving the shared code into a method is usually cleaner.
The switch Expression
The switch expression, which arrived in C# 8, is the value-returning form of switch. When your goal is to pick one value based on another, it is much shorter and clearer than the statement:
string format = "ebook";
decimal shippingFee = format switch
{
"hardcover" => 4.90m,
"paperback" => 2.90m,
"ebook" => 0m,
_ => throw new ArgumentException($"Unknown format: {format}")
};
Console.WriteLine(shippingFee);
0
The value comes first and switch comes after it. Each line is an arm: the pattern on the left, the result to the right of =>. Arms are separated by commas, there is no break, and the expression ends with a semicolon. _ means "everything else"; it is the counterpart of default in the statement. An arm can also throw an exception instead of producing a value, because since C# 7 throw can be used as an expression too.
So what happens if you forget the _ arm? The compiler notices, but it only warns:
string format = "audiobook";
decimal shippingFee = format switch
{
"hardcover" => 4.90m,
"paperback" => 2.90m,
"ebook" => 0m
};
Console.WriteLine(shippingFee);
Program.cs(3,30): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern '""' is not covered.
Unhandled exception. System.Runtime.CompilerServices.SwitchExpressionException: Non-exhaustive switch expression failed to match its input.
Unmatched value was audiobook.
at <PrivateImplementationDetails>.ThrowSwitchExpressionException(Object unmatchedValue)
at Program.<Main>$(String[] args) in /src/Bookstore/Program.cs:line 3
The code compiled, but when a value arrived that matched nothing, the runtime threw a SwitchExpressionException. Never ignore CS8509; either add a _ arm or make sure you cover every case.
Patterns: Type, Relational and Logical
On the left of a case or a switch expression arm you can write a pattern, not only a constant. The relational patterns (<, <=, >, >=) and logical patterns (and, or, not) that came with C# 9 make range checks very readable. A discount rate that grows with the quantity:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
foreach (int quantity in new[] { 1, 4, 12, 60 })
{
Console.WriteLine($"{quantity} -> {DiscountRate(quantity)}");
}
static decimal DiscountRate(int quantity) => quantity switch
{
<= 0 => throw new ArgumentOutOfRangeException(nameof(quantity)),
< 3 => 0m,
>= 3 and < 10 => 0.05m,
>= 10 and < 50 => 0.10m,
_ => 0.15m
};
1 -> 0
4 -> 0.05
12 -> 0.10
60 -> 0.15
Decimal output depends on the culture, so we fixed the culture at the start of the example; with the Turkish culture you would see 0,05. The arms are again tried in order, so the >= 3 check is actually redundant: we already know a value that reaches that arm is not less than 3. Still, writing both ends of the range answers the question "which range is this arm, exactly?" at a glance.
A type pattern checks the type of the value and, if it matches, puts the value into a new variable of that type. With when you can add an extra condition (a case guard) to an arm:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
object?[] items = { 42, -1, 19.90m, "978-0441013593", "Dune", null, true };
foreach (object? item in items)
{
Console.WriteLine(Describe(item));
}
static string Describe(object? value) => value switch
{
null => "nothing",
int count when count > 0 => $"quantity {count}",
int => "invalid quantity",
decimal price => $"price {price}",
string isbn when isbn.StartsWith("978", StringComparison.Ordinal) => $"ISBN {isbn}",
string text => $"text '{text}'",
_ => $"unsupported: {value.GetType().Name}"
};
quantity 42
invalid quantity
price 19.90
ISBN 978-0441013593
text 'Dune'
nothing
unsupported: Boolean
The int count when count > 0 arm both checks the type and makes the value available as count. -1 could not pass that arm and fell to the next int arm. For the ISBN check we passed StringComparison.Ordinal to StartsWith; an ISBN is a culture-independent code, and the comparison should be too. Patterns also work outside switch, with is:
char grade = 'b';
bool isLetter = grade is >= 'a' and <= 'z' or >= 'A' and <= 'Z';
Console.WriteLine(isLetter);
string? coupon = null;
if (coupon is not null)
{
Console.WriteLine($"Coupon: {coupon}");
}
else
{
Console.WriteLine("No coupon");
}
object stock = 7;
if (stock is int units && units > 5)
{
Console.WriteLine($"Plenty: {units}");
}
True
No coupon
Plenty: 7
and binds tighter than or, so the first line reads as "between a and z, or between A and Z". is not null is the modern and recommended way to write a null check. The switch statement accepts the same patterns and when too:
decimal price = 120m;
bool isMember = true;
switch (price)
{
case < 0:
Console.WriteLine("Invalid price");
break;
case 0:
Console.WriteLine("Free");
break;
case > 100 when isMember:
Console.WriteLine("Member discount applies");
break;
default:
Console.WriteLine("Regular price");
break;
}
Member discount applies
We will open the deeper forms of pattern matching, such as property, positional and tuple patterns, in a separate post in the C# Advanced series.
Which One to Use When
The rule I use in practice is simple. For conditions with two or three branches that check different things, if/else. If you start from one value and pick another value, a switch expression: it is short, its result goes into a single variable, and the compiler warns you about missing cases. If each branch does a few lines of work (write a log, reduce stock, send an email), a switch statement.
Notice one more thing: because a switch expression is an expression, you can write it directly as a method's return value, in a property, or inside string interpolation. In the DiscountRate example the whole method is a single switch expression. Had we written the same thing with if, we would be looking at four return statements and a lot of braces.
If the number of conditions starts to reach dozens, stop and look at the design. Mappings that are really "data", like a shipping fee per format, often belong not in code but in a Dictionary or a database table. We will come back to such lookup tables in the collections series.
Common Pitfalls
Writing an assignment instead of a comparison. With bool variables, if you write = instead of ==, the code compiles:
bool isMember = true;
if (isMember = false)
{
Console.WriteLine("Member price");
}
Console.WriteLine(isMember);
Program.cs(3,5): warning CS0665: Assignment in conditional expression is always constant; did you mean to use == instead of = ?
False
The condition never ran, and on top of that the variable's value changed. If you write if (isMember) and if (!isMember) instead of == true or == false for a bool, you cannot fall into this. For types like int, if (stock = 0) does not even compile, thanks to CS0029.
Putting the arms in the wrong order. If you place a wide pattern above a narrow one, the lower arm can never run. The compiler catches this in a switch expression:
int quantity = 5;
string rate = quantity switch
{
> 0 => "positive",
> 10 => "bulk",
_ => "none"
};
Console.WriteLine(rate);
Program.cs(6,5): error CS8510: The pattern is unreachable. It has already been handled by a previous arm of the switch expression or it is impossible to match.
But it does not catch the same mistake in the else if chain from the start of the post; there the order is entirely your responsibility.
Skipping braces. As you saw above, indentation fools you; use braces in every block.
Forgetting about letter case in string comparisons. The "novel" arm in the switch does not match "Novel". Normalize user input before comparing it, or use string.Equals(a, b, StringComparison.OrdinalIgnoreCase); we saw the culture pitfalls in the strings post.
A Small Experiment
★☆☆ Fix the shipping example from the start of the post by putting the conditions in the right order, and check the output for 50, 150 and 250.
★★☆ Rewrite the same shipping rule as orderTotal switch { ... } with relational patterns. Add an arm that throws ArgumentOutOfRangeException for a negative total.
★★★ Add a decimal price when price > 1000m arm to the Describe method that returns "expensive". What does the compiler say when you put it below the decimal price arm, and what happens when you put it above? Explain the difference.
A condition makes a decision once. But when you want to walk through every book in the store and make the same decision for each one that is out of stock, you need repetition. In the next post we look at loops: for, while, do-while, foreach, break and continue, and how to get out of nested loops properly.