In the previous lessons you've learned to make decisions, repeat actions, and classify values, but always assuming the data arrives correctly: a number that really is a number, an index that exists within the array, a book that really is in the catalog. In a real program, that's not guaranteed: a user might type text where a number is expected, someone might try to lend a book that doesn't exist, or divide by a value that turns out to be zero. When something like this happens, C# doesn't just carry on as if nothing happened: it throws an exception, a special object that interrupts the program's normal flow to signal that something has gone wrong. This lesson, the last one in Module 2, teaches you to anticipate these situations and handle them in a controlled way with try/catch/finally, instead of letting the program stop abruptly.

Content

  1. What is an exception?
  2. try/catch: catching errors
  3. finally: code that always runs
  4. Throwing your own exceptions with throw
  5. Common exception types
  6. Catching multiple exception types
  7. Best practices with exceptions
  8. Full application: lending a book safely

  1. What is an exception?

An exception is an object (from a class that derives, directly or indirectly, from System.Exception) representing an error that occurred while the program was running. If nothing is done to handle it, the exception propagates upward until, if no one catches it, the program terminates abruptly and prints an error message to the console along with the call stack (stack trace): the sequence of methods that were executing when the failure occurred.

int[] availableCopies = { 3, 0, 5 };

Console.WriteLine(availableCopies[10]); // IndexOutOfRangeException: the program stops here
Console.WriteLine("This line never runs");

Running this code, the console would show something similar to:

Unhandled exception. System.IndexOutOfRangeException: Index was outside the bounds of the array.
   at Program.Main(String[] args)

The goal of this lesson is to keep predictable errors like this one from stopping BiblioTechConsole entirely, and instead react to them in a controlled way.

  1. try/catch: catching errors

The try block wraps code that might fail; the catch block defines what to do if it actually does fail.

int[] availableCopies = { 3, 0, 5 };

try
{
    Console.WriteLine(availableCopies[10]);
}
catch (IndexOutOfRangeException)
{
    Console.WriteLine("Error: attempted to access a position that doesn't exist in the array.");
}

Console.WriteLine("The program keeps running normally.");

Unlike the previous example, here the program doesn't stop: the exception is caught, a readable message is displayed, and execution continues on the line after the try/catch block.

You can also access the exception object to look up additional information, such as its Message property:

try
{
    string userInput = "not-a-number";
    int pages = int.Parse(userInput);
    Console.WriteLine($"Pages: {pages}");
}
catch (FormatException ex)
{
    Console.WriteLine($"Error converting text to a number: {ex.Message}");
}

  1. finally: code that always runs

The optional finally block contains code that runs always, whether the try block finishes successfully or an exception is thrown and caught. It's the usual place to release resources (closing a file, a network connection, or a database connection) that must be closed no matter what happens.

try
{
    Console.WriteLine("Opening the loan log...");
    int pages = int.Parse("120");
    Console.WriteLine($"Entry processed: {pages} pages");
}
catch (FormatException ex)
{
    Console.WriteLine($"Format error: {ex.Message}");
}
finally
{
    Console.WriteLine("Closing the loan log.");
}

In this example, "Closing the loan log." is always displayed: whether the conversion of "120" succeeds, or fails and the catch catches the error. You'll see finally play a much bigger role in Module 5, when working with files and databases, where properly closing open resources is critical.

  1. Throwing your own exceptions with throw

Besides catching exceptions thrown by the runtime itself (.NET), your code can throw its own exceptions with throw, to signal an anomalous situation according to BiblioTech's business rules.

void RegisterLoan(bool bookAvailable)
{
    if (!bookAvailable)
    {
        throw new InvalidOperationException("Cannot lend a book that isn't available.");
    }

    Console.WriteLine("Loan registered successfully.");
}

try
{
    RegisterLoan(bookAvailable: false);
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"Invalid operation: {ex.Message}");
}

throw new ExceptionType("descriptive message") creates a new instance of the specified exception and throws it immediately, interrupting the method's normal execution until some catch block (in this method or in whoever called it) catches it.

  1. Common exception types

.NET provides many ready-made exception classes for common situations. Using the most specific type possible (rather than the generic Exception) helps whoever reads the code — and whoever catches it — understand exactly what went wrong.

Exception When it occurs (or is thrown deliberately)
Exception The base class all others derive from; represents "a generic error"
ArgumentException An invalid argument was passed to a method
ArgumentNullException null was passed where a value was expected (derives from ArgumentException)
InvalidOperationException An operation is attempted that isn't valid in the object's/program's current state
FormatException A string doesn't have the expected format for conversion (for example, int.Parse on non-numeric text)
IndexOutOfRangeException An array is accessed at a position outside its bounds
NullReferenceException An attempt is made to use a member (method, property) of a variable that is null
DivideByZeroException An integer division by zero is performed
void ValidatePageCount(int pages)
{
    if (pages <= 0)
    {
        throw new ArgumentException("The page count must be greater than zero.", nameof(pages));
    }
}

try
{
    ValidatePageCount(-50);
}
catch (ArgumentException ex)
{
    Console.WriteLine($"Invalid argument: {ex.Message}");
}

  1. Catching multiple exception types

A single try block can have several catch blocks, each for a different exception type. C# evaluates the catch blocks from top to bottom and runs the first one whose type matches (or is a base class of) the thrown exception, so the more specific types must always go before the more generic ones.

string[] bookTitles = { "One Hundred Years of Solitude", "Hopscotch", "Ficciones" };

try
{
    Console.Write("Enter the position of the book you want to check: ");
    string? input = Console.ReadLine();
    int position = int.Parse(input!);

    Console.WriteLine(bookTitles[position]);
}
catch (FormatException)
{
    Console.WriteLine("You must enter a valid whole number.");
}
catch (IndexOutOfRangeException)
{
    Console.WriteLine("There's no book at that position in the catalog.");
}
catch (Exception ex)
{
    Console.WriteLine($"An unexpected error occurred: {ex.Message}");
}

If FormatException and IndexOutOfRangeException came after catch (Exception ex), they would never run: the C# compiler directly flags that order as an error, because a preceding catch (Exception ex) block would already catch any exception, making the remaining blocks unreachable.

It's also possible to catch several types with the same block, using the when operator to add an extra condition, or simply defining the block for the common base class you're interested in; for most everyday cases, though, one catch per type (as in the previous example) is clearer.

  1. Best practices with exceptions

Best practice Why it matters
Catch the most specific type possible Makes it easier to give a precise message and doesn't hide other errors
Don't use exceptions for the program's normal flow They're expensive in performance and make the program's logic harder to follow
Don't catch generic Exception "just because" It can hide real programming errors that should be fixed
Include descriptive messages in throw new ...(...) Helps diagnose the problem without having to guess the cause
Use finally (or resources that close themselves) to release resources Prevents memory leaks, locked files, or open connections

On the first point in the table — not using exceptions for normal flow — if a situation is predictable and frequent (for example, checking whether a text can be converted to a number), it's usually better to use tools like int.TryParse (which you already saw in the variables lesson of Module 1) rather than wrapping int.Parse in a try/catch and waiting for it to fail. Exceptions are meant for exceptional situations, not routine validations:

// Preferable for routine validations: no exceptions involved
string input = "abc";
if (int.TryParse(input, out int pages))
{
    Console.WriteLine($"Page count: {pages}");
}
else
{
    Console.WriteLine("Invalid input.");
}

// Reserve try/catch for what's truly unexpected or beyond your control,
// such as accessing an array at a position that, in theory, should never occur.

  1. Full application: lending a book safely

Let's close the lesson — and the module — with an example that combines several of the ideas covered: searching for a book by title, validating user input, and throwing and catching your own exceptions when a loan operation isn't possible.

string[] bookTitles = { "One Hundred Years of Solitude", "Hopscotch", "Ficciones" };
bool[] availability = { true, false, true };

void LendBook(string requestedTitle)
{
    int foundPosition = -1;

    for (int i = 0; i < bookTitles.Length; i++)
    {
        if (bookTitles[i] == requestedTitle)
        {
            foundPosition = i;
            break;
        }
    }

    if (foundPosition == -1)
    {
        throw new ArgumentException($"The book '{requestedTitle}' doesn't exist in the catalog.");
    }

    if (!availability[foundPosition])
    {
        throw new InvalidOperationException($"The book '{requestedTitle}' is already on loan.");
    }

    availability[foundPosition] = false;
    Console.WriteLine($"Loan of '{requestedTitle}' registered successfully.");
}

string[] requests = { "Hopscotch", "1984", "Ficciones" };

foreach (string request in requests)
{
    try
    {
        LendBook(request);
    }
    catch (ArgumentException ex)
    {
        Console.WriteLine($"Could not process the request: {ex.Message}");
    }
    catch (InvalidOperationException ex)
    {
        Console.WriteLine($"Could not process the request: {ex.Message}");
    }
    finally
    {
        Console.WriteLine($"--- End of processing for '{request}' ---");
    }
}

Expected output:

Could not process the request: The book 'Hopscotch' is already on loan.
--- End of processing for 'Hopscotch' ---
Could not process the request: The book '1984' doesn't exist in the catalog.
--- End of processing for '1984' ---
Loan of 'Ficciones' registered successfully.
--- End of processing for 'Ficciones' ---

Notice how the foreach loop keeps processing every request even if one of them fails: thanks to the try/catch inside the loop itself, an error in one request doesn't prevent the next ones from being processed, and the finally guarantees that the closing message is displayed in all three cases.

Common Mistakes and Tips

  • Systematically catching generic Exception: this hides the real error type and makes problems harder to diagnose. Reserve it as a last resort, after the specific types, or to log truly unexpected errors.
  • Using exceptions for validations that could be anticipated: if you can check the condition beforehand with an if or with TryParse, do so; don't let the program fail "on purpose" just to catch it afterward.
  • Empty catch blocks: catching an exception and doing nothing with it (not displaying a message, not logging it) hides errors that should be fixed; at the very least, report the problem.
  • Wrong order of catch blocks: more specific types must always come before more generic ones; otherwise, the compiler will flag the generic block as unreachable.
  • Forgetting to release resources: if you open something that needs to be closed (files, connections), use finally or the constructs that already handle it automatically (you'll see this with using in Module 5, when working with files).
  • Uninformative exception messages: throw new Exception("Error") doesn't help anyone understand what happened; always include relevant details (which book, which value, which operation).

Exercises

  1. Write a try/catch block that attempts to convert the string "cien" to int with int.Parse, catches the FormatException, and displays a message stating that the input isn't a valid number.

  2. Create a local function void ValidateIsbn(string isbn) that throws an ArgumentException with the message "The ISBN cannot be empty." if the isbn parameter is an empty string (""), and otherwise prints "Valid ISBN." to the console. Call the function with "" inside a try/catch that catches the exception and displays its message.

  3. Given the array int[] availableCopies = { 2, 0, 3 };, write a try/catch/finally that attempts to access position 5 of the array (which doesn't exist), catches the IndexOutOfRangeException displaying a suitable message, and in the finally displays "Lookup finished." regardless of whether there was an error.

Solutions

try
{
    int number = int.Parse("cien");
    Console.WriteLine(number);
}
catch (FormatException)
{
    Console.WriteLine("The input isn't a valid number.");
}

Since "cien" can't be converted to int, a FormatException is thrown, and the catch block displays the message "The input isn't a valid number."; the program continues normally after the block.

void ValidateIsbn(string isbn)
{
    if (isbn == "")
    {
        throw new ArgumentException("The ISBN cannot be empty.");
    }

    Console.WriteLine("Valid ISBN.");
}

try
{
    ValidateIsbn("");
}
catch (ArgumentException ex)
{
    Console.WriteLine(ex.Message);
}

Calling ValidateIsbn("") throws the exception with the given message, which the catch catches and displays: "The ISBN cannot be empty.".

int[] availableCopies = { 2, 0, 3 };

try
{
    Console.WriteLine(availableCopies[5]);
}
catch (IndexOutOfRangeException)
{
    Console.WriteLine("That position doesn't exist in the array of available copies.");
}
finally
{
    Console.WriteLine("Lookup finished.");
}

Attempting to access availableCopies[5] (the array only has positions 0, 1, and 2) throws an IndexOutOfRangeException; the catch displays the error message and, afterward, the finally displays "Lookup finished." regardless.

Conclusion

In this lesson you've learned what an exception is and how to catch it with try/catch, how to guarantee that certain code always runs with finally, how to throw your own exceptions with throw to flag broken business rules, the most common .NET exception types, how to catch several types in the right order, and why you shouldn't overuse exceptions for validations that can be anticipated. BiblioTechConsole can now react to invalid data or unforeseen situations without grinding to a halt.

This closes Module 2: Control Structures. You now know how to make decisions with conditionals, repeat actions with loops, classify values with switch, and protect your program from errors with exceptions — all of it, so far, using loose variables, arrays, and local functions inside Main. That way of working starts to show its limits as BiblioTechConsole grows: each book is several loose variables (title, ISBN, availability...) that have to be kept in sync by hand, and each member is just as many more. In Module 3: Object-Oriented Programming you'll learn to model these concepts with classes, grouping data and behavior in a single place: the first step toward BiblioTechConsole ceasing to be a collection of loose variables and starting to look like a real, well-organized application.

© Copyright 2026. All rights reserved