Operators and Expressions

This section documents C# 14 on .NET 10 (LTS), as published at learn.microsoft.com/dotnet/csharp, which is the reference these pages are written and verified against. Features introduced by C# 15 / .NET 11 are still in preview and are always flagged as such — never presented as baseline.

This content was generated with the assistance of AI and should be verified against learn.microsoft.com before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

An expression is anything that produces a value. This page is the reference for the operators that build them; Equality and Operator Overloading covers how to give your own types operator behaviour.

Arithmetic

int a = 17, b = 5;

Console.WriteLine(a + b);      // 22
Console.WriteLine(a - b);      // 12
Console.WriteLine(a * b);      // 85
Console.WriteLine(a / b);      // 3   -- integer division truncates toward zero
Console.WriteLine(a % b);      // 2   -- remainder, sign follows the dividend
Console.WriteLine(-a / b);     // -3
Console.WriteLine(-a % b);     // -2

Console.WriteLine(17.0 / 5);   // 3.4 -- one double operand makes it floating point
Console.WriteLine(17m / 5);    // 3.4 -- decimal division

Console.WriteLine(+a);         // unary plus (no-op)
Console.WriteLine(-a);         // unary minus

Integer division by zero throws DivideByZeroException; floating-point division by zero yields Infinity or NaN. Operands narrower than int (byte, short, char) are promoted to int before the operation, which is why byte x = b1 + b2; needs a cast.

Increment and Decrement

int i = 5;
Console.WriteLine(i++);   // 5  -- postfix: yields the old value, then increments
Console.WriteLine(i);     // 6
Console.WriteLine(++i);   // 7  -- prefix: increments, then yields the new value
Console.WriteLine(i--);   // 7
Console.WriteLine(--i);   // 5

Comparison and Equality

Console.WriteLine(3 < 4);            // True
Console.WriteLine(3 <= 3);           // True
Console.WriteLine("a" == "a");       // True  -- string == compares content
Console.WriteLine(3 != 4);           // True

object x = new object();
object y = new object();
Console.WriteLine(x == y);           // False -- reference identity for most classes
Console.WriteLine(x.Equals(x));      // True

// Structs and records get value equality; plain classes do not.
Console.WriteLine(new DateTime(2026, 1, 1) == new DateTime(2026, 1, 1));   // True

== on reference types means identity unless the type overloads it (string, record, many BCL value types do). See Equality and Operator Overloading.

Boolean Logical Operators

static bool Expensive() { Console.WriteLine("called"); return true; }

bool p = false, q = true;

Console.WriteLine(p && Expensive());   // False -- short-circuits, Expensive() not called
Console.WriteLine(p & Expensive());    // False -- but Expensive() IS called
Console.WriteLine(q || Expensive());   // True  -- short-circuits
Console.WriteLine(q ^ p);              // True  -- exclusive or
Console.WriteLine(!p);                 // True

Prefer && and ||; use & and | on bool only when you genuinely need both sides evaluated.

Bitwise and Shift

uint flags = 0b_1010_1100;

Console.WriteLine(Convert.ToString(flags & 0b_0000_1111, 2));   // 1100   AND
Console.WriteLine(Convert.ToString(flags | 0b_0000_0011, 2));   // 10101111 OR
Console.WriteLine(Convert.ToString(flags ^ 0b_1111_1111, 2));   // 1010011  XOR
Console.WriteLine(Convert.ToString(~flags));                    // NOT (complement)

Console.WriteLine(1 << 4);          // 16   left shift
Console.WriteLine(-16 >> 2);        // -4   arithmetic right shift (sign-preserving)
Console.WriteLine(-16 >>> 2);       // 1073741820  unsigned right shift (C# 11)

The shift count is taken modulo the operand width (32 for int, 64 for long), so 1 << 32 is 1, not 0. See Enums for [Flags], the usual reason to reach for these.

Assignment and Compound Assignment

int n = 10;
n += 5;    // 15
n -= 3;    // 12
n *= 2;    // 24
n /= 4;    // 6
n %= 4;    // 2
n <<= 3;   // 16
n &= 0b1100;
n |= 0b0011;
n ^= 0b0101;
Console.WriteLine(n);

string s = "a";
s += "b";  // "ab"
Console.WriteLine(s);

A compound assignment x op= y means x = (T)(x op y) — the cast back to x’s type is implicit, which is why `byte b = 1; b += 300; compiles (and wraps) where b = b + 300; does not. C# 14 lets a type define its own compound assignment operator; see Equality and Operator Overloading.

The Conditional Operator

int score = 72;
string grade = score >= 60 ? "pass" : "fail";
Console.WriteLine(grade);

// It is an expression, so it nests -- but a switch expression usually reads better.
string band = score >= 90 ? "A" : score >= 75 ? "B" : score >= 60 ? "C" : "F";
Console.WriteLine(band);

// `ref` conditional: choose which variable to assign to.
int left = 1, right = 2;
ref int chosen = ref (score > 50 ? ref left : ref right);
chosen = 99;
Console.WriteLine($"{left} {right}");    // 99 2

Both branches must convert to a common type, or you must supply one with a cast or a target type.

Null-Coalescing and Null-Conditional

string? maybe = null;

Console.WriteLine(maybe ?? "fallback");      // ??  -- use the right side when the left is null

maybe ??= "assigned once";                   // ??= -- assign only if currently null
Console.WriteLine(maybe);

string? name = null;
Console.WriteLine(name?.Length);             // ?.  -- null, not a NullReferenceException
Console.WriteLine(name?.Length ?? 0);        // 0

int[]? numbers = null;
Console.WriteLine(numbers?[0]);              // ?[] -- null, no exception

// The whole chain short-circuits: one null anywhere yields null.
Console.WriteLine(name?.Trim().ToUpperInvariant().Length);

?. on a value-returning member lifts the result to a nullable type, which is why name?.Length is int?.

Null-Conditional Assignment (C# 14)

C# 14 allows ?. and ?[] on the left of an assignment or compound assignment. The right-hand side is evaluated only when the receiver is non-null:

public sealed class Settings
{
    public string Theme { get; set; } = "light";
    public List<string> Tags { get; } = [];
}

public static class NullConditionalAssignment
{
    public static void Apply(Settings? settings)
    {
        // C# 14: no-op when `settings` is null -- previously required an `if`.
        settings?.Theme = "dark";
        settings?.Tags[0] = "first";

        // Equivalent to:
        // if (settings is not null) { settings.Theme = "dark"; }
    }
}

++, -- and compound assignment work the same way. Increment and decrement are not permitted in this position.

The Null-Forgiving Operator

#nullable enable
static int LengthOf(string? maybe)
{
    // `!` asserts to the compiler that the value is not null. It generates no code
    // and performs no check -- it only silences the warning. Use it sparingly.
    return maybe!.Length;
}

! is a claim you are making to the compiler; if you are wrong you still get a NullReferenceException. See Nullable Types and Null Safety.

Type Testing and Conversion

object value = "hello";

// `is` with a type pattern -- tests and declares in one step.
if (value is string text)
{
    Console.WriteLine(text.Length);
}

Console.WriteLine(value is string);        // True
Console.WriteLine(value is not null);      // True
Console.WriteLine(value is int);           // False

// `as` -- converts or yields null; never throws. Reference and nullable types only.
string? asText = value as string;
int? asNumber = value as int?;             // null
Console.WriteLine($"{asText} {asNumber is null}");

// Cast -- converts or throws InvalidCastException.
string cast = (string)value;
Console.WriteLine(cast);

// typeof on a type, GetType() on an instance.
Console.WriteLine(typeof(string) == value.GetType());   // True

Use is with a pattern for a test-then-use; use as followed by a null check when you prefer that shape; use a cast when a failure is a bug you want to hear about immediately.

Index and Range

int[] values = [10, 20, 30, 40, 50];

Console.WriteLine(values[0]);      // 10
Console.WriteLine(values[^1]);     // 50 -- ^n counts from the end, ^1 is the last
Console.WriteLine(values[^2]);     // 40

int[] middle = values[1..4];       // 20, 30, 40 -- start inclusive, end exclusive
int[] head   = values[..2];        // 10, 20
int[] tail   = values[3..];        // 40, 50
int[] all    = values[..];         // a copy
int[] lastTwo = values[^2..];      // 40, 50

Console.WriteLine(string.Join(",", middle));
Console.WriteLine(string.Join(",", head));
Console.WriteLine(string.Join(",", tail));
Console.WriteLine(string.Join(",", all));
Console.WriteLine(string.Join(",", lastTwo));

// Index and Range are real types you can pass around.
Index last = ^1;
Range firstThree = 0..3;
Console.WriteLine(values[last]);
Console.WriteLine(string.Join(",", values[firstThree]));

Ranging an array or string copies; ranging a Span<T> does not. That is the main reason parsers work in spans.

default, new, with

// `default` -- target-typed since C# 7.1.
int zero = default;
List<string>? nothing = default;

// Target-typed `new` -- the type comes from the target (C# 9).
List<string> names = new();
Dictionary<string, List<int>> index = new();
Point origin = new(0, 0);

// `with` -- non-destructive mutation of a record or struct (C# 9 / C# 10).
var p1 = new Point(1, 2);
var p2 = p1 with { Y = 99 };
Console.WriteLine($"{p1} {p2} {zero} {nothing is null} {names.Count} {index.Count} {origin}");

public readonly record struct Point(int X, int Y);

switch Expressions

static string Classify(object value) => value switch
{
    null            => "null",
    int n when n < 0 => "negative int",
    int n           => $"int {n}",
    string { Length: 0 } => "empty string",
    string s        => $"string of {s.Length}",
    _               => "something else",
};

public static class SwitchExpressionDemo
{
    public static void Run()
    {
        Console.WriteLine(Classify(null));
        Console.WriteLine(Classify(-3));
        Console.WriteLine(Classify("abc"));
    }
}

Arms are tested top to bottom and the whole thing is an expression. See Pattern Matching for the full pattern grammar and Control Flow for how it differs from the switch statement.

await, stackalloc, checked

public static class OtherOperators
{
    public static async Task<int> AwaitDemo()
    {
        int result = await Task.FromResult(42);      // await is an operator
        return result;
    }

    public static int StackallocDemo()
    {
        Span<int> buffer = stackalloc int[8];        // stack memory, no allocation
        for (int i = 0; i < buffer.Length; i++)
        {
            buffer[i] = i * i;
        }
        return buffer[^1];                           // 49
    }

    public static int CheckedDemo() => unchecked(int.MaxValue + 1);
}

stackalloc in a Span<T> is safe code; only pointer-typed stackalloc needs unsafe. Never stackalloc in a loop — the memory is released only when the method returns.

Operator Precedence

Highest to lowest. Operators in the same row have equal precedence.

Category Operators Associativity

Primary

x.y x?.y x?[y] f(x) a[i] x++ x-- new typeof checked unchecked default nameof stackalloc x→y

left

Unary

x` `-x` `!x` `~x` `+x --x (T)x await x &x *x true false

right

Range

x..y

left

Multiplicative

* / %

left

Additive

+ -

left

Shift

<< >> >>>

left

Relational and type-testing

< > >= is as

left

Equality

== !=

left

Boolean logical AND

&

left

Boolean logical XOR

^

left

Boolean logical OR

|

left

Conditional AND

&&

left

Conditional OR

||

left

Null-coalescing

??

right

Conditional

c ? t : f

right

Assignment / lambda

= += -=??=

right

Two traps worth remembering: &/^/| bind tighter than &&/|| but looser than ==, so if (flags & Mask == Mask) does not mean what it looks like — parenthesise it. And ?? is right-associative, so a ?? b ?? c is a ?? (b ?? c), which is what you want.

Operand evaluation is strictly left to right, independent of precedence.

See Also