Control Flow and Pattern Matching
Making decisions in C#: if and else, the switch statement and switch expressions, and patterns that test types, values, ranges and shapes in one readable expression.
Conditions are bool
Every decision in a program depends on a condition: an expression
that is either true or false. The general idea of
selection
is the same in every language; this page shows how C# does it. In
C#, a condition is an expression of type bool, usually
a comparison or a combination of comparisons with
&&, || and ! (see
variables, types and operators).
int openTickets = 3;
bool needsAttention = openTickets > 0;
Console.WriteLine(needsAttention);
Console.WriteLine(openTickets > 5);
Console.WriteLine(needsAttention.GetType());
True False System.Boolean
C# is strict about this. A number, a string or an object is never
accepted where a bool is expected, so the compiler
stops you before the program runs:
int openTickets = 3;
if (openTickets)
{
Console.WriteLine("Work to do");
}
example.cs(2,5): error CS0029: Cannot implicitly convert type 'int' to 'bool'
The same strictness catches a classic typo. In C and JavaScript,
if (status = "Open") assigns instead of comparing and
silently runs the block. In C# the result of that assignment is a
string, not a bool, so it is a compile
error.
if, else if and else
An if statement runs a block when its condition is
true. The condition always sits in parentheses, and the block is
enclosed in braces { }. Indentation means nothing to
the compiler; it is there for human readers. An optional
else block runs when the condition is false, and
else if adds more alternatives. C# tests the conditions
from top to bottom and runs only the block of the
first condition that is true.
double temperature = 31.5;
if (temperature >= 30)
{
Console.WriteLine("Heat alert: check the transformer cooling.");
}
else if (temperature <= 0)
{
Console.WriteLine("Frost alert: check the outdoor meters.");
}
else
{
Console.WriteLine("Normal temperature.");
}
Heat alert: check the transformer cooling.
There is no special elif keyword:
else if is simply an else whose block is
another if statement. Because the first true condition
wins, put the narrowest test first, exactly as in
any else-if chain.
Always use braces
When a block contains a single statement, C# allows you to leave out
the braces. Only that one statement then belongs to the
if; the next line runs every time, however it is
indented.
int usageKwh = 120;
if (usageKwh > 500)
Console.WriteLine("High usage this month.");
Console.WriteLine("Sending usage warning to the customer.");
Sending usage warning to the customer.
The conditional operator
When an if/else only chooses between two
values, the conditional operator ?: (often called the
ternary operator, because it takes three operands) is
shorter:
condition ? valueIfTrue : valueIfFalse
It is an expression, so you can use it anywhere a value is expected.
Both values must have a common type:
stock > 0 ? "in stock" : 0 does not compile.
int stock = 0;
string status = stock > 0 ? "in stock" : "sold out";
Console.WriteLine(status);
int items = 1;
Console.WriteLine($"{items} {(items == 1 ? "item" : "items")} in your cart");
sold out 1 item in your cart
The switch statement
A
switch statement
compares one value against a list of case labels and
runs the matching switch section. Several labels can share
one section, and the optional default section runs when
no label matches. C# can switch on numbers, characters, strings,
enums and, with patterns, on almost anything.
string priority = "P2";
switch (priority)
{
case "P1":
Console.WriteLine("Respond within 15 minutes.");
break;
case "P2":
case "P3":
Console.WriteLine("Respond within 4 hours.");
break;
default:
Console.WriteLine("Respond on the next working day.");
break;
}
Respond within 4 hours.
No fall-through
In C, Java and JavaScript, a case without
break continues into the next one ("falls through"),
which is a well-known source of bugs. C# does not allow it: the end
of every switch section must be unreachable, so each section ends
with break, return, throw,
continue (inside a loop) or goto case.
Stacking labels with nothing in between, as with
"P2" and "P3" above, is still allowed.
int level = 1;
switch (level)
{
case 1:
Console.WriteLine("Level 1 support");
case 2:
Console.WriteLine("Level 2 support");
break;
}
example.cs(5,5): error CS0163: Control cannot fall through from one case label ('case 1:') to another
If you really want one section to continue with another, say so with
goto case 2;. That is rare; usually the cleaner fix is
to stack the labels or move the shared code into a
method.
Switch expressions
Most switches exist to pick a value: a label for a status code, a
price for a tariff. For that, C# 8 added the
switch expression: the
value comes first, then the keyword switch, then a list
of arms in braces. Each arm is
pattern => result, the arms are separated by commas,
and the whole construct produces the result of the first arm whose
pattern matches.
int[] codes = [200, 302, 404, 500];
foreach (int code in codes)
{
string meaning = code switch
{
200 => "OK",
301 or 302 => "Redirect",
404 => "Not found",
_ => "Other status"
};
Console.WriteLine($"{code} {meaning}");
}
200 OK 302 Redirect 404 Not found 500 Other status
Compared with the switch statement there is no case, no
break and no default, and the result can
go straight into a variable, a return or an argument.
301 or 302 is one pattern made of two alternatives
(more on or
below). The
foreach loop around it just runs the switch
for each code.
The discard pattern and exhaustiveness
The last arm, _ => "Other status", uses the
discard pattern
_, which matches any value and gives it no name. It
plays the role of default or else.
A switch expression must produce a value for every possible input.
When the compiler can see that some inputs are not covered, it
warns. If such an input really arrives, the program stops with a
SwitchExpressionException:
string tariff = "Night";
decimal pricePerKwh = tariff switch
{
"Day" => 0.32m,
"Evening" => 0.28m
};
Console.WriteLine(pricePerKwh);
Unhandled exception. System.Runtime.CompilerServices.SwitchExpressionException: Non-exhaustive switch expression failed to match its input.
The build already warned about it (the checker hides warnings):
example.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.
Take that warning seriously: either add the missing arms, or add a
final _ arm that does something deliberate, such as
throwing an exception with a clear message (see
exceptions and error handling).
The order of the arms
The arms are tested from top to bottom, so order matters as soon as
patterns overlap. A pattern that can never be reached because an
earlier arm already covers it, such as anything after
_, is a compile error (CS8510), not a warning.
int score = 85;
string grade = score switch
{
>= 90 => "A",
>= 80 => "B",
>= 70 => "C",
_ => "F"
};
Console.WriteLine(grade);
B
Patterns
A pattern is a test that a value either matches or does
not, sometimes taking the value apart at the same time. C# has grown
a small language of patterns since version 7, and you can use them
in three places: after is (value is pattern, which gives a bool), in case labels of
a switch statement, and in the arms of a switch expression. The
simplest ones you have already seen: a
constant pattern such as 404 or
"Day", and the discard _.
Type patterns
A type pattern checks whether a
value has a certain type at run time. Followed by a name, it also
declares a new variable of that type, ready to use, so a type test
and a cast happen in one step. This is useful when a variable's
static type is general, such as object, but the actual
values differ.
object[] values = [42, "INC-20481", 3.5, true];
foreach (object value in values)
{
string description = value switch
{
int n => $"a whole number: {n + 1} is one more",
string s => $"text of {s.Length} characters",
double d => $"a double, half is {d / 2}",
_ => $"something else: {value}"
};
Console.WriteLine(description);
}
a whole number: 43 is one more text of 9 characters a double, half is 1.75 something else: True
With is, the new variable is available inside the
if block, where the compiler knows the test succeeded:
object reading = 412.5;
if (reading is double kwh)
{
Console.WriteLine($"Meter reading: {kwh:F1} kWh");
}
if (reading is not string)
{
Console.WriteLine("Not text, so no parsing needed.");
}
Meter reading: 412.5 kWh Not text, so no parsing needed.
Relational and logical patterns
A
relational pattern
compares the value with a constant using <,
<=, > or >=. It
turns a chain of range checks into a compact table. Patterns can be
combined with the keywords and, or and
not (the logical patterns, C# 9), and grouped
with parentheses.
double[] temperatures = [-5, 0, 18, 35];
foreach (double t in temperatures)
{
string label = t switch
{
< 0 => "below freezing",
0 => "freezing point",
>= 30 => "hot",
_ => "mild"
};
Console.WriteLine($"{t,3} °C: {label}");
}
int percent = 104;
if (percent is < 0 or > 100)
{
Console.WriteLine($"{percent}% is not a valid percentage.");
}
char grade = 'b';
Console.WriteLine(grade is >= 'a' and <= 'f');
-5 °C: below freezing 0 °C: freezing point 18 °C: mild 35 °C: hot 104% is not a valid percentage. True
percent is < 0 or > 100 reads almost like English
and tests percent only once. The constants in a pattern
must be known at compile time: >= limit with a
variable limit does not compile. For that, use a
when guard or an ordinary
if.
Property patterns
A property pattern looks
inside an object: { Priority: "P1" } matches when the
object's Priority
property
matches the pattern after the colon. That inner pattern can itself
be any pattern (a constant, a relational pattern, another property
pattern), and several properties are combined with commas, which
means "and". The example below uses a small
record type, declared
after the top-level code.
Ticket[] tickets =
[
new("INC-20481", "P1", "Network", 0),
new("INC-20482", "P2", "Network", 3),
new("INC-20483", "P3", "Billing", 1),
new("INC-20484", "P3", "Portal", 9),
];
foreach (Ticket ticket in tickets)
{
string route = ticket switch
{
{ Priority: "P1" } => "page the on-call engineer",
{ Category: "Network", Priority: "P2" } => "NOC queue",
{ Category: "Billing" } => "billing team",
{ Reopened: >= 5 } => "problem management",
_ => "service desk queue"
};
Console.WriteLine($"{ticket.Id}: {route}");
}
record Ticket(string Id, string Priority, string Category, int Reopened);
INC-20481: page the on-call engineer INC-20482: NOC queue INC-20483: billing team INC-20484: problem management
Property patterns also check for null: { } matches any
object that is not null, and a property pattern never matches a null
reference. Nested properties can be written with a dot, as in
{ Customer.City: "Delft" } (C# 10).
List patterns
A list pattern (C# 11) matches
an array or list by its elements, in square brackets. Each position
has its own pattern, so the length must match too. Inside the
brackets, .. (the slice pattern) stands for
zero or more elements and may appear once;
.. var rest captures them.
var name captures a single element of any value.
string[][] commands =
[
["quit"],
["restart", "meter-0042"],
["assign", "INC-20481", "Amara", "Mei"],
[],
["dance"],
];
foreach (string[] command in commands)
{
string result = command switch
{
["quit"] => "Goodbye",
["restart", var device] => $"Restarting {device}",
["assign", var id, .. var people] => $"Assigning {id} to {string.Join(" and ", people)}",
[] => "Empty command",
_ => $"Unknown command: {string.Join(" ", command)}"
};
Console.WriteLine(result);
}
int[] readings = [412, 415, 419, 431];
if (readings is [var first, .., var last])
{
Console.WriteLine($"Used {last - first} kWh over {readings.Length} readings");
}
Goodbye Restarting meter-0042 Assigning INC-20481 to Amara and Mei Empty command Unknown command: dance Used 19 kWh over 4 readings
when guards
Sometimes a pattern alone cannot express the test, for example when
it compares with a variable or with another property. A
when clause adds an ordinary
bool condition to a case label or a switch
arm: the arm only matches when the pattern matches and the
condition is true.
decimal monthlyLimit = 500m;
Reading[] readings =
[
new("MTR-0042", 320m, 300m),
new("MTR-0043", 980m, 310m),
new("MTR-0044", -4m, 280m),
];
foreach (Reading r in readings)
{
string verdict = r switch
{
{ Kwh: < 0 } => "invalid: negative usage",
_ when r.Kwh > r.LastMonth * 2 => "spike: check the meter",
_ when r.Kwh > monthlyLimit => "above the monthly limit",
_ => "normal"
};
Console.WriteLine($"{r.MeterId}: {verdict}");
}
record Reading(string MeterId, decimal Kwh, decimal LastMonth);
MTR-0042: normal MTR-0043: spike: check the meter MTR-0044: invalid: negative usage
Prefer real patterns where you can, and use when for
what patterns cannot say. The compiler can check exhaustiveness and
unreachable arms for patterns, but it cannot reason about the
condition inside a when.
Choosing the right construct
All of these constructs change the control flow; they differ in whether they run statements or produce a value, and in how many cases they handle comfortably.
| Construct | Produces | Best for |
|---|---|---|
if / else |
nothing (runs statements) | one or two conditions, or conditions on different variables |
?: |
a value | choosing between exactly two short values |
switch statement |
nothing (runs statements) | many cases on one value, where each case does several things |
| switch expression | a value | mapping one value to a result: labels, prices, routing decisions |
is pattern |
a bool (plus variables) |
one test on type, range, shape or null |
A good rule of thumb: when every branch of an
if/else if chain assigns the same
variable, a switch expression says the same thing in fewer lines and
lets the compiler check that no case is forgotten. When the branches
do different kinds of work, keep the statements. And when the same
switch on a "kind" field appears in many places, consider
polymorphism
instead.