try/catch/finally & Exception Types
Exception handling is the mechanism that lets a program respond to runtime errors in a controlled way rather than crashing. In C#, every exception is an object that derives from the base class System.Exception.
The Exception Hierarchy
The hierarchy starts with System.Exception. Two broad sub-branches exist beneath it: SystemException (thrown by the runtime for things like NullReferenceException, IndexOutOfRangeException, and DivideByZeroException) and ApplicationException (the historical base for user-defined exceptions, though modern guidance recommends deriving directly from Exception). Knowing this tree helps you decide how specific your catch clauses need to be.
try / catch / finally
A try block wraps the code that might throw. One or more catch blocks follow, each targeting a specific exception type. A finally block, if present, always runs — whether the try succeeded, threw, or even if a catch re-threw. This makes finally ideal for releasing resources when you cannot use a using statement.
try { /* risky code */ }
catch (IOException ex) { /* handle I/O issues */ }
catch (Exception ex) { /* handle everything else */ }
finally { /* always runs */ }Catch Order Matters
C# evaluates catch blocks top to bottom and picks the first match. Because Exception is the base of all exceptions, placing it first would swallow every error before more specific handlers can run. Always order catch blocks from most-specific to least-specific.
The when Clause (Exception Filters)
C# 6 introduced exception filters: catch (Exception ex) when (condition). The filter expression is evaluated before the stack unwinds, which is a crucial difference from catching and re-throwing inside the block. If the filter returns false the runtime continues searching for a handler further up the call stack — the stack trace is fully preserved.
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
// only handles 404s
}Rethrowing Correctly
Inside a catch block, throw; (bare throw) re-throws the original exception with its original stack trace intact. throw ex; creates a new exception from the catch variable, resetting the stack trace to the current line and hiding where the error really originated. Always use throw; unless you are wrapping the exception in a new one.
AggregateException
When multiple tasks fail concurrently (e.g., via Task.WhenAll), the runtime wraps all failures in an AggregateException. You can inspect InnerExceptions to see each individual failure, or call Flatten() to normalise nested aggregates, and Handle() to process each inner exception and mark it as handled.
Code Examples
using System;
int[] numbers = { 10, 0, 5 };
for (int i = 0; i <= numbers.Length; i++) // intentionally goes out of range
{
try
{
int divisor = numbers[i];
int result = 100 / divisor;
Console.WriteLine(````````100 / ${divisor} = ${result}````````);
}
catch (DivideByZeroException ex)
{
Console.WriteLine(````````Caught divide-by-zero: ${ex.Message}````````);
}
catch (IndexOutOfRangeException ex) when (i >= numbers.Length)
{
// The 'when' filter only matches if the index is truly out of range
Console.WriteLine(````````Filter matched – index ${i} is out of range.````````);
}
catch (Exception ex)
{
// Catch-all – least specific, must come last
Console.WriteLine(````````Unexpected error: ${ex.Message}````````);
}
finally
{
Console.WriteLine(````````--- iteration ${i} complete ---````````);
}
}Catch blocks are evaluated top-to-bottom. The 'when' clause lets us inspect state before the stack unwinds. 'finally' always runs regardless of whether an exception occurred.
using System;
void ProcessFile(string path)
{
try
{
// Simulate a file operation
if (string.IsNullOrEmpty(path))
throw new ArgumentNullException(nameof(path), "Path must not be null or empty.");
Console.WriteLine(````````Processing: ${path}````````);
throw new InvalidOperationException("Simulated processing error.");
}
catch (ArgumentNullException)
{
// Handle argument problems here without re-throwing
Console.WriteLine("Bad argument – handled locally.");
}
catch (Exception)
{
Console.WriteLine("Logging error, then re-throwing with original stack trace...");
throw; // bare throw – stack trace is preserved
// DO NOT write: throw ex; <-- resets stack trace!
}
}
try
{
ProcessFile("report.csv");
}
catch (Exception ex)
{
Console.WriteLine(````````Outer catch: ${ex.GetType().Name} – ${ex.Message}````````);
}Using bare 'throw;' re-throws the exception preserving the original stack trace. 'throw ex;' would reset it to this line, making debugging harder.
using System;
using System.Threading.Tasks;
async Task RunAllAsync()
{
Task t1 = Task.Run(() => throw new InvalidOperationException("Task 1 failed"));
Task t2 = Task.Run(() => throw new ArgumentException("Task 2 failed"));
Task t3 = Task.Run(async () =>
{
await Task.Delay(10);
Console.WriteLine("Task 3 succeeded");
});
try
{
await Task.WhenAll(t1, t2, t3);
}
catch (Exception)
{
// Task.WhenAll wraps all failures in AggregateException
// When awaited, the first inner exception is surfaced – retrieve all via .Exception
AggregateException? agg = t1.Exception?.Flatten();
// Use Handle() to process each inner exception
Task.WhenAll(t1, t2).Exception?.Flatten().Handle(ex =>
{
Console.WriteLine(````````Inner: [${ex.GetType().Name}] ${ex.Message}````````);
return true; // mark as handled
});
}
}
await RunAllAsync();Task.WhenAll captures all failures into an AggregateException. Flatten() removes nested AggregateExceptions, and Handle() lets you inspect and mark each inner exception as handled.
Quick Quiz
1. Why must more-specific catch blocks appear before less-specific ones?
2. What is the key advantage of 'catch (Exception ex) when (condition)' over checking the condition inside the catch body?
3. What is wrong with writing 'throw ex;' instead of 'throw;' inside a catch block?
4. Which class wraps multiple exceptions thrown by concurrently running tasks?
Was this lesson helpful?