Skip to main content

Structure apps with inheritance and abstract classes

Learn about inheritance, abstract classes, method overrides, and encapsulation in Dart. Build an extensible framework for CLI apps.

This chapter builds on the classes created in the previous lesson by exploring inheritance and abstract classes in Dart. Learn how to share behavior between classes using inheritance, define contracts using abstract classes, and protect internal state with encapsulationLibrary-privateAccessibility restricted to the [library](#library) where it is defined. Learn more.

What you'll accomplish

Design and understand abstract classes
Extend parent classes and override methods
Protect internal state with encapsulation
Build an extensible command runner framework

Prerequisites

#

Before starting this chapter:

  • Complete Chapter 5 and have a working Dart development environment with the dartpedia project.
  • Understand basic object-oriented concepts in Dart, such as defining classes, constructors, fields, and getters.
  • Understand packages and libraries in Dart.

Abstract classes and inheritance

#

In Dart, an abstract class is a class that cannot be instantiated directly (calling CliElement() causes a compile-time error). Instead, it serves as a blueprint or contract that other classes extend.

In a CLI, options (--verbose) and commands (help) share common features like a name and help description, but a generic "CLI element" does not make sense on its own. Declaring CliElement and Command as abstract ensures that code only instantiates specific classes like Option and HelpCommand.

       ┌──────────────┐
       │  CliElement  │ (abstract: blueprint for all CLI elements)
       └──────┬───────┘

      ┌───────┴───────┐
      ▼               ▼
┌───────────┐   ┌───────────┐
│  Option   │   │  Command  │ (abstract: blueprint for commands)
└───────────┘   └─────┬─────┘


               ┌─────────────┐
               │ HelpCommand │ (can be instantiated and run)
               └─────────────┘

Share behavior with inheritance

#

Inheritance allows a class to adopt properties and behavior from a parent class. Because both options (Option) and commands (Command) share a name, help text, and a formatted usage message, inheriting from a common CliElement parent eliminates duplicate code across multiple classes.

Inheritance also enables polymorphism—the ability for the command runner to treat any command or option uniformly through the shared CliElement interface.

Tasks

#

In Chapter 5, the Option and ArgResults classes established basic data structures for CLI options and parsed output. This chapter establishes a shared hierarchy between commands and options, and expands the placeholder CommandRunner from Chapter 4 into a full-featured command parser.

The classes and logic in the following tasks create the foundation for parsing and executing CLI commands.

Task 1: Define the CliElement abstract class and update Option

#

Both options and commands share core attributes such as a name, help text, and a formatted usage string. Defining an abstract base class establishes a single contract for both.

  1. Open command_runner/lib/src/arguments.dart.

  2. Define an abstract class called CliElement below the OptionType enum:

    command_runner/lib/src/arguments.dart
    dart
    abstract class CliElement {
      String get name;
      String? get help;
    
      // In the case of flags, the default value is a bool.
      // In other options and commands, the default value is a String.
      // NB: flags are just Option objects that don't take arguments
      Object? get defaultValue;
      String? get valueHelp;
    
      String get usage;
    }
    

    The abstract keyword marks CliElement as a base class that cannot be instantiated directly (CliElement()). Getters without bodies (String get name;) define required properties that every subclass must implement. The defaultValue getter uses type Object? so it can return either a bool (for flags) or a String (for options that accept values).

  3. Update the Option class to extend CliElement:

    command_runner/lib/src/arguments.dart
    dart
    class Option extends CliElement {
      Option(
        this.name, {
        required this.type,
        this.help,
        this.abbr,
        this.defaultValue,
        this.valueHelp,
      });
    
      @override
      final String name;
    
      final OptionType type;
    
      @override
      final String? help;
    
      final String? abbr;
    
      @override
      final Object? defaultValue;
    
      @override
      final String? valueHelp;
    
      @override
      String get usage {
        if (abbr != null) {
          return '-$abbr,--$name: $help';
        }
    
        return '--$name: $help';
      }
    }
    

    The extends keyword establishes an inheritance relationship where Option becomes a subtype of CliElement. In Dart, every field has an implicit getter. Declaring @override final String name; satisfies the abstract getter String get name; declared in CliElement. The abbr and type fields remain specific to Option.

Task 2: Define the Command abstract class

#

Commands represent actions that users can perform, such as help or search. Because commands share properties with Option (like name and usage), they also extend CliElement.

  1. Add required imports to the top of command_runner/lib/src/arguments.dart:

    command_runner/lib/src/arguments.dart
    dart
    import 'dart:async';
    import 'dart:collection';
    import 'command_runner_base.dart';
    
  2. Start by defining the core Command abstract class with its properties and runner reference:

    command_runner/lib/src/arguments.dart
    dart
    abstract class Command extends CliElement {
      @override
      String get name;
    
      String get description;
    
      bool get requiresArgument => false;
    
      late CommandRunner runner;
    
      @override
      String? help;
    
      @override
      String? defaultValue;
    
      @override
      String? valueHelp;
    }
    
    • abstract class Command extends CliElement: Establishes Command as an abstract subtype of CliElement, providing a template for all specific commands to follow.
    • late CommandRunner runner;: A command needs a reference to the CommandRunner executing it, so it can access global runner state. The late keyword promises Dart that this non-nullable variable is assigned before reading it (when registered with command.runner = this;).
  3. Next, add encapsulated option storage and helper methods to Command:

    command_runner/lib/src/arguments.dart
    dart
    abstract class Command extends CliElement {
      // ... existing properties ...
    
      final List<Option> _options = [];
    
      UnmodifiableSetView<Option> get options =>
          UnmodifiableSetView(_options.toSet());
    
      void addFlag(
        String name, {
        String? help,
        String? abbr,
        String? valueHelp,
      }) {
        _options.add(
          Option(
            name,
            help: help,
            abbr: abbr,
            defaultValue: false,
            valueHelp: valueHelp,
            type: OptionType.flag,
          ),
        );
      }
    
      void addOption(
        String name, {
        String? help,
        String? abbr,
        String? defaultValue,
        String? valueHelp,
      }) {
        _options.add(
          Option(
            name,
            help: help,
            abbr: abbr,
            defaultValue: defaultValue,
            valueHelp: valueHelp,
            type: OptionType.option,
          ),
        );
      }
    }
    
    • Encapsulation with _options: Prefixing _options with an underscore (_) makes it library-private, preventing code outside arguments.dart from modifying the list directly.
    • UnmodifiableSetView: Exposes a read-only view of the command's options, ensuring callers cannot mutate internal state directly.
    • addFlag and addOption: Provide controlled methods to create and register valid Option instances into the command.
  4. Finally, add the abstract run method and usage getter to complete Command:

    command_runner/lib/src/arguments.dart
    dart
    abstract class Command extends CliElement {
      // ... existing properties and helper methods ...
    
      FutureOr<Object?> run(ArgResults args);
    
      @override
      String get usage {
        return '$name:  $description';
      }
    }
    
    • FutureOr<Object?> run(...): Defines the abstract method where a command's execution logic lives. FutureOr allows the method to return either a raw synchronous value or a Future for asynchronous operations (connecting back to Chapter 3).
    • usage getter: Formats the command's name and description for CLI help output.
  5. Update the ArgResults class at the bottom of command_runner/lib/src/arguments.dart to reference Command:

    command_runner/lib/src/arguments.dart
    dart
    class ArgResults {
      Command? command;
      String? commandArg;
      Map<Option, Object?> options = {};
    
      // ... existing flag, hasOption, and getOption methods ...
    }
    

    In Chapter 5, ArgResults.command was a String? before Command existed. Now that Command exists, changing its type to Command? allows the command runner to store and execute the resolved Command object directly.

Task 3: Update the CommandRunner class

#

Chapter 4 created a placeholder CommandRunner in command_runner/lib/src/command_runner_base.dart that simply printed arguments. Now, replace that placeholder with the real command coordinator.

  1. Open command_runner/lib/src/command_runner_base.dart.

  2. Replace the file contents with the following code:

    command_runner/lib/src/command_runner_base.dart
    dart
    import 'dart:collection';
    import 'dart:io';
    import 'arguments.dart';
    
    class CommandRunner {
      final Map<String, Command> _commands = <String, Command>{};
    
      UnmodifiableSetView<Command> get commands =>
          UnmodifiableSetView<Command>(<Command>{..._commands.values});
    
      Future<void> run(List<String> input) async {
        final ArgResults results = parse(input);
        if (results.command != null) {
          Object? output = await results.command!.run(results);
          print(output.toString());
        }
      }
    
      void addCommand(Command command) {
        _commands[command.name] = command;
        command.runner = this;
      }
    
      ArgResults parse(List<String> input) {
        var results = ArgResults();
        results.command = _commands[input.first];
        return results;
      }
    
      String get usage {
        final exeFile = Platform.script.path.split('/').last;
        return 'Usage: dart bin/$exeFile <command> [commandArg?] [...options?]';
      }
    }
    

    Highlights from the preceding code:

    • Spread operator (..._commands.values): Unpacks the values of the private _commands map into a new set, preventing callers from modifying the underlying map.
    • command.runner = this;: Assigns the runner instance to the command when registered, fulfilling the promise made by the late keyword in Command.
    • Null assertion operator (!): In results.command!.run(results), the ! asserts that command is non-null because of the preceding if (results.command != null) check.
  3. Open command_runner/lib/command_runner.dart and update the exports:

    command_runner/lib/command_runner.dart
    dart
    /// Support for command-line parsing and execution.
    library;
    
    export 'src/arguments.dart';
    export 'src/command_runner_base.dart';
    export 'src/help_command.dart';
    

    These export statements make arguments.dart, command_runner_base.dart, and help_command.dart part of the public API of the command_runner package.

Task 4: Create a HelpCommand

#

Create a HelpCommand that extends Command and prints usage information.

  1. Create command_runner/lib/src/help_command.dart.

  2. Add the following code:

    command_runner/lib/src/help_command.dart
    dart
    import 'dart:async';
    import 'arguments.dart';
    
    class HelpCommand extends Command {
      HelpCommand() {
        addFlag(
          'verbose',
          abbr: 'v',
          help: 'When true, prints each command and its options.',
        );
        addOption(
          'command',
          abbr: 'c',
          help: 'Prints verbose usage for the specified command.',
        );
      }
    
      @override
      String get name => 'help';
    
      @override
      String get description => 'Prints usage information to the command line.';
    
      @override
      String? get help => 'Prints this usage information';
    
      @override
      FutureOr<Object?> run(ArgResults args) async {
        var usage = runner.usage;
        for (var command in runner.commands) {
          usage += '\n ${command.usage}';
        }
    
        return usage;
      }
    }
    

    The HelpCommand constructor calls inherited helper methods addFlag and addOption to configure its supported options. Its run method reads runner.usage and iterates over runner.commands to assemble and return the complete CLI usage message dynamically.

Task 5: Update cli.dart to use CommandRunner

#

Connect CommandRunner and HelpCommand in the executable entry point.

  1. Open cli/bin/cli.dart.

  2. Replace the file contents with the following code:

    cli/bin/cli.dart
    dart
    import 'package:command_runner/command_runner.dart';
    
    const version = '0.0.1';
    
    void main(List<String> arguments) {
      var commandRunner = CommandRunner()..addCommand(HelpCommand());
      commandRunner.run(arguments);
    }
    

    The cascade notation ..addCommand(...) calls addCommand on the newly constructed CommandRunner and returns that runner instance, enabling concise method chaining before passing arguments to run().

Task 6: Run the application

#

Test the CommandRunner and HelpCommand.

  1. From the cli directory, run:

    bash
    dart run bin/cli.dart help
    

    The console outputs:

    bash
    Usage: dart bin/cli.dart <command> [commandArg?] [...options?]
     help:  Prints usage information to the command line.
    

    This confirms that CommandRunner dispatches to HelpCommand and prints the expected usage output.

Review

#

What you accomplished

A summary of the concepts and code introduced in this lesson.
Designed and understood abstract classes

Created the abstract CliElement and Command classes as base contracts that cannot be instantiated directly.

Extended parent classes and overrode methods

Used extends to create Option, Command, and HelpCommand subtypes, using @override to provide required implementations.

Protected internal state with encapsulation

Stored options in private lists (_options, _commands) and exposed them via UnmodifiableSetView to prevent unintended mutations.

Built an extensible command runner framework

Applied object-oriented principles to implement a polymorphic CLI framework capable of dispatching commands dynamically.

Quiz

#

Check your understanding

1 / 3
In the Option class, what is the purpose of the @override annotation?
  1. To provide a specific implementation for a method or property defined in a parent class.

    That's right!

    @override indicates that you're providing a concrete implementation for an abstract member or replacing an inherited implementation.

  2. To create a new method that doesn't exist in the parent class.

    Not quite.

    @override doesn't create new methods. New methods are created simply by defining them in the class.

  3. To indicate that a method is optional.

    Not quite.

    @override doesn't affect whether methods are optional. Optional parameters use different syntax entirely.

  4. To make a property private.

    Not quite.

    Privacy is indicated by a leading underscore (_), not by annotations. @override serves a different purpose related to inheritance.

What is the difference between an abstract class and a regular class in Dart?
  1. An abstract class can't be instantiated directly.

    That's right!

    Abstract classes serve as blueprints that other classes extend. You can't create instances of an abstract class directly.

  2. An abstract class can't have any methods.

    Not quite.

    Abstract classes can have both abstract methods (without implementation) and concrete methods (with implementation).

  3. An abstract class can only have private methods.

    Not quite.

    Abstract classes can have methods with any visibility level. The abstract keyword doesn't restrict visibility.

  4. There is no difference between an abstract class and a regular class.

    Not quite.

    There is a significant difference. Try writing var x = MyAbstractClass(); and see what happens.

Why does the Command class expose options through an UnmodifiableSetView instead of allowing direct access to _options?
  1. To prevent external code from modifying the command's internal list of options directly.

    That's right!

    Encapsulating the list and exposing an unmodifiable view prevents external callers from adding, removing, or reordering options without using the command's helper methods.

  2. Because Dart doesn't allow classes to contain mutable lists.

    Not quite.

    Dart classes can contain mutable lists. The unmodifiable view is an intentional design choice for encapsulation.

  3. To make the options accessible from outside the library.

    Not quite.

    Public getters already make data accessible; the unmodifiable wrapper specifically protects the collection from being mutated externally.

  4. To automatically sort the options alphabetically.

    Not quite.

    UnmodifiableSetView does not sort collections; it only prevents mutations.

Next lesson

#

The next chapter covers handling errors and exceptions in Dart. Create a custom exception class, and add error handling to CommandRunner to make the application more robust.