C# · lesson 5 of 26
Operators in C#
Master arithmetic, null-coalescing, null-conditional, and pattern matching operators in C#.
By SkillByExample · Updated
Arithmetic Operators
Arithmetic operators are the foundation of numeric computation in C#. A few behaviors are worth knowing upfront: integer division truncates toward zero (it does not round), and the modulo operator % returns the remainder. When you need a fractional result from integer inputs, cast at least one operand to a floating-point type first.
int a = 17, b = 5;
Console.WriteLine(a + b); // 22 — addition
Console.WriteLine(a - b); // 12 — subtraction
Console.WriteLine(a * b); // 85 — multiplication
Console.WriteLine(a / b); // 3 — integer division (truncates, not rounds)
Console.WriteLine(a % b); // 2 — modulo (remainder after division)
// Cast to double to get floating-point division
Console.WriteLine((double)a / b); // 3.4
// Increment / decrement — pre vs post matters when used in expressions
int x = 5;
Console.WriteLine(x++); // 5 (post-increment: returns current value, then increments)
Console.WriteLine(x); // 6
Console.WriteLine(++x); // 7 (pre-increment: increments first, then returns)
// Compound assignment — shorthand for x = x op y
x += 10; // x = x + 10 -> 16
x -= 3; // x = x - 3 -> 13
x *= 2; // x = x * 2 -> 26
x /= 4; // x = x / 4 -> 6
x %= 3; // x = x % 3 -> 1
Console.WriteLine($"Final x: {x}");
$ dotnet run
22
12
85
3
2
3.4
5
6
7
Final x: 1
Comparison and Logical Operators
Comparison operators produce bool results and are the building blocks of conditions. Logical operators combine booleans, with && and || short-circuiting — they stop evaluating as soon as the result is determined, which is useful for guarding against null before accessing a member.
int age = 25;
bool isAdult = age >= 18; // true
bool isSenior = age >= 65; // false
bool isTeenager = age is >= 13 and < 20; // false (pattern matching syntax)
Console.WriteLine($"isAdult={isAdult}, isSenior={isSenior}, isTeenager={isTeenager}");
// Logical — && and || short-circuit (second operand not evaluated if unnecessary)
bool flagA = true, flagB = false;
Console.WriteLine(flagA && flagB); // False — AND: both must be true
Console.WriteLine(flagA || flagB); // True — OR: at least one must be true
Console.WriteLine(!flagA); // False — NOT: inverts the value
// Bitwise (on integers) — useful for flags and low-level operations
int flags = 0b_0101;
int mask = 0b_0011;
Console.WriteLine(Convert.ToString(flags & mask, 2)); // 0001 — AND: bits set in both
Console.WriteLine(Convert.ToString(flags | mask, 2)); // 0111 — OR: bits set in either
Console.WriteLine(Convert.ToString(flags ^ mask, 2)); // 0110 — XOR: bits set in exactly one
Console.WriteLine(flags << 1); // 10 — left shift (multiply by 2)
Console.WriteLine(flags >> 1); // 2 — right shift (divide by 2)
$ dotnet run
isAdult=True, isSenior=False, isTeenager=False
False
True
False
0001
0111
0110
10
2
Null-Coalescing Operators
Null checks scattered throughout code quickly become noisy. The null-coalescing operators give you a compact, readable way to handle null — either by providing a fallback value or by lazily assigning a default. They are especially useful when working with optional data from databases or external APIs.
The null-coalescing operator ?? returns its left operand if not null, otherwise the right operand.
string? name = null;
string displayName = name ?? "Anonymous"; // "Anonymous"
// Chain multiple fallbacks — evaluates left to right, stops at the first non-null
string? first = null, second = null, third = "Found!";
string result = first ?? second ?? third; // "Found!"
Console.WriteLine(displayName);
Console.WriteLine(result);
$ dotnet run
Anonymous
Found!
The null-coalescing assignment operator ??= assigns only when the variable is null. It is cleaner than an explicit null check when initializing lazily.
List<string>? items = null;
items ??= new List<string>(); // items is now an empty list; no-op if already set
Console.WriteLine($"items count: {items.Count}");
$ dotnet run
items count: 0
Null-Conditional Operators
Null-conditional operators let you safely navigate a chain of member accesses where any link might be null. Without them, you would need a null check at every step. With them, the entire chain short-circuits to null at the first null value, preventing NullReferenceException.
The null-conditional operator ?. accesses a member only if the object is not null; otherwise it returns null.
string? text = null;
int? length = text?.Length; // null (no NullReferenceException thrown)
Console.WriteLine($"length: {length}");
// Chained access — short-circuits at the first null
string? city = text?.ToUpper().Substring(0, 3); // null — text is null, chain stops
Console.WriteLine($"city: {city}");
// Combining with ?? — safe access with a fallback default
string display = text?.ToUpper() ?? "Empty";
Console.WriteLine($"display: {display}");
$ dotnet run
length:
city:
display: Empty
Null-Conditional with Method Chaining
The null-conditional is especially powerful when chaining multiple member accesses:
class Person { public Address? Address { get; set; } }
class Address { public string? City { get; set; } }
var person = new Person();
// Safe navigation — no NullReferenceException even if Address is null
Console.WriteLine(person.Address?.City ?? "Unknown");
person.Address = new Address { City = "London" };
Console.WriteLine(person.Address?.City ?? "Unknown");
$ dotnet run
Unknown
London
Ternary and Switch Expressions
The ternary operator is useful for simple conditional assignments in one line. Switch expressions (C# 8+) extend this to multiple branches and are exhaustiveness-checked by the compiler, meaning it warns you if a case is missing.
// Ternary — condition ? valueIfTrue : valueIfFalse
int score = 75;
string grade = score >= 90 ? "A" : score >= 70 ? "B" : "C";
// Switch expression (C# 8+) — cleaner than nested ternaries
string label = score switch
{
>= 90 => "Excellent",
>= 70 => "Good",
>= 50 => "Average",
_ => "Needs Improvement" // _ is the discard / default arm
};
Console.WriteLine($"grade={grade}, label={label}");
$ dotnet run
grade=B, label=Good
Pattern Matching Operators
Pattern matching lets you test a value’s type, structure, and content in one expression. It reduces the need for explicit casts and null checks, and it integrates naturally with switch expressions to produce clean, readable dispatch logic.
is operator
object obj = "Hello";
// Type pattern — checks type and binds to a new variable in one step
if (obj is string s)
Console.WriteLine(s.ToUpper()); // s is only in scope inside this block
// Null check
if (obj is not null)
Console.WriteLine("Not null");
// Constant pattern — check for a specific value
if (obj is "Hello")
Console.WriteLine("It's hello");
// Combined patterns with 'and' / 'or' / 'not'
int n = 42;
bool inRange = n is >= 0 and <= 100; // range check in one expression
bool isEdge = n is 0 or 100; // matches either endpoint
bool notNegative = n is not < 0; // negated pattern
Console.WriteLine($"inRange={inRange}, isEdge={isEdge}, notNegative={notNegative}");
$ dotnet run
HELLO
Not null
It's hello
inRange=True, isEdge=False, notNegative=True
Type patterns in switch
record Circle(double Radius);
record Rectangle(double Width, double Height);
record Triangle(double Base, double Height);
object shape = new Circle(5.0);
double area = shape switch
{
Circle c => Math.PI * c.Radius * c.Radius,
Rectangle r => r.Width * r.Height,
Triangle t => 0.5 * t.Base * t.Height,
null => throw new ArgumentNullException(nameof(shape)),
_ => throw new NotSupportedException($"Unknown shape: {shape.GetType()}")
};
Console.WriteLine($"area: {area:F4}");
$ dotnet run
area: 78.5398
Property patterns
Property patterns let you match on the values of nested properties, making complex conditional logic declarative rather than imperative.
record Address(string City, string Country);
record Customer(string Name, Address Address);
var customer = new Customer("Alice", new Address("London", "UK"));
string region = customer switch
{
{ Address.Country: "US" } => "North America",
{ Address.Country: "UK" } => "Europe",
{ Address: { City: "Tokyo" } } => "Asia",
_ => "Other"
};
Console.WriteLine(region);
$ dotnet run
Europe
String Operators
String has its own set of operators that handle common operations like concatenation and comparison. Prefer string interpolation over + for readability, and use string.Compare with an explicit StringComparison to avoid locale-sensitive surprises.
string hello = "Hello";
string world = "World";
string full = hello + ", " + world + "!";
string full2 = $"{hello}, {world}!";
Console.WriteLine("abc" == "abc"); // True
Console.WriteLine("abc" != "xyz"); // True
Console.WriteLine(string.Compare("apple", "banana")); // -1 (apple < banana)
Console.WriteLine(full);
Console.WriteLine(full2);
$ dotnet run
True
True
-1
Hello, World!
Hello, World!
Operator Precedence
Operators have a fixed evaluation order. Knowing the rules prevents subtle bugs, but when in doubt, parentheses always make intent explicit and are better than relying on readers knowing the precedence table by heart.
From highest to lowest (abridged):
() — parentheses
! ~ ++ -- — unary
* / % — multiplicative
+ - — additive
<< >> — shift
< > <= >= is as — relational and type
== != — equality
& — bitwise AND
^ — bitwise XOR
| — bitwise OR
&& — logical AND
|| — logical OR
?? — null-coalescing
?: — ternary
= += -= — assignment
When in doubt, use parentheses to make your intent explicit:
int a = 1, b = 2, c = 3;
int result = a + b * c; // 7 (b * c first)
int result2 = (a + b) * c; // 9 (1 + 2) * 3
Console.WriteLine($"result={result}, result2={result2}");
$ dotnet run
result=7, result2=9
Practice
- Integer division pitfall. Given
int x = 17, y = 5, what isx / y? What if you write(double)x / y? Explain why the results differ and when the truncation catches you by surprise.
Solution
17 / 5 gives 3 (integer division truncates toward zero). (double)17 / 5 gives 3.4. The difference is that integer division discards the remainder, while casting to double first promotes the operation to floating-point. This catches you by surprise when you expect a fractional result from two integer variables — for example, calculating an average: sum / count gives an integer average, losing the decimal part. Fix: cast at least one operand: (double)sum / count.
- Null-coalescing vs null-conditional. Create a
string?variable set tonull. Use??to provide a fallback, and?.to safely access.Length. What does each return? When would you use one over the other?
Solution
string? text = null;
// ?? provides a fallback value
string safe = text ?? "default"; // "default"
// ?. safely accesses a member; returns null if source is null
int? length = text?.Length; // null (not an exception)
int safeLength = text?.Length ?? 0; // 0 — combine with ?? for a usable int
Use ?? when you want to guarantee a non-null value. Use ?. when you want to safely traverse a chain that might hit null, possibly followed by ?? to provide a fallback.
- Switch expression vs nested ternaries. Rewrite a nested ternary like
score >= 90 ? "A" : score >= 80 ? "B" : "C"as a switch expression. Which is more readable? What does the switch expression give you that the ternary doesn’t?
Solution
// Ternary — hard to read with many cases
string grade = score >= 90 ? "A" : score >= 80 ? "B" : score >= 70 ? "C" : "D";
// Switch expression — clean, pattern-based, and exhaustiveness-checked
string grade = score switch
{
>= 90 => "A",
>= 80 => "B",
>= 70 => "C",
>= 60 => "D",
_ => "F"
};
The switch expression is more readable at 3+ branches, and the compiler enforces exhaustiveness — it warns you if a case is missing. The _ discard arm makes the default explicit.