Skip to main content

Handle errors gracefully

Improve app robustness by handling errors. Learn about exceptions, errors, `try/catch`, `throw`, and `rethrow`.

In this chapter, make your application more robust by handling errors gracefully. Explore exceptions, try/catch blocks, and how to create custom exceptions to manage errors in a structured way.

What you'll accomplish

Distinguish errors and exceptions
Use try/catch to handle failures
Create and throw custom exceptions

Prerequisites

#

Before you begin this chapter, ensure you:

  • Have completed Chapter 6 and have a working Dart development environment with the dartpedia project.
  • Understand basic programming concepts like functions and classes.

Errors versus exceptions

#

Dart distinguishes between two main types of failures: errors and exceptions.

Concept Exception Error
What it indicates An expected runtime failure that code can recover from. A programming bug or flaw in the code.
Common examples FormatException, HttpException, SocketException. RangeError, TypeError, StateError.
Typical causes Invalid user input, network failure, missing file. Off-by-one index, calling a method on null.
How to handle Catch and handle gracefully (for example, with try/catch). Do not catch; fix the bug in your code.

Exceptions

#

Exceptions represent conditions that you can anticipate and recover from. For example, when a user enters an unrecognized command-line argument, your application shouldn't crash with a raw stack trace. Instead, your code catches the exception, displays a friendly error message, and prompts the user with proper usage instructions.

In Dart, custom exceptions typically implement the Exception class or extend an existing exception type like FormatException.

Errors

#

Errors represent bugs in your logic that should be fixed during development rather than handled at runtime. For example, accessing an element beyond the bounds of a list throws a RangeError. Attempting to catch and suppress a RangeError hides the bug and can leave your application in an unpredictable state. Instead, let errors propagate so you can inspect the stack trace and fix the underlying mistake.

In Dart, classes that represent bugs extend or implement Error.

Tasks

#

The following tasks apply these principles to the command_runner package. You define a custom exception class for invalid user arguments, use try/catch blocks to intercept failures, and configure CommandRunner to handle exceptions gracefully while letting unexpected errors propagate.

Task 1: Create a custom ArgumentException

#

First, define a custom exception class called ArgumentException to represent errors related to command-line arguments.

  1. Create the file command_runner/lib/src/exceptions.dart. This file contains the definition for your ArgumentException class.

  2. Define a class called ArgumentException that extends FormatException.

    command_runner/lib/src/exceptions.dart
    dart
    class ArgumentException extends FormatException {
      /// The command that was parsed before discovering the error.
      ///
      /// This will be empty if the error was on the root parser.
      final String? command;
    
      /// The name of the argument that was being parsed when the error was
      /// discovered.
      final String? argumentName;
    
      ArgumentException(
        super.message, [
        this.command,
        this.argumentName,
        super.source,
        super.offset,
      ]);
    
      @override
      String toString() {
        return 'ArgumentException: $message';
      }
    }
    

    This class extends FormatException, which is a built-in Dart class that implements Exception. Invalid command-line arguments are an expected condition that callers can anticipate and handle gracefully, so ArgumentException is designed as an exception rather than an Error. It includes additional properties to store the command and argument name associated with the error. This provides more context when handling the exception.

    • command: The command that was being processed when the exception occurred.
    • argumentName: The name of the argument that caused the exception.

Task 2: Implement error handling in CommandRunner

#

Next, update the CommandRunner class to handle potential errors gracefully. This involves adding an error-handling callback, using try/catch to manage exceptions, and throwing your new ArgumentException when the user provides bad input.

  1. Add the necessary imports.

    In command_runner/lib/src/command_runner_base.dart, add imports for dart:async (to use FutureOr) and your new exceptions.dart file.

    dart
    import 'dart:async'; // Add this line
    import 'dart:collection';
    import 'dart:io';
    
    import 'arguments.dart';
    import 'exceptions.dart'; // Add this line
    
  2. Add an onError callback to the CommandRunner.

    Modify the CommandRunner to accept an optional onError function in its constructor. This allows users of your package to define their own error-handling logic.

    dart
    class CommandRunner {
      // Add a constructor that accepts the optional callback.
      CommandRunner({this.onError});
    
      final Map<String, Command> _commands = <String, Command>{};
    
      UnmodifiableSetView<Command> get commands =>
          UnmodifiableSetView<Command>(<Command>{..._commands.values});
    
      // Define the onError property.
      FutureOr<void> Function(Object)? onError;
    
      // The rest of the class implementation...
    }
    

    This change introduces a nullable onError property. The FutureOr<void> Function(Object)? type means it's a function that takes an Object and returns a Future or nothing, and it might be null.

  3. Update the run method to use try/catch.

    Wrap the logic inside the run method in a try/catch block. If an exception occurs, this block catches it and either passes it to the onError callback or rethrows it if no callback is provided. rethrow preserves the original error and stack trace.

    dart
    Future<void> run(List<String> input) async {
      // [Step 6 update] try/catch added
      try {
        final ArgResults results = parse(input);
        if (results.command != null) {
          Object? output = await results.command!.run(results);
          print(output.toString());
        }
      } on Exception catch (exception) {
        if (onError != null) {
          onError!(exception);
        } else {
          rethrow;
        }
      }
    }
    

    Notice the use of on Exception catch (exception). In Dart, a bare catch (e) intercepts all thrown objects, including Error instances. Specifying on Exception ensures that your code catches only recoverable exceptions, allowing bugs to propagate uncaught so you can fix them.

  4. Add validation to the parse method.

    Finally, replace the existing parse method in command_runner_base.dart with the following updated version. It includes checks that throw your custom ArgumentException whenever it detects invalid user input.

    dart
    // [Step 6 update] This method is replaced entirely.
    ArgResults parse(List<String> input) {
      ArgResults results = ArgResults();
      if (input.isEmpty) return results;
    
      // Throw an exception if the command is not recognized.
      if (_commands.containsKey(input.first)) {
        results.command = _commands[input.first];
        input = input.sublist(1);
      } else {
        throw ArgumentException(
          'The first word of input must be a command.',
          null,
          input.first,
        );
      }
    
      // Throw an exception if multiple commands are provided.
      if (results.command != null &&
          input.isNotEmpty &&
          _commands.containsKey(input.first)) {
        throw ArgumentException(
          'Input can only contain one command. Got ${input.first} and ${results.command!.name}',
          null,
          input.first,
        );
      }
    
      // Section: Handle options, including flags.
      Map<Option, Object?> inputOptions = {};
      int i = 0;
      while (i < input.length) {
        if (input[i].startsWith('-')) {
          var base = _removeDash(input[i]);
          // Throw an exception if an option is not recognized for the given command.
          var option = results.command!.options.firstWhere(
            (option) => option.name == base || option.abbr == base,
            orElse: () {
              throw ArgumentException(
                'Unknown option ${input[i]}',
                results.command!.name,
                input[i],
              );
            },
          );
    
          if (option.type == OptionType.flag) {
            inputOptions[option] = true;
            i++;
            continue;
          }
    
          if (option.type == OptionType.option) {
            // Throw an exception if an option requires an argument but none is given.
            if (i + 1 >= input.length) {
              throw ArgumentException(
                'Option ${option.name} requires an argument',
                results.command!.name,
                option.name,
              );
            }
            if (input[i + 1].startsWith('-')) {
              throw ArgumentException(
                'Option ${option.name} requires an argument, but got another option ${input[i + 1]}',
                results.command!.name,
                option.name,
              );
            }
            var arg = input[i + 1];
            inputOptions[option] = arg;
            i++;
          }
        } else {
          // Throw an exception if more than one positional argument is provided.
          if (results.commandArg != null && results.commandArg!.isNotEmpty) {
            throw ArgumentException(
              'Commands can only have up to one argument.',
              results.command!.name,
              input[i],
            );
          }
          results.commandArg = input[i];
        }
        i++;
      }
      results.options = inputOptions;
    
      return results;
    }
    
    String _removeDash(String input) {
      if (input.startsWith('--')) {
        return input.substring(2);
      }
      if (input.startsWith('-')) {
        return input.substring(1);
      }
      return input;
    }
    

    This updated parse method now actively defends against bad input. Specifically, the new throw statements handle several common error cases:

    • Unknown commands: The first if/else block ensures the first argument is a valid command.
    • Multiple commands: It checks that the user hasn't tried to run more than one command at a time.
    • Unknown options: The orElse parameter within firstWhere now throws an exception if a user provides a flag or option (like --foo) that hasn't been defined for that command.
    • Missing option values: It ensures that an option (like --output) is followed by a value and not another option or the end of the input.
    • Too many arguments: It enforces a rule that commands can only have one positional argument.

Task 3: Update cli.dart to use the new error handling

#

Modify cli/bin/cli.dart to use the new error handling in CommandRunner.

  1. Open the cli/bin/cli.dart file.

  2. Update the main function to pass in an onError function to the CommandRunner:

    cli/bin/cli.dart
    dart
    import 'package:command_runner/command_runner.dart';
    
    const version = '0.0.1';
    
    void main(List<String> arguments) {
      // [Step 6 update] Add onError method
      var commandRunner = CommandRunner(
        onError: (Object error) {
          if (error is Error) {
            throw error;
          }
          if (error is Exception) {
            print(error);
          }
        },
      )..addCommand(HelpCommand());
      commandRunner.run(arguments);
    }
    

    This code passes an onError callback to CommandRunner. The callback rethrows any Error so the application crashes, while printing any Exception to the console so the user sees the error message.

Task 4: Update command_runner library exports

#

Make ArgumentException available to the command_runner library.

  1. Open command_runner/lib/command_runner.dart, and add the following exports:

    command_runner/lib/command_runner.dart
    dart
    /// Support for doing something awesome.
    ///
    /// More dartdocs go here.
    library;
    
    export 'src/arguments.dart';
    export 'src/command_runner_base.dart';
    export 'src/help_command.dart';
    export 'src/exceptions.dart'; // Add this line
    
    // TODO: Export any libraries intended for clients of this package.
    

    In Dart packages, files inside lib/src/ are private implementation details. Exporting src/exceptions.dart from lib/command_runner.dart exposes ArgumentException as part of the package's public API so callers (like cli.dart) can import and use it.

Task 5: Test the new error handling

#

Test the new error handling by running the application with invalid arguments.

  1. Open your terminal and navigate to the cli directory.

  2. Run the command dart run bin/cli.dart invalid_command.

    You should see the following output:

    bash
    ArgumentException: The first word of input must be a command.
    

    This confirms that the ArgumentException is being thrown and caught correctly.

Review

#

What you accomplished

Here's a summary of what you built and learned in this lesson.
Distinguished errors from exceptions

You learned that subtypes of Error indicate programming bugs and aren't intended to be caught, while subtypes of Exception represent recoverable failures that your code can handle gracefully.

Used try-catch blocks to handle failures

You wrapped risky code in try-catch blocks to intercept exceptions. You used on ExceptionType catch (e) to handle specific types and rethrow to repropagate exceptions while preserving the stack trace.

Created and threw custom exceptions

You built a ArgumentException class that extends FormatException to provide context-rich error information. Then you used throw to signal validation failures with meaningful messages for better debugging and user feedback.

Quiz

#

Check your understanding

1 / 4
You're writing a function that parses user input. If the input is invalid, what's the best way to signal this to the calling code?
  1. Throw an exception describing what went wrong.

    That's right!

    Throwing an exception immediately stops the invalid code path, provides a clear error message, and forces the caller to handle the error explicitly.

  2. Return null and let the caller check for it.

    Not quite.

    Returning null forces callers to remember to check for it. If they forget, bugs can occur silently. There's a more explicit way to signal problems.

  3. Print an error message and continue execution.

    Not quite.

    Printing doesn't stop invalid data from being used. The function would still need to return something, potentially causing bugs downstream.

  4. Set a global error flag that other code can check.

    Not quite.

    Global state is error-prone and easy to forget. This approach doesn't force callers to handle the error.

What's the difference between throw and rethrow in a catch block?
  1. throw always creates a new stack trace; rethrow preserves the original stack trace.

    That's right!

    Use rethrow when you want to log or partially handle an exception but still let it propagate with its original stack trace intact for debugging.

  2. throw creates a new exception; rethrow just prints the current one.

    Not quite.

    Neither one prints anything. rethrow propagates the exception, it doesn't just display it.

  3. throw works inside catch blocks, while rethrow only works outside them.

    Not quite.

    It's closer to the opposite. rethrow only works inside catch blocks. throw works anywhere.

  4. throw is for errors; rethrow is for exceptions.

    Not quite.

    Both work with any throwable object. The difference isn't about the type of object being thrown.

Consider this code: try { riskyOperation(); } on FormatException catch (e) { print(e); }. What happens if riskyOperation() throws an HttpException?
  1. The HttpException propagates up, uncaught by this try/catch.

    That's right!

    The on clause filters by exception type. Since HttpException isn't a FormatException, it bypasses this catch block entirely.

  2. The HttpException is caught and printed.

    Not quite.

    The on FormatException clause only catches FormatException and its subtypes. HttpException is a different type.

  3. The program crashes because you can't use on with catch.

    Not quite.

    on Type catch (e) is valid syntax that filters which exceptions to catch. The issue is the type mismatch, not the syntax.

  4. The exception is silently ignored and execution continues.

    Not quite.

    Exceptions are never silently ignored in Dart. If a catch block doesn't match, the exception doesn't just disappear.

In Dart, what is the primary difference between an Error and an Exception?
  1. An Error indicates a programming bug that shouldn't be caught, while an Exception represents an expected condition that code can recover from.

    That's right!

    Subtypes of Error (such as RangeError or TypeError) indicate bugs in code that you should fix. Subtypes of Exception (such as FormatException or HttpException) represent conditions that your code can anticipate and handle gracefully.

  2. Error is thrown by the Dart runtime, while Exception can only be thrown by user code.

    Not quite.

    Both the Dart runtime and user code can throw exceptions and errors.

  3. Exception requires a try/catch block, but Error is ignored if uncaught.

    Not quite.

    Uncaught errors and uncaught exceptions both terminate execution. Neither is silently ignored.

  4. Error and Exception are identical in Dart and can be used interchangeably.

    Not quite.

    Dart distinguishes between them by design: errors indicate bugs, while exceptions represent recoverable failures.

Next lesson

#

In the next lesson, learn about advanced object-oriented features in Dart, including enhanced enums and extensions. Improve the output formatting and add color to your CLI application.