30 min read

> "The robustness of a program is proportional to the effort invested in handling the cases where things go wrong."

Learning Objectives

  • Use try..except blocks to catch and handle exceptions
  • Use try..finally blocks for guaranteed cleanup
  • Create custom exception classes
  • Apply exception handling best practices (don't catch everything, meaningful messages)
  • Migrate from IOResult-style error handling to exception-based patterns

Chapter 19: Exception Handling: Writing Robust Code with TRY..EXCEPT..FINALLY

"The robustness of a program is proportional to the effort invested in handling the cases where things go wrong." — Niklaus Wirth

Programs fail. Files go missing. Users type "abc" where a number is expected. Network connections drop. Disks fill up. Memory runs out. A database driver returns an error code you have never seen before. This is not pessimism — this is the reality of software that runs on real hardware, reads real files, and serves real users.

Until now, our programs have been optimistic. They assume that every file exists, every user types valid input, and every operation succeeds. That optimism served us well while we were learning syntax and algorithms, but it produces fragile software — software that crashes the moment something unexpected happens.

This chapter teaches you exception handling: Pascal's structured mechanism for detecting, reporting, and recovering from errors. Exceptions let you separate the "happy path" — the code that runs when everything works — from the error-handling code that deals with failures. The result is programs that are both cleaner and more robust: the normal logic reads like a straight line, and the error handling is clearly demarcated in try..except and try..finally blocks.

By the end of this chapter, PennyWise will handle corrupted save files, invalid user input, and missing directories with grace instead of crashes. Rosa will no longer lose her data when she accidentally deletes a file, and Tomas will get helpful error messages instead of cryptic runtime errors.

Exception handling is one of those topics that separates educational programs from real programs. Everything we have written until now has been educational: if an error occurs, the program crashes. That is fine for learning, but it is unacceptable for software that real people use to manage their real finances. This chapter marks the transition from "programs that work when everything goes right" to "programs that work even when things go wrong." The difference is profound, and it is entirely about exception handling.

The discipline of exception handling transfers to every language. Java has try-catch-finally. Python has try-except-finally. C++ has try-catch. C# has try-catch-finally. JavaScript has try-catch-finally. Rust takes a different approach with Result<T, E> types, but the underlying concern — handling failure gracefully — is identical. Learn exception handling well in Pascal, and you have learned it for every language you will ever use. This is Theme 2 at its most practical.


19.1 What Are Exceptions?

An exception is an object that represents an error condition. When something goes wrong — a file is not found, a number conversion fails, an array index is out of range — Pascal creates an exception object and raises (or "throws") it. This immediately interrupts the normal flow of execution. The program jumps out of the current procedure, out of the procedure that called it, out of the procedure that called that, searching back up the call stack for code that knows how to handle the error.

If the program finds a handler — a try..except block — it executes the handler code and continues. If no handler is found anywhere in the call stack, the program terminates with an error message. This is what happens when you see "Runtime error 201 at address 0x00401234" — an exception was raised and nobody caught it.

The Old Way: Error Codes

Before exceptions, Pascal (and most languages) handled errors with return codes and special values. The IOResult function is the classic example:

{$I-}   { Turn off automatic I/O checking }
AssignFile(F, 'data.txt');
Reset(F);
if IOResult <> 0 then
begin
  WriteLn('Could not open file!');
  Exit;
end;
ReadLn(F, Line);
if IOResult <> 0 then
begin
  WriteLn('Could not read from file!');
  CloseFile(F);
  Exit;
end;
CloseFile(F);
{$I+}

This works, but it has serious problems:

  1. Every operation needs a check. The error-checking code is interleaved with the normal logic, making it hard to read either one.
  2. It is easy to forget a check. If you forget to test IOResult after an operation, the error is silently ignored and the program continues with corrupt or missing data.
  3. Error propagation is manual. If a deeply nested procedure encounters an error, it must return an error code, which the caller must check, which its caller must check, all the way up the stack. At any link in this chain, someone can forget to check.
  4. Cleanup is fragile. If you open three resources and the second one fails, you must remember to close the first before exiting. The more resources, the more complex the cleanup logic.

The Exception Way

Exceptions solve all four problems:

try
  AssignFile(F, 'data.txt');
  Reset(F);
  ReadLn(F, Line);
  CloseFile(F);
except
  on E: EInOutError do
    WriteLn('File error: ', E.Message);
end;

The normal logic reads as a clean sequence with no error checks. If any operation raises an exception, control jumps immediately to the except block. You cannot accidentally ignore an exception — if you do not handle it, the program terminates rather than silently continuing with bad data. And exceptions propagate automatically up the call stack: if a deeply nested procedure raises an exception, it flies past every intervening caller until it finds a handler.

💡 Analogy: Think of exceptions like a fire alarm. The IOResult approach is like checking every room for fire before entering. The exception approach is: walk through the building normally, but if a fire starts anywhere, the alarm goes off and everyone evacuates to the designated assembly point (the except block). The alarm approach is both safer and less intrusive.


Exceptions vs. Assertions

Before we dive into try..except, let us distinguish exceptions from a related concept: assertions. An assertion is a check for a condition that should always be true. If an assertion fails, it means your code has a bug — not that the user did something wrong or the file system failed.

{ Assertion: this should ALWAYS be true — a failure means a bug in our code }
Assert(Index >= 0, 'Index must not be negative');
Assert(List <> nil, 'List was not initialized');

{ Exception: this can happen in normal operation — a failure means bad input or environment }
if Amount < 0 then
  raise EInvalidExpenseError.Create('Amount cannot be negative');

Assertions are checked only when compiled with {$C+} (the default) and are typically disabled in release builds for performance. Exceptions are always active and represent conditions the program must handle at runtime. Use assertions for programmer errors (bugs); use exceptions for environmental and user errors.

The Exception Object Lifecycle

When an exception is raised, Pascal creates an exception object on the heap. This object persists until the except block that handles it finishes executing, at which point the runtime frees it automatically. You should never call Free on an exception object inside an except block — the runtime handles that for you.

try
  raise Exception.Create('Test error');
except
  on E: Exception do
  begin
    WriteLn(E.Message);   { Object is alive here }
    { Do NOT call E.Free — the runtime frees it when this block ends }
  end;
  { E is freed here automatically }
end;

If you re-raise an exception with raise, the exception object's lifetime is extended — it is passed to the next handler up the call stack, which is responsible for its eventual deallocation.


19.2 try..except Basics

The try..except block is the fundamental exception-handling construct. It has two parts:

try
  { Code that might raise an exception }
except
  { Code that handles the exception }
end;

If no exception occurs in the try section, the except section is skipped entirely. If an exception occurs, execution immediately leaves the try section and enters the except section. After the except section completes, execution continues after the end.

Catching Specific Exception Types

The on clause lets you catch specific exception types:

try
  Val := StrToInt(UserInput);
except
  on E: EConvertError do
    WriteLn('Invalid number: ', E.Message);
end;

The variable E is the exception object. Every exception has a Message property (a human-readable error description) and a ClassName (like EConvertError). The on E: EConvertError clause catches only EConvertError exceptions — other exception types pass through unhandled.

Multiple Exception Handlers

You can catch different exception types with different handlers:

try
  ProcessFile(FileName);
except
  on E: EInOutError do
    WriteLn('File error: ', E.Message);
  on E: EConvertError do
    WriteLn('Data format error: ', E.Message);
  on E: ERangeError do
    WriteLn('Value out of range: ', E.Message);
end;

The handlers are checked in order. The first matching handler executes; the rest are skipped. Because of this, always list more specific exceptions before more general ones.

The Catch-All Handler

A bare except without on catches any exception:

try
  DoSomethingRisky;
except
  WriteLn('Something went wrong.');
end;

You can also use on E: Exception to catch any exception while still accessing the exception object:

try
  DoSomethingRisky;
except
  on E: Exception do
    WriteLn('Error [', E.ClassName, ']: ', E.Message);
end;

⚠️ Best Practice: Avoid catching all exceptions unless you have a very good reason (such as a top-level error handler in a long-running application). Catching everything hides bugs. If you catch an EAccessViolation (a null pointer dereference), your program has a serious bug that no except block can fix — it just hides the symptom. Catch specific exceptions that you know how to handle; let everything else propagate.

The else Clause in try..except

After all on clauses, you can add an else that catches any remaining exceptions:

try
  DoSomething;
except
  on E: EConvertError do
    WriteLn('Conversion error');
  on E: EInOutError do
    WriteLn('File error');
else
  WriteLn('Some other error occurred');
end;

The else clause does not give you access to the exception object. If you need the object, use on E: Exception do as the final handler instead. Most programmers prefer the explicit on E: Exception do over else for this reason.

Nested try..except Blocks: Detailed Walkthrough

Before examining exception replacement, let us trace through a realistic nested try..except scenario step by step. Consider a program that processes a list of files:

procedure ProcessAllFiles;
var
  i: Integer;
  Files: array[0..2] of String = ('data1.csv', 'missing.csv', 'data3.csv');
begin
  for i := 0 to 2 do
  begin
    WriteLn('--- Processing: ', Files[i], ' ---');
    try
      try
        OpenFile(Files[i]);
        try
          ParseContents;
          SaveResults;
        except
          on E: EConvertError do
            WriteLn('  Parse error in ', Files[i], ': ', E.Message);
        end;
      finally
        CloseFileIfOpen;
      end;
    except
      on E: EInOutError do
        WriteLn('  File error for ', Files[i], ': ', E.Message);
    end;
    WriteLn('  Done with ', Files[i]);
  end;
end;

Trace for data1.csv (exists, valid data): 1. OpenFile succeeds. 2. ParseContents succeeds. 3. SaveResults succeeds. 4. Inner except is skipped (no exception). 5. finally runs: CloseFileIfOpen closes the file. 6. Outer except is skipped. 7. Prints "Done with data1.csv".

Trace for missing.csv (does not exist): 1. OpenFile raises EInOutError. 2. Inner except does not catch EInOutError (it only catches EConvertError). Exception propagates. 3. finally runs: CloseFileIfOpen handles the case where no file was opened. 4. Outer except catches EInOutError. Prints "File error for missing.csv: ...". 5. Prints "Done with missing.csv". 6. Loop continues with data3.csv.

Trace for data3.csv (exists, but has invalid data): 1. OpenFile succeeds. 2. ParseContents raises EConvertError on a bad line. 3. Inner except catches EConvertError. Prints "Parse error in data3.csv: ...". 4. finally runs: CloseFileIfOpen closes the file. 5. Outer except is skipped (the inner handler already dealt with the exception). 6. Prints "Done with data3.csv".

The key insight: the finally block runs in every scenario. The inner except handles parse errors locally. The outer except catches file errors that the inner handler cannot. And the loop continues after each file, regardless of what happened. This is the power of nested exception blocks — each layer handles errors at the appropriate level of abstraction.

Exception Replacement in Except Handlers

An interesting edge case occurs when an exception is raised inside an except handler. This is legal — the new exception replaces the one being handled:

try
  raise Exception.Create('First error');
except
  on E: Exception do
  begin
    WriteLn('Handling: ', E.Message);
    raise Exception.Create('Second error');  { Replaces the first }
  end;
end;

If a handler raises a new exception, the original exception is freed and the new one propagates. This is sometimes useful for wrapping low-level exceptions in higher-level ones:

try
  LoadFromDatabase;
except
  on E: ESQLError do
    raise EPennyWiseError.CreateFmt('Cannot load expenses: %s', [E.Message]);
end;

Here, the SQL-specific exception is caught and replaced with an application-level exception that provides context meaningful to the caller.

Example: Safe String-to-Integer Conversion

function SafeStrToInt(const S: String; Default: Integer): Integer;
begin
  try
    Result := StrToInt(S);
  except
    on EConvertError do
      Result := Default;
  end;
end;

{ Usage }
var
  Age: Integer;
begin
  Write('Enter your age: ');
  ReadLn(Input);
  Age := SafeStrToInt(Input, -1);
  if Age < 0 then
    WriteLn('That was not a valid number.')
  else
    WriteLn('You are ', Age, ' years old.');
end;

This is a clean pattern: try the conversion, catch the specific exception that indicates invalid input, and return a default value. The calling code does not need to know about exceptions at all — it just gets a valid integer or the default.


19.3 Exception Classes

Object Pascal has a hierarchy of exception classes, all descending from the base class Exception (declared in the SysUtils unit). Knowing the hierarchy helps you catch the right exceptions.

The Exception Class Hierarchy

Exception                          { Base class — has Message property }
├── EAbort                         { Silent exception — halts without error }
├── EInOutError                    { File I/O errors (ErrorCode property) }
├── EConvertError                  { String conversion failures }
├── ERangeError                    { Value out of range }
├── EIntOverflow                   { Integer arithmetic overflow }
├── EDivByZero                     { Integer division by zero }
├── EInvalidOp                     { Invalid floating-point operation }
├── EZeroDivide                    { Floating-point division by zero }
├── EOverflow                      { Floating-point overflow }
├── EUnderflow                     { Floating-point underflow }
├── EAccessViolation               { Invalid memory access (null pointer, etc.) }
├── EInvalidPointer                { Freeing an invalid pointer }
├── EOutOfMemory                   { Memory allocation failure }
├── EStackOverflow                 { Stack overflow (often from infinite recursion) }
├── EExternalException             { OS-level exception }
├── EStringListError               { TStringList errors }
├── EListError                     { TList index errors }
└── (user-defined exceptions)      { Your custom exception classes }

Common Exceptions You Will Encounter

EConvertError — Raised by StrToInt, StrToFloat, StrToDate, and similar conversion functions when the string does not match the expected format. This is by far the most common exception in programs that process user input.

try
  Value := StrToFloat(UserInput);
except
  on E: EConvertError do
    WriteLn('Please enter a valid number.');
end;

EInOutError — Raised when a file operation fails: the file does not exist, permissions are denied, the disk is full. The ErrorCode property gives the OS error code.

try
  AssignFile(F, 'data.txt');
  Reset(F);
except
  on E: EInOutError do
    WriteLn('Cannot open file. Error code: ', E.ErrorCode);
end;

ERangeError — Raised when an array index or subrange value is out of bounds (only when range checking is enabled with {$R+}).

{$R+}
var
  Arr: array[1..10] of Integer;
begin
  try
    Arr[11] := 42;  { Out of bounds! }
  except
    on E: ERangeError do
      WriteLn('Index out of range: ', E.Message);
  end;
end;

EDivByZero — Raised when integer division by zero is attempted.

try
  Result := Numerator div Denominator;
except
  on E: EDivByZero do
    WriteLn('Cannot divide by zero.');
end;

EAccessViolation — Raised when code tries to read or write memory through an invalid pointer (typically a nil pointer). This almost always indicates a bug in your code rather than a recoverable error condition.

📊 Exception Frequency in Typical Applications: - EConvertError — very common (user input parsing) - EInOutError — common (file operations) - ERangeError — occasional (array bounds) - EDivByZero — rare (arithmetic) - EAccessViolation — indicates a bug, should not be caught in normal flow


Exception Class Properties

Every exception object has several useful properties inherited from the base Exception class:

try
  SomeRiskyOperation;
except
  on E: Exception do
  begin
    WriteLn('Class: ', E.ClassName);     { The exception class name }
    WriteLn('Message: ', E.Message);     { Human-readable error description }
    WriteLn('Help: ', E.HelpContext);    { Help context ID (for GUI apps) }
  end;
end;

The Message property is the most important — it contains the error description passed to Create or CreateFmt. Always provide meaningful messages when raising exceptions. A message like 'Error' is useless; a message like 'Cannot open file "expenses.csv": file not found in directory /home/rosa/data' tells the user exactly what happened and where.

The EInOutError.ErrorCode Property

EInOutError has an additional ErrorCode property that contains the operating system's error code:

try
  AssignFile(F, '/nonexistent/path/file.txt');
  Reset(F);
except
  on E: EInOutError do
  begin
    WriteLn('I/O Error: ', E.Message);
    WriteLn('OS Error Code: ', E.ErrorCode);
    case E.ErrorCode of
      2: WriteLn('File not found');
      3: WriteLn('Path not found');
      5: WriteLn('Access denied');
    else
      WriteLn('Unknown I/O error');
    end;
  end;
end;

This level of detail is invaluable for diagnosing problems in production. Rosa does not just see "file error" — she sees "path not found," which tells her exactly what to fix.

Silent Exceptions: EAbort

There is one special exception class worth knowing about: EAbort. When EAbort is raised, it silently cancels the current operation without displaying an error message (in GUI applications). It is used for operations like "Cancel" button clicks where the user intentionally stopped something:

procedure LoadDataWithCancel;
begin
  if UserClickedCancel then
    raise EAbort.Create('');  { Silently abort — no error dialog }
  { Otherwise, load data... }
end;

In console applications, EAbort behaves like any other exception. In Lazarus GUI applications, the default exception handler specifically does not show a dialog for EAbort. This makes it the right way to cancel long-running operations without alarming the user.


19.4 try..finally for Cleanup

The try..finally block serves a different purpose from try..except. It does not catch exceptions — it guarantees that cleanup code executes whether or not an exception occurs.

The Problem: Resource Leaks

Consider this code:

var
  F: TextFile;
begin
  AssignFile(F, 'data.txt');
  Rewrite(F);
  WriteLn(F, 'Important data');
  { What if an exception occurs here? }
  WriteLn(F, CalculateResult);  { Could raise an exception! }
  CloseFile(F);  { This line might never execute }
end;

If CalculateResult raises an exception, CloseFile(F) never executes. The file remains open, potentially locked, and the written data might not be flushed to disk.

The Solution: try..finally

var
  F: TextFile;
begin
  AssignFile(F, 'data.txt');
  Rewrite(F);
  try
    WriteLn(F, 'Important data');
    WriteLn(F, CalculateResult);
  finally
    CloseFile(F);  { Always executes, exception or not }
  end;
end;

The finally block executes in all three scenarios:

  1. No exception: The try section completes normally, then the finally section executes.
  2. Exception raised: Execution leaves the try section, the finally section executes, and then the exception continues propagating up the call stack.
  3. Exit or Break: If the code contains Exit, Break, or Continue inside the try section, the finally section still executes before the jump.

This guarantee makes try..finally essential for resource management: files, database connections, network sockets, memory allocations — anything that must be cleaned up.

Object Cleanup Pattern

The most common use of try..finally in Object Pascal is freeing objects:

var
  List: TStringList;
begin
  List := TStringList.Create;
  try
    List.Add('Hello');
    List.Add('World');
    ProcessList(List);
  finally
    List.Free;  { Always freed, even if ProcessList raises an exception }
  end;
end;

This pattern is so fundamental that you should consider it the default way to work with objects in Pascal. Create, then immediately open a try..finally block, and put Free in the finally section.

💡 The Create-Try-Finally Pattern: Every time you write SomeObject := TSomething.Create, the next line should be try, and the finally block should contain SomeObject.Free. This is not a suggestion — it is how professional Pascal code is written.

Multiple Resources

When you need to manage multiple resources, nest your try..finally blocks:

var
  Input, Output: TFileStream;
begin
  Input := TFileStream.Create('source.dat', fmOpenRead);
  try
    Output := TFileStream.Create('dest.dat', fmCreate);
    try
      CopyData(Input, Output);
    finally
      Output.Free;
    end;
  finally
    Input.Free;
  end;
end;

Each resource gets its own try..finally block. If Output creation fails, Input is still freed. If CopyData fails, both streams are freed. The nesting ensures proper cleanup order (last opened, first closed).

An alternative for multiple objects is the nil-initialization pattern:

var
  Input, Output: TFileStream;
begin
  Input := nil;
  Output := nil;
  try
    Input := TFileStream.Create('source.dat', fmOpenRead);
    Output := TFileStream.Create('dest.dat', fmCreate);
    CopyData(Input, Output);
  finally
    Output.Free;  { Free is safe to call on nil — it does nothing }
    Input.Free;
  end;
end;

This works because TObject.Free first checks whether Self is nil before calling Destroy. Calling Free on a nil reference is safe and does nothing.


19.5 Nested try Blocks

In real programs, you often need both exception handling and cleanup. You can nest try..except inside try..finally (or vice versa):

Pattern 1: try..finally with try..except Inside

var
  F: TextFile;
begin
  AssignFile(F, FileName);
  Rewrite(F);
  try
    try
      WriteLn(F, ProcessData);
    except
      on E: Exception do
        WriteLn('Error processing data: ', E.Message);
    end;
  finally
    CloseFile(F);
  end;
end;

Here, the try..except handles errors during data processing, and the outer try..finally guarantees the file is closed regardless.

Pattern 2: try..except with try..finally Inside

try
  var F: TextFile;
  AssignFile(F, FileName);
  Reset(F);
  try
    ReadLn(F, Data);
    ProcessData(Data);
  finally
    CloseFile(F);
  end;
except
  on E: EInOutError do
    WriteLn('File error: ', E.Message);
  on E: Exception do
    WriteLn('Processing error: ', E.Message);
end;

Here, the outer try..except catches any exception from either the file operations or the data processing. The inner try..finally ensures the file is closed even if an exception occurs. This is a robust pattern for file processing.

A Real-World Example: Processing a Configuration File

Here is a complete example showing nested try blocks in a realistic scenario — loading a configuration file with both error handling and cleanup:

procedure LoadConfiguration(const FileName: String);
var
  F: TextFile;
  Line, Key, Value: String;
  LineNum: Integer;
  SepPos: Integer;
begin
  if not FileExists(FileName) then
  begin
    WriteLn('Configuration file not found. Using defaults.');
    LoadDefaults;
    Exit;
  end;

  AssignFile(F, FileName);
  try
    Reset(F);
    try
      LineNum := 0;
      while not EOF(F) do
      begin
        Inc(LineNum);
        ReadLn(F, Line);
        Line := Trim(Line);
        if (Line = '') or (Line[1] = '#') then
          Continue;  { Skip blank lines and comments }

        SepPos := Pos('=', Line);
        if SepPos = 0 then
        begin
          WriteLn(Format('Warning: line %d has no "=" separator. Skipping.', [LineNum]));
          Continue;
        end;

        Key := Trim(Copy(Line, 1, SepPos - 1));
        Value := Trim(Copy(Line, SepPos + 1, Length(Line)));

        try
          ApplyConfigValue(Key, Value);
        except
          on E: EConvertError do
            WriteLn(Format('Warning: invalid value for "%s" on line %d: %s',
              [Key, LineNum, E.Message]));
          on E: ERangeError do
            WriteLn(Format('Warning: value out of range for "%s" on line %d: %s',
              [Key, LineNum, E.Message]));
        end;
      end;
    finally
      CloseFile(F);
    end;
  except
    on E: EInOutError do
    begin
      WriteLn('Error reading configuration: ', E.Message);
      WriteLn('Using default settings.');
      LoadDefaults;
    end;
  end;
end;

This procedure demonstrates professional-grade error handling: - File-level errors (cannot open) fall back to defaults. - Line-level format issues (no separator) are warned and skipped. - Value-level conversion errors are caught per-key, so one bad setting does not prevent loading the others. - The file is always closed via try..finally.

Choosing the Right Nesting

The rule of thumb is:

  • Outer try..finally for cleanup, inner try..except for handling: Use this when you want cleanup to happen unconditionally and error handling to happen only for specific operations.
  • Outer try..except for handling, inner try..finally for cleanup: Use this when you want a single exception handler for the entire block and guaranteed cleanup for resources.

Both patterns are common and correct. Choose the one that best matches your intent.


The try..except..else Construct

Free Pascal also supports an else clause in try..except blocks. The else clause catches any exception not matched by a preceding on clause:

try
  ProcessFile(FileName);
except
  on E: EInOutError do
    WriteLn('File error: ', E.Message);
  on E: EConvertError do
    WriteLn('Data error: ', E.Message);
else
  WriteLn('An unexpected error occurred.');
end;

The else clause is equivalent to a bare except after all on clauses. It catches anything not handled above. However, it does not give you access to the exception object. For this reason, many programmers prefer the explicit on E: Exception do catch-all over the else clause — it provides the same catch-all behavior while still giving access to E.Message and E.ClassName.


19.6 Raising Exceptions

You can raise exceptions yourself using the raise keyword. This is how you signal error conditions in your own code:

procedure SetAge(AAge: Integer);
begin
  if (AAge < 0) or (AAge > 150) then
    raise Exception.Create('Age must be between 0 and 150');
  FAge := AAge;
end;

When raise executes, the exception object is created and thrown. Execution immediately leaves the current procedure and searches for a handler.

Raising Specific Exception Types

It is better practice to raise specific exception types rather than the generic Exception:

procedure SetAge(AAge: Integer);
begin
  if AAge < 0 then
    raise ERangeError.Create('Age cannot be negative');
  if AAge > 150 then
    raise ERangeError.Create('Age cannot exceed 150');
  FAge := AAge;
end;

This lets callers catch ERangeError specifically without catching unrelated exceptions.

Re-Raising Exceptions

Re-raising is one of the most useful exception handling techniques. It lets you perform partial handling — logging, updating state, notifying the user — while still passing the exception to a higher-level handler that can make a broader recovery decision.

Inside an except block, you can re-raise the current exception by using raise with no arguments:

try
  ProcessFile(FileName);
except
  on E: EInOutError do
  begin
    WriteLn('Logging error: ', E.Message);
    raise;  { Re-raise the same exception }
  end;
end;

This is useful for logging or partial handling: you note the error but then pass it up to a higher-level handler that can make a broader decision about recovery.

Re-raise vs. raise new: There is a critical difference between raise; (re-raise) and raise Exception.Create(...) (raise new). Re-raising preserves the original exception object, including its class type and any additional properties. Raising a new exception creates a fresh object and frees the original. Choose re-raise when you want higher-level handlers to see the exact original error. Choose raise-new when you want to wrap a low-level exception in a higher-level one:

try
  LoadFromDatabase;
except
  on E: ESQLError do
  begin
    LogError('Database load failed: ' + E.Message);
    { Option A: re-raise (preserves ESQLError type) }
    raise;
    { Option B: wrap (converts to application exception) }
    raise EPennyWiseError.CreateFmt('Cannot load expenses: %s', [E.Message]);
  end;
end;

In practice, use Option A (re-raise) when the caller knows about database errors. Use Option B (wrap) when the caller should not know about the database layer — it should only see application-level errors. The wrapping pattern is how you enforce architectural boundaries: the UI layer never sees ESQLError, only EPennyWiseError.

Raising with Format

A common pattern is creating exception messages with Format:

raise Exception.CreateFmt('Cannot process file "%s": %s',
  [FileName, Reason]);

The CreateFmt constructor works like Format — it accepts a format string and an array of arguments, producing a readable error message.


19.7 Custom Exception Classes

For any moderately complex application, you should define your own exception classes. Custom exceptions make your error handling more precise: callers can catch your specific exceptions without catching unrelated errors from other libraries.

Defining Custom Exceptions

Custom exceptions are classes that descend from Exception (or from a more specific exception class):

type
  { Base exception for the PennyWise application }
  EPennyWiseError = class(Exception);

  { Specific exception types }
  EInvalidExpenseError = class(EPennyWiseError);
  EBudgetExceededError = class(EPennyWiseError);
  EDataFileCorruptError = class(EPennyWiseError);
  EDuplicateEntryError = class(EPennyWiseError);

By defining a hierarchy, you can catch errors at different levels of specificity:

try
  AddExpense(NewExpense);
except
  on E: EBudgetExceededError do
    WriteLn('Warning: budget exceeded. ', E.Message);
  on E: EInvalidExpenseError do
    WriteLn('Invalid expense data. ', E.Message);
  on E: EPennyWiseError do
    WriteLn('PennyWise error: ', E.Message);
  on E: Exception do
    WriteLn('Unexpected error: ', E.Message);
end;

The handlers are checked from most specific to most general. If the expense is invalid, EInvalidExpenseError catches it. If the budget is exceeded, EBudgetExceededError catches it. Any other PennyWise error falls to EPennyWiseError. Truly unexpected errors fall to the generic Exception handler.

Custom Exceptions with Additional Data

Custom exceptions can carry additional information beyond the message string:

type
  EBudgetExceededError = class(EPennyWiseError)
  private
    FCategory: String;
    FBudgetLimit: Currency;
    FActualAmount: Currency;
  public
    constructor Create(const ACategory: String;
      ABudgetLimit, AActualAmount: Currency);
    property Category: String read FCategory;
    property BudgetLimit: Currency read FBudgetLimit;
    property ActualAmount: Currency read FActualAmount;
  end;

constructor EBudgetExceededError.Create(const ACategory: String;
  ABudgetLimit, AActualAmount: Currency);
begin
  inherited CreateFmt('Budget exceeded for %s: limit $%.2f, actual $%.2f',
    [ACategory, ABudgetLimit, AActualAmount]);
  FCategory := ACategory;
  FBudgetLimit := ABudgetLimit;
  FActualAmount := AActualAmount;
end;

Now the handler can access structured information:

except
  on E: EBudgetExceededError do
  begin
    WriteLn('Over budget in: ', E.Category);
    WriteLn('Limit: $', E.BudgetLimit:0:2);
    WriteLn('Actual: $', E.ActualAmount:0:2);
    WriteLn('Overage: $', (E.ActualAmount - E.BudgetLimit):0:2);
  end;
end;

This is far more useful than parsing a message string. The exception object carries typed data that handlers can use directly.

Exception Classes as a Design Tool

Custom exception classes are more than error containers — they are a design tool that documents your application's failure modes. When you define EInvalidExpenseError, EBudgetExceededError, and EDataFileCorruptError, you are making an explicit statement about the three ways your application can fail. Anyone reading your exception hierarchy instantly understands the error landscape of your system.

Here is a well-designed exception hierarchy for a hypothetical banking application:

type
  { Base exception for the banking application }
  EBankError = class(Exception);

  { Account-related errors }
  EAccountError = class(EBankError);
  EAccountNotFoundError = class(EAccountError);
  EAccountLockedError = class(EAccountError);
  EInsufficientFundsError = class(EAccountError)
  private
    FRequested: Currency;
    FAvailable: Currency;
  public
    constructor Create(ARequested, AAvailable: Currency);
    property Requested: Currency read FRequested;
    property Available: Currency read FAvailable;
  end;

  { Transaction-related errors }
  ETransactionError = class(EBankError);
  EDuplicateTransactionError = class(ETransactionError);
  ETransactionLimitError = class(ETransactionError);

  { Authentication errors }
  EAuthError = class(EBankError);
  EInvalidCredentialsError = class(EAuthError);
  ESessionExpiredError = class(EAuthError);

A handler at the UI level can catch EBankError to handle all banking errors generically. A handler in the transaction processing layer can catch EAccountError to handle all account errors. A specific handler can catch EInsufficientFundsError to display the exact shortfall. The hierarchy gives you precision at every level.

This pattern transfers directly to Java (which has a nearly identical checked exception hierarchy), C# (which uses the same inheritance-based exception taxonomy), and Python (which uses exception classes in the same way). If you can design a good exception hierarchy in Pascal, you can design one anywhere.

💡 Design Tip: Define a base exception class for your application (like EPennyWiseError) and derive all your specific exceptions from it. This lets callers write a single on E: EPennyWiseError to catch all your application's errors, while still being able to catch specific ones when needed. The hierarchy should be two to three levels deep — deeper than that usually signals over-engineering.


19.8 Exception Handling Best Practices

Exception handling is powerful, but it is easy to misuse. Here are the practices that distinguish robust code from fragile code with hidden bugs.

1. Do Not Catch Exceptions You Cannot Handle

The worst thing you can do with exceptions is catch them and do nothing:

{ NEVER DO THIS }
try
  ProcessFile(FileName);
except
  { Silently ignore all errors }
end;

This is called "swallowing exceptions" and it creates bugs that are almost impossible to find. The file was not processed, but the program continues as if it was. Data is silently lost or corrupted. The user sees no error message. When the problem surfaces later, it is far from the original cause.

Rule: If you catch an exception, do something meaningful — log it, display it, recover from it, or re-raise it. Never silently ignore it.

Here is what the "swallowing exceptions" antipattern looks like in a real-world scenario. Rosa adds an auto-save feature to PennyWise that saves every 5 minutes. A developer writes:

procedure AutoSave;
begin
  try
    SaveExpenses('data.csv');
  except
    { Ignore — auto-save is optional }
  end;
end;

This looks harmless. But when the disk fills up, auto-save silently fails. Rosa works for two hours, then her computer crashes. She restarts and discovers that the last two hours of work are gone — the auto-save was failing silently the entire time. If the exception had been logged or displayed, she would have known about the disk space problem immediately and could have freed space.

The correct version:

procedure AutoSave;
begin
  try
    SaveExpenses('data.csv');
  except
    on E: EInOutError do
    begin
      WriteLn('Warning: Auto-save failed: ', E.Message);
      WriteLn('Please save manually and check disk space.');
    end;
  end;
end;

Now Rosa sees the warning immediately and takes action.

2. Catch Specific Exceptions, Not Generic Ones

{ Avoid this — too broad }
try
  Amount := StrToFloat(Input);
  SaveToFile(Amount);
except
  on E: Exception do
    WriteLn('Error: ', E.Message);
end;

{ Better — specific handlers for specific errors }
try
  Amount := StrToFloat(Input);
  SaveToFile(Amount);
except
  on E: EConvertError do
    WriteLn('Invalid number format. Please try again.');
  on E: EInOutError do
    WriteLn('Could not save file: ', E.Message);
end;

Catching specific exceptions lets you provide meaningful error messages and appropriate recovery for each failure mode.

3. Use try..finally for All Resource Cleanup

Every resource acquisition (opening a file, creating an object, acquiring a lock) should be immediately followed by a try..finally block that releases the resource:

Stream := TFileStream.Create(FileName, fmCreate);
try
  { Use the stream }
finally
  Stream.Free;
end;

This is non-negotiable. Without try..finally, any exception in the middle of your code causes a resource leak.

4. Provide Meaningful Error Messages

{ Bad: tells the user nothing useful }
raise Exception.Create('Error');

{ Good: tells the user what happened and what to do }
raise Exception.CreateFmt(
  'Cannot open file "%s". Check that the file exists and you have read permission.',
  [FileName]);

Good error messages answer three questions: What happened? Why? What can the user do about it?

5. Do Not Use Exceptions for Normal Flow Control

Exceptions should represent exceptional conditions — errors that are unusual and unexpected. Do not use them as a substitute for if statements:

{ BAD: Using exceptions for flow control }
try
  Index := List.IndexOf(Item);
  raise Exception.Create('');  { Always raises! }
except
  on E: Exception do
    WriteLn('Item not found');
end;

{ GOOD: Use a conditional check }
Index := List.IndexOf(Item);
if Index < 0 then
  WriteLn('Item not found')
else
  ProcessItem(List[Index]);

Exceptions have overhead — creating exception objects, unwinding the stack, searching for handlers. For conditions you expect to happen routinely, use normal control flow.

6. Clean Up Before Re-Raising

If you need to do partial cleanup before passing an exception to a higher handler, use try..finally or re-raise after cleanup:

procedure ProcessBatch(const Files: array of String);
var
  i, SuccessCount: Integer;
begin
  SuccessCount := 0;
  for i := Low(Files) to High(Files) do
  begin
    try
      ProcessFile(Files[i]);
      Inc(SuccessCount);
    except
      on E: EInOutError do
      begin
        WriteLn('Skipping ', Files[i], ': ', E.Message);
        { Do not re-raise — continue with next file }
      end;
      on E: Exception do
      begin
        WriteLn('Fatal error processing ', Files[i]);
        raise;  { Re-raise for non-file errors }
      end;
    end;
  end;
  WriteLn('Processed ', SuccessCount, ' of ', Length(Files), ' files.');
end;

This procedure handles file errors gracefully (skip and continue) but re-raises unexpected errors (which might indicate a serious problem).

7. Document Which Exceptions Your Code Raises

In Pascal, unlike Java, there is no throws clause that forces callers to handle exceptions. This means it is your responsibility to document which exceptions your procedures can raise:

{ Raises:
    EInvalidExpenseError — if Amount <= 0 or Category is empty
    EBudgetExceededError — if adding this expense exceeds the budget
    EInOutError — if the auto-save file cannot be written }
procedure AddExpense(AExpense: TExpense);

Good documentation turns runtime surprises into design-time decisions.

Common Anti-Patterns to Avoid

Beyond the practices above, here are specific anti-patterns that appear frequently in beginner code and should be recognized and corrected.

Anti-Pattern: Catch-and-return-false.

{ BAD: Hides the reason for failure }
function TryLoadFile(const FileName: String): Boolean;
begin
  try
    LoadFile(FileName);
    Result := True;
  except
    Result := False;  { What went wrong? Nobody knows. }
  end;
end;

The caller gets False but has no idea why — was the file missing? Was it corrupt? Was access denied? Each cause requires a different response, but the function has erased the information. Either let the exception propagate, or return a structured error (like a TResult record with an error message).

Anti-Pattern: Exception as control flow.

{ BAD: Using exceptions for expected situations }
function GetUserAge: Integer;
begin
  repeat
    try
      Write('Age: ');
      ReadLn(Input);
      Result := StrToInt(Input);
      Break;  { Success — exit loop }
    except
      WriteLn('Invalid.');
    end;
  until False;
end;

This works but is unnecessarily slow. A user typing "abc" is not exceptional — it is expected. The correct approach uses TryStrToInt, which returns a Boolean without raising an exception:

{ GOOD: No exceptions for expected input errors }
function GetUserAge: Integer;
var
  Input: String;
begin
  repeat
    Write('Age: ');
    ReadLn(Input);
    if TryStrToInt(Input, Result) and (Result > 0) and (Result < 150) then
      Exit;
    WriteLn('Please enter a valid age (1-149).');
  until False;
end;

Anti-Pattern: Catch-log-swallow.

{ BAD: Logs the error but then hides it }
try
  ProcessImportantData;
except
  on E: Exception do
    WriteLn('Error logged: ', E.Message);
    { Exception is silently swallowed — caller thinks everything is fine }
end;

Logging is good; swallowing is bad. If you log, also re-raise (or take corrective action). The user or the calling code needs to know that processing failed.

8. Consider the Error-Handling Strategy at Architecture Level

In a large application, you need a consistent error-handling strategy. Here is a three-tier approach that works well:

Tier 1: Domain layer — Raise custom exceptions for business rule violations. EBudgetExceededError, EInvalidExpenseError, EDuplicateEntryError. These carry domain-specific data.

Tier 2: Service layer — Catch domain exceptions when appropriate, log them, and either handle them or re-raise wrapped versions. A service might catch EInOutError from the file system and re-raise it as EDataPersistenceError with additional context.

Tier 3: UI/Top level — Catch all remaining exceptions and present them to the user. In a console application, this is a top-level try..except around the main program. In a GUI application, this is the Application.OnException handler.

{ Top-level exception handler in a console application }
begin
  try
    RunApplication;
  except
    on E: EPennyWiseError do
    begin
      WriteLn('PennyWise Error: ', E.Message);
      WriteLn('Please check your data and try again.');
      ExitCode := 1;
    end;
    on E: Exception do
    begin
      WriteLn('Unexpected Error: [', E.ClassName, '] ', E.Message);
      WriteLn('This is likely a bug. Please report it.');
      ExitCode := 2;
    end;
  end;
end.

This tiered approach ensures that: - Domain errors get domain-specific handling - Infrastructure errors get translated into domain terms - Nothing escapes without being handled - The user always sees a meaningful message, never a raw stack trace

Exception Safety Levels

When designing functions and classes, think about the exception safety guarantee each one provides:

  1. No-throw guarantee: The function never raises an exception. Destructors should provide this guarantee — if a destructor raises an exception during stack unwinding, the program terminates abnormally.

  2. Strong guarantee: If an exception occurs, the program state is rolled back to what it was before the function was called. The "write-to-temp-then-rename" pattern provides this for file operations.

  3. Basic guarantee: If an exception occurs, no resources are leaked and no invariants are violated, but the program state may have changed. Most well-written code provides at least this guarantee.

  4. No guarantee: If an exception occurs, anything could happen — resources may leak, data may be corrupt. This is unacceptable in production code.

Aim for the strong guarantee where practical (especially for data modification operations) and the basic guarantee everywhere else.

Example of the strong guarantee: The "write-to-temp-then-rename" pattern we use in PennyWise's save procedure provides the strong guarantee. If writing to the temporary file fails, the original file is untouched — the program state is exactly as it was before SaveExpenses was called. The user can retry the save, fix the disk space issue, or continue working without data loss.

Example of the basic guarantee: A procedure that adds three expenses to a list. If the second addition raises an exception (out of memory), the first expense is already in the list. The program state has changed, but no resources are leaked and no invariants are violated — the list is consistent, just not in the state the caller expected.

Example of no guarantee: A procedure that directly overwrites a file without using a temporary. If the write fails halfway through, the file contains half old data and half new data — a corrupt state. This is unacceptable in production code and is the reason we always use the temp-then-rename pattern for critical data files.


19.9 IOResult vs. Exceptions: Comparing Approaches

In Chapter 13, we used {$I-} and IOResult for file error handling. Now we have exceptions. Which should you use? Let us compare them side by side.

The IOResult Approach

{$I-}
AssignFile(F, FileName);
Reset(F);
if IOResult <> 0 then
begin
  WriteLn('Cannot open file.');
  Exit;
end;
ReadLn(F, Header);
if IOResult <> 0 then
begin
  WriteLn('Cannot read header.');
  CloseFile(F);
  Exit;
end;
ReadLn(F, Data);
if IOResult <> 0 then
begin
  WriteLn('Cannot read data.');
  CloseFile(F);
  Exit;
end;
CloseFile(F);
{$I+}

The Exception Approach

try
  AssignFile(F, FileName);
  Reset(F);
  try
    ReadLn(F, Header);
    ReadLn(F, Data);
  finally
    CloseFile(F);
  end;
except
  on E: EInOutError do
    WriteLn('File error: ', E.Message);
end;

Comparison

Criterion IOResult Exceptions
Code clarity Error checks interleaved with logic Clean separation of logic and error handling
Forgetting to check Silently ignored Automatically terminates or propagates
Cleanup Manual, error-prone Guaranteed with try..finally
Error detail Numeric code only Object with message, class name, extra data
Performance No overhead on success Small overhead on success; more overhead when exception occurs
Composability Does not propagate through call stack Automatically propagates to nearest handler
Legacy compatibility Works in all Pascal compilers Requires SysUtils unit

When to Use Each

Use exceptions (the default recommendation) for: - All new code - Any operation where failure is unusual (file not found, conversion error) - Object-oriented codebases where classes raise errors - Any code that manages resources (files, objects, connections)

Use IOResult only for: - Legacy code maintenance where changing to exceptions would require extensive refactoring - Hot inner loops where even the small overhead of exception setup is unacceptable (extremely rare) - Code that must compile without SysUtils (embedded or minimal environments)

For PennyWise and virtually all application code, exceptions are the right choice. They produce code that is cleaner, safer, and easier to maintain.

The Hybrid Approach

In practice, many programs use both approaches. The bulk of the code uses exceptions, but specific hot spots use IOResult or Try* functions for performance-critical paths:

{ Hybrid approach: exceptions for structure, Try* for performance }
procedure ImportCSVFile(const FileName: String);
var
  F: TextFile;
  Line: String;
  Value: Currency;
  Success: Boolean;
begin
  AssignFile(F, FileName);
  try
    Reset(F);  { Exception if file not found — this is exceptional }
    try
      while not EOF(F) do
      begin
        ReadLn(F, Line);
        { Use Try* for expected parsing failures — many lines may be invalid }
        Success := TryStrToCurr(ExtractField(Line, 3), Value);
        if Success then
          ProcessValue(Value)
        else
          Inc(SkippedCount);
      end;
    finally
      CloseFile(F);
    end;
  except
    on E: EInOutError do
      raise EPennyWiseError.CreateFmt('Cannot import "%s": %s',
        [FileName, E.Message]);
  end;
end;

The file-open uses exceptions (file not found is genuinely exceptional). The per-line parsing uses TryStrToCurr (invalid values are expected and common). This hybrid gives the best of both worlds: clean structure from exceptions, performance from Try* functions.

🔗 Cross-Reference: Chapter 13 covered IOResult and {$I-}`/`{$I+} in detail. If you need to maintain legacy code that uses those techniques, refer back to that chapter. For all new code, use the exception-based patterns taught here.


19.10 Project Checkpoint: PennyWise Gets Robust

Let us apply exception handling to PennyWise. We will wrap file I/O operations in exception handlers, validate user input with exceptions, and define custom exception classes for application-specific errors.

Step 1: Define Custom Exceptions

unit PennyWise.Exceptions;

{$mode objfpc}{$H+}

interface

uses
  SysUtils;

type
  EPennyWiseError = class(Exception);
  EInvalidExpenseError = class(EPennyWiseError);
  EBudgetExceededError = class(EPennyWiseError)
  private
    FCategory: String;
    FLimit: Currency;
    FActual: Currency;
  public
    constructor Create(const ACategory: String; ALimit, AActual: Currency);
    property Category: String read FCategory;
    property Limit: Currency read FLimit;
    property Actual: Currency read FActual;
  end;
  EDataFileCorruptError = class(EPennyWiseError);

implementation

constructor EBudgetExceededError.Create(const ACategory: String;
  ALimit, AActual: Currency);
begin
  inherited CreateFmt('Budget exceeded for "%s": limit $%.2f, spent $%.2f',
    [ACategory, ALimit, AActual]);
  FCategory := ACategory;
  FLimit := ALimit;
  FActual := AActual;
end;

end.

Step 2: Safe File Loading

function LoadExpenses(const AFileName: String): Boolean;
var
  F: TextFile;
  Line: String;
  LineNum: Integer;
begin
  Result := False;
  if not FileExists(AFileName) then
  begin
    WriteLn('No saved data found. Starting fresh.');
    Exit;
  end;

  AssignFile(F, AFileName);
  try
    Reset(F);
    try
      LineNum := 0;
      while not EOF(F) do
      begin
        Inc(LineNum);
        try
          ReadLn(F, Line);
          ParseAndAddExpense(Line);
        except
          on E: EConvertError do
            WriteLn(Format('Warning: Skipping invalid data on line %d: %s',
              [LineNum, E.Message]));
          on E: EInvalidExpenseError do
            WriteLn(Format('Warning: Invalid expense on line %d: %s',
              [LineNum, E.Message]));
        end;
      end;
      Result := True;
    finally
      CloseFile(F);
    end;
  except
    on E: EInOutError do
      WriteLn('Error reading file: ', E.Message);
  end;
end;

Notice the careful design: individual line errors are caught and skipped (the file might have a few corrupt lines, but the rest is fine), while file-level I/O errors abort the entire load. This is the kind of nuanced error handling that exceptions make possible.

Step 3: Safe User Input

function ReadCurrencyInput(const APrompt: String): Currency;
var
  Input: String;
  Done: Boolean;
begin
  Result := 0;
  Done := False;
  repeat
    Write(APrompt);
    ReadLn(Input);
    try
      Result := StrToCurr(Input);
      if Result <= 0 then
        raise EInvalidExpenseError.Create('Amount must be positive.');
      Done := True;
    except
      on E: EConvertError do
        WriteLn('Invalid amount. Please enter a number (e.g., 42.50).');
      on E: EInvalidExpenseError do
        WriteLn(E.Message);
    end;
  until Done;
end;

function ReadDateInput(const APrompt: String): TDateTime;
var
  Input: String;
  Done: Boolean;
begin
  Result := 0;
  Done := False;
  repeat
    Write(APrompt);
    ReadLn(Input);
    try
      Result := StrToDate(Input);
      Done := True;
    except
      on E: EConvertError do
        WriteLn('Invalid date. Please use the format MM/DD/YYYY.');
    end;
  until Done;
end;

Step 4: Safe File Saving

procedure SaveExpenses(const AFileName: String);
var
  F: TextFile;
  TempFile: String;
begin
  { Write to a temporary file first, then rename — if writing fails,
    we do not corrupt the existing data file }
  TempFile := AFileName + '.tmp';
  AssignFile(F, TempFile);
  try
    Rewrite(F);
    try
      WriteAllExpenses(F);
    finally
      CloseFile(F);
    end;
    { If we got here, the temp file was written successfully }
    if FileExists(AFileName) then
      DeleteFile(AFileName);
    RenameFile(TempFile, AFileName);
    WriteLn('Data saved successfully.');
  except
    on E: EInOutError do
    begin
      WriteLn('Error saving data: ', E.Message);
      WriteLn('Your unsaved data is still in memory.');
      { Clean up the temp file if it exists }
      if FileExists(TempFile) then
        DeleteFile(TempFile);
    end;
  end;
end;

This save procedure uses the "write-to-temp-then-rename" pattern — a standard technique for crash-safe file writing. If the write fails partway through, the original file is untouched.

Testing the Error Handling

begin
  WriteLn('=== PennyWise — Chapter 19 Checkpoint ===');
  WriteLn;

  { Test 1: Load from a non-existent file }
  WriteLn('Test 1: Loading from non-existent file...');
  LoadExpenses('nonexistent.dat');
  WriteLn;

  { Test 2: Input validation }
  WriteLn('Test 2: Reading expense amount...');
  Amount := ReadCurrencyInput('Enter amount: ');
  WriteLn('You entered: $', Amount:0:2);
  WriteLn;

  { Test 3: Budget checking }
  WriteLn('Test 3: Budget check...');
  try
    CheckBudget('Dining', 100.00, 145.50);
  except
    on E: EBudgetExceededError do
    begin
      WriteLn('Budget alert!');
      WriteLn('  Category: ', E.Category);
      WriteLn('  Limit: $', E.Limit:0:2);
      WriteLn('  Spent: $', E.Actual:0:2);
    end;
  end;

  WriteLn;
  WriteLn('=== Checkpoint complete ===');
end.

Checkpoint Status: PennyWise now handles file I/O errors gracefully, validates user input with retry loops, uses custom exception classes for domain-specific errors, and employs the write-to-temp-then-rename pattern for crash-safe saving. Rosa's data is safe even when things go wrong.


What Rosa and Tomas Experience

Before exception handling, Rosa's PennyWise would crash with a cryptic "Runtime error 2" when she accidentally deleted her data file. After Chapter 19, it says: "No saved data found. Starting fresh." She never loses data again because the save procedure writes to a temporary file first and only renames it after success.

Tomas, who types fast and carelessly, used to see "Runtime error 106" every time he entered "abc" instead of a number. Now the program says "Invalid amount. Please enter a number (e.g., 42.50)" and lets him try again. He has not seen a single crash since the exception handlers went in.

These are not cosmetic improvements. They are the difference between software that a real person can use and software that a real person will abandon after three crashes. Exception handling is what separates student programs from professional programs.

Exception Handling and Performance

A common concern about exceptions is performance. Let us address this directly.

When no exception occurs, the overhead is minimal. The compiler inserts a small amount of bookkeeping code at each try block entry (setting up the stack frame for unwinding), but this is negligible in practice — typically a few nanoseconds. You should not avoid try..except blocks for performance reasons in normal code.

When an exception does occur, the cost is significant: the runtime must unwind the call stack, search for handlers, create the exception object, and possibly execute multiple finally blocks. This can take microseconds to milliseconds depending on stack depth.

The performance conclusion is clear: exceptions are fast on the happy path and slow on the error path. Since errors should be exceptional (rare), this is the right tradeoff. You pay almost nothing when things work correctly, and you pay a bit more when they fail — but when they fail, correctness matters more than speed anyway.

The one place to be cautious is tight loops where you expect frequent failures. For example, parsing a large file where 50% of lines are invalid — using try..except around each parse would be noticeably slower than using TryStrToInt (which returns a Boolean instead of raising an exception). Free Pascal provides TryStrToInt, TryStrToFloat, TryStrToDate, and other Try* functions specifically for this purpose.

{ Slow: exception for expected failures }
for i := 0 to High(Lines) do
begin
  try
    Values[i] := StrToInt(Lines[i]);
  except
    Values[i] := 0;
  end;
end;

{ Fast: no exception for expected failures }
for i := 0 to High(Lines) do
begin
  if not TryStrToInt(Lines[i], Values[i]) then
    Values[i] := 0;
end;

Use Try* functions when you expect frequent failures (parsing user input in bulk). Use try..except when failures are genuinely exceptional (file I/O, network operations, business rule violations).


19.11 Summary

This chapter transformed our approach to errors from "hope nothing goes wrong" to "handle it gracefully when something does."

try..except blocks catch exceptions and let you handle them — displaying messages, retrying operations, or taking alternative paths. Always catch specific exception types; avoid catching generic Exception unless you are writing a top-level error handler.

try..finally blocks guarantee that cleanup code executes whether or not an exception occurs. Use them for every resource acquisition: files, objects, database connections, locks. The Create-Try-Finally pattern is the standard way to manage object lifetimes in Pascal.

Raising exceptions with raise lets you signal error conditions in your own code. Prefer specific exception types over generic Exception, and provide meaningful messages that tell the user what happened and what to do.

Custom exception classes let you define application-specific error types with additional data (like the category, budget limit, and actual amount in EBudgetExceededError). Build a hierarchy with a base application exception and specific subtypes.

Best practices: Do not swallow exceptions. Do not use exceptions for normal flow control. Catch only exceptions you can handle. Document which exceptions your code raises. Log errors before re-raising.

IOResult vs. Exceptions: For new code, exceptions are almost always the right choice. They produce cleaner code, automatic propagation, and guaranteed cleanup. Use IOResult only for legacy code or highly constrained environments.

The discipline of exception handling transfers directly to every modern language: Java's try-catch-finally, Python's try-except-finally, C#'s try-catch-finally, C++'s try-catch — the syntax varies, but the concepts are identical. Learn them well in Pascal, and you learn them for every language.

In Chapter 20, we turn to generics — parameterized types that let you write code once and use it with any type. Where exceptions made our code robust against errors, generics will make our code safe against type confusion, eliminating a whole class of bugs that arise from unsafe typecasting.


Key Terms Introduced in This Chapter

Term Definition
Exception An object representing an error condition, raised to interrupt normal execution
try..except A block that catches exceptions and executes error-handling code
try..finally A block that guarantees cleanup code executes regardless of exceptions
raise Keyword to create and throw an exception
Exception class A class descending from Exception that represents a specific type of error
EConvertError Exception raised when string-to-number/date conversion fails
EInOutError Exception raised when file I/O operations fail
ERangeError Exception raised when a value exceeds its type's range
EDivByZero Exception raised on integer division by zero
EAccessViolation Exception raised on invalid memory access (often nil pointers)
Re-raising Using raise with no arguments inside an except block to pass the exception upward
Swallowing exceptions Catching exceptions and ignoring them — a dangerous antipattern
Create-Try-Finally pattern Creating a resource, immediately entering try, and freeing in finally
Custom exception A user-defined exception class carrying application-specific error data