Skip to main content

πŸ“ Lesson 1.1: Exception Handling

Real programs meet real problems: missing files, bad input, dropped connections. In this lesson you'll learn to catch these errors and respond gracefully β€” instead of letting your program crash.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what an exception is and when one is thrown
  • Handle errors with try, catch, and finally
  • Catch specific exception types in the right order
  • Throw exceptions with throw, including your own custom types
  • Follow best practices for robust error handling

Estimated Time: 60 minutes

Project: Make a fragile program resilient with proper exception handling.

In This Lesson

What Is an Exception?

An exception is C#'s way of signaling that something went wrong at runtime β€” a problem the compiler couldn't catch, but that stops normal execution. You met one in the intro course: calling int.Parse("hello") throws a FormatException and crashes the program.

πŸ“– Definition

Exception: an object representing an error that occurs while a program runs. When code throws an exception, normal flow stops and C# looks for code that will handle it.

When an exception is thrown and nothing handles it, it travels up the call stack until it reaches the top and terminates the program with an error message. Exception handling lets you intercept that and decide what to do instead.

πŸ’‘ Exceptions vs. the TryParse pattern

In the intro course you used int.TryParse to avoid a crash. That's ideal for expected situations like user typos. Exceptions are for exceptional problems β€” a missing file, a failed network call β€” where a crash would otherwise be the only outcome. This lesson gives you the general tool.

try and catch

You wrap risky code in a try block. If it throws, control jumps to the matching catch block instead of crashing:

try
{
    Console.Write("Enter a number: ");
    int number = int.Parse(Console.ReadLine());
    Console.WriteLine($"You entered {number}.");
}
catch (Exception ex)
{
    Console.WriteLine($"Something went wrong: {ex.Message}");
}

Console.WriteLine("The program continues normally.");

If the user types abc, int.Parse throws. Instead of crashing, the catch runs and the program keeps going:

Sample run (user types "abc"):

Enter a number: abc
Something went wrong: The input string 'abc' was not in a correct format.
The program continues normally.

The catch (Exception ex) captures the exception object in ex. Its most useful member is ex.Message, a human-readable description of what happened.

graph TD A["Enter try block"] --> B["Run risky code"] B --> C{"Exception
thrown?"} C -->|"No"| D["Skip catch"] C -->|"Yes"| E["Jump to catch block"] D --> F["Continue program"] E --> F style A fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style F fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px

Catching Specific Exceptions

Catching the broad Exception type works, but it treats every problem the same way. Usually you want to react differently to different failures. C# lets you write multiple catch blocks, each for a specific exception type:

try
{
    int[] numbers = { 1, 2, 3 };
    Console.Write("Enter an index: ");
    int i = int.Parse(Console.ReadLine());
    Console.WriteLine($"Value: {numbers[i]}");
}
catch (FormatException)
{
    Console.WriteLine("That wasn't a valid whole number.");
}
catch (IndexOutOfRangeException)
{
    Console.WriteLine("That index is outside the array.");
}
catch (Exception ex)
{
    Console.WriteLine($"Unexpected error: {ex.Message}");
}

C# checks the catch blocks top to bottom and runs the first one whose type matches the thrown exception.

⚠️ Order matters: specific before general

Because the first match wins, list specific exception types before the general Exception. If catch (Exception) came first, it would catch everything and the specific blocks below it would be unreachable β€” the compiler will actually flag this as an error.

Here are some exception types you'll meet often:

ExceptionTypically thrown when…
FormatExceptionParsing text that isn't in the expected format
IndexOutOfRangeExceptionUsing an array index that doesn't exist
NullReferenceExceptionUsing a reference that is null
FileNotFoundExceptionOpening a file that isn't there
DivideByZeroExceptionDividing an integer by zero
ArgumentExceptionA method receives an invalid argument

The finally Block

A finally block runs no matter what β€” whether the try succeeded, threw an exception, or the exception was caught. It's the place for cleanup that must always happen, like closing a file or releasing a resource:

try
{
    Console.WriteLine("Opening resource...");
    int result = 10 / int.Parse("0");   // throws DivideByZeroException
    Console.WriteLine(result);          // skipped
}
catch (DivideByZeroException)
{
    Console.WriteLine("Cannot divide by zero.");
}
finally
{
    Console.WriteLine("Cleaning up (always runs).");
}

Output:

Opening resource...
Cannot divide by zero.
Cleaning up (always runs).

πŸ’‘ using is often better for cleanup

For objects that implement IDisposable (files, connections), C#'s using statement automatically disposes them even if an exception occurs β€” a cleaner alternative to a manual finally. You'll use using in Module 4 when working with files.

Throwing Exceptions

You can raise exceptions yourself with the throw keyword. This is how a method signals that it was given something it can't work with β€” pushing the problem to the caller rather than silently continuing with bad data:

decimal Withdraw(decimal balance, decimal amount)
{
    if (amount <= 0)
    {
        throw new ArgumentException("Amount must be positive.");
    }
    if (amount > balance)
    {
        throw new InvalidOperationException("Insufficient funds.");
    }
    return balance - amount;
}

The caller decides how to respond by wrapping the call in try/catch:

try
{
    decimal newBalance = Withdraw(100m, 250m);
    Console.WriteLine($"New balance: {newBalance:C}");
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"Transaction declined: {ex.Message}");
}

Output:

Transaction declined: Insufficient funds.

βœ… Throw early, throw clearly

Validate inputs at the start of a method and throw immediately with a clear message if something's wrong. This "fail fast" habit keeps bad data from spreading deeper into your program where it's harder to diagnose.

Custom Exceptions

Sometimes the built-in exception types don't describe your problem well. You can define your own by creating a class that derives from Exception β€” a direct use of the inheritance you learned in the intro course:

class InsufficientFundsException : Exception
{
    public InsufficientFundsException(string message) : base(message)
    {
    }
}

Now you can throw and catch it by name, making your intent crystal clear:

decimal Withdraw(decimal balance, decimal amount)
{
    if (amount > balance)
    {
        throw new InsufficientFundsException(
            $"Tried to withdraw {amount:C} but only {balance:C} available.");
    }
    return balance - amount;
}

try
{
    Withdraw(100m, 250m);
}
catch (InsufficientFundsException ex)
{
    Console.WriteLine($"Declined: {ex.Message}");
}

πŸ’‘ Why bother?

A custom exception type lets callers catch exactly the error they care about, and the type name itself documents what went wrong. By convention, custom exception class names end in Exception, and they pass their message up to the base with : base(message).

Best Practices

βœ… Do

  • Catch specific exceptions you can actually handle, not just Exception.
  • Use the message. Log or display ex.Message so problems are diagnosable.
  • Clean up with finally or (better) using.
  • Validate and throw early with clear messages.

❌ Don't

  • Don't swallow exceptions silently. An empty catch { } hides bugs and makes failures invisible β€” one of the most damaging habits in programming.
  • Don't use exceptions for normal flow. For expected cases (like validating user input), prefer checks such as TryParse; exceptions are comparatively expensive and obscure intent.
  • Don't catch what you can't handle. If a block can't do anything useful about an error, let it propagate to code that can.
πŸ’‘ The mindset: Exception handling isn't about making errors disappear β€” it's about responding to them deliberately. A good handler either fixes the situation, informs the user, or logs the problem and re-raises it.

Exercise & Quiz

πŸ‹οΈ Exercise: A Safe Division Tool

Objective: Use multiple catch blocks, finally, and a custom exception.

Instructions:

  1. Create a new project called SafeDivide.
  2. Ask the user for two integers and divide the first by the second.
  3. Wrap it in try/catch handling FormatException (bad input) and DivideByZeroException (dividing by zero) with distinct messages.
  4. Add a finally block that prints "Calculation attempt finished."
  5. Bonus: Write a method int Divide(int a, int b) that throws an ArgumentException if b is zero, and call it inside the try.

Starter Code:

try
{
    Console.Write("Numerator: ");
    int a = int.Parse(Console.ReadLine());
    Console.Write("Denominator: ");
    int b = int.Parse(Console.ReadLine());

    Console.WriteLine($"Result: {a / b}");
}
// TODO: catch FormatException
// TODO: catch DivideByZeroException
// TODO: finally block
πŸ’‘ Hint

List the specific catches (FormatException, DivideByZeroException) before any general catch (Exception). The finally block goes last and always runs. For the bonus, throw new ArgumentException("...") inside Divide when b == 0.

βœ… Solution
try
{
    Console.Write("Numerator: ");
    int a = int.Parse(Console.ReadLine());
    Console.Write("Denominator: ");
    int b = int.Parse(Console.ReadLine());

    Console.WriteLine($"Result: {Divide(a, b)}");
}
catch (FormatException)
{
    Console.WriteLine("Please enter valid whole numbers.");
}
catch (ArgumentException ex)
{
    Console.WriteLine($"Bad argument: {ex.Message}");
}
catch (DivideByZeroException)
{
    Console.WriteLine("You can't divide by zero.");
}
finally
{
    Console.WriteLine("Calculation attempt finished.");
}

int Divide(int a, int b)
{
    if (b == 0)
    {
        throw new ArgumentException("Denominator cannot be zero.");
    }
    return a / b;
}

Sample run:

Numerator: 10
Denominator: 0
Bad argument: Denominator cannot be zero.
Calculation attempt finished.

🎯 Quick Quiz

Question 1: What happens to code after the failing line inside a try block once an exception is thrown?

Question 2: Why must specific catch blocks come before catch (Exception)?

Question 3: When does a finally block run?

Summary

πŸŽ‰ Key Takeaways

  • An exception signals a runtime error; unhandled, it crashes the program.
  • Wrap risky code in try and handle failures in catch; read ex.Message for details.
  • Catch specific exception types before the general Exception β€” order matters.
  • finally always runs (great for cleanup); using is often cleaner for disposable resources.
  • Use throw to raise exceptions (fail fast), and derive from Exception for custom types. Never silently swallow exceptions.

πŸ“š Additional Resources

πŸš€ What's Next?

You can now keep programs running through errors. Next, we make code reusable and type-safe at the same time. In Lesson 1.2: Generics, you'll write classes and methods that work with any type β€” the technology behind List<T> itself.

πŸŽ‰ Robustness unlocked!

Your programs no longer fall over at the first surprise. Next up: generics.