Object-oriented Dart programming
In this chapter, you'll explore the power of object-oriented programming (OOP) in Dart. You'll learn how to create classes and define relationships between them, including inheritance and abstract classes. You'll also build a foundation for creating well-structured CLI applications.
Prerequisites
#Before you begin this chapter, ensure you:
- Have completed Chapter 4 and have a working Dart development environment with the
dartpedia
project. - Are familiar with basic programming concepts like variables, functions, and control flow.
- Understand the concepts of packages and libraries in Dart.
Tasks
#A command-line interface (CLI) is defined by the commands, options, and arguments a user can type into their terminal.
By the end of this lesson, you will have built a framework that can understand a command like this:
$ dartpedia help --verbose --command=search
Here is a breakdown of each part:
dartpedia
: This is the executable, the name of your application.help
: This is a command, an action you want the application to perform.--verbose
: This is a flag (a type of option that doesn't take a value), which modifies the command's behavior.--command=search
: This is an option that takes a value. Here, theoption
is namedcommand
, and its value issearch
.
The classes and logic you build in the following tasks create the foundation for parsing and executing commands just like this one.
Task 1: Define the argument hierarchy
#First, you'll define an Argument
class, an Option
class, and a Command
class, establishing an inheritance relationship.
Create the file
command_runner/lib/src/arguments.dart
. This file will contain the definitions for yourArgument
,Option
,Command
, andArgResults
classes.Define an
enum
calledOptionType
.command_runner/lib/src/arguments.dartdartenum OptionType { flag, option }
This
enum
represents the type of option, which can be either aflag
(a boolean option) or a regularoption
(an option that takes a value). Enums are useful for representing a fixed set of possible values.Define an
abstract class
calledArgument
.Start by defining the basic structure of your
Argument
class. You'll declare it asabstract
, which means it serves as a blueprint that other classes can extend, but it cannot be instantiated on its own.Below the
enum
you just added, paste in the following code:command_runner/lib/src/arguments.dartdart// Paste this new class below the enum you added abstract class Argument { 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 String // NB: flags are just Option objects that don't take arguments Object? get defaultValue; String? get valueHelp; String get usage; }
name
is aString
that uniquely identifies the argument.help
is an optionalString
that provides a description.defaultValue
is of typeObject?
because it can be abool
(for flags) or aString
.valueHelp
is an optionalString
to give a hint about the expected value.- The
usage
getter will provide a string showing how to use the argument.
With the
Argument
class fully defined, you have a common interface for all types of command-line arguments. Next, you'll build upon this by definingOption
, a specific type of argument that extendsArgument
.Define a class called
Option
thatextends
Argument
.The
Option
class will represent command-line options like--verbose
or--output=file.txt
. It will inherit from yourArgument
class.Add the following
Option
class to the bottom of your file:command_runner/lib/src/arguments.dartdartclass Option extends Argument { 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 the inheritance relationship. The constructor uses@override
to provide concrete implementations for the properties defined inArgument
. It also addstype
(using theOptionType
enum) and an optionalabbr
for a short-form of the option. Theusage
getter is implemented to provide clear instructions to the user.With
Option
complete, you have a specialized type of argument. Next, you'll define theCommand
class, another type of argument that will represent the main actions a user can perform in your CLI application.Define an
abstract class
calledCommand
that alsoextends
Argument
.The
Command
class will represent an executable action. Since it provides a template for other commands to follow, you'll declare it asabstract
.command_runner/lib/src/arguments.dartdart// Add this class below the Option class abstract class Command extends Argument { // Properties and methods will go here }
The
abstract
keyword means thatCommand
cannot be instantiated directly. It serves as a blueprint for other classes.Now, add the core properties. A command needs a
name
anddescription
. It also needs a reference back to theCommandRunner
that executes it.command_runner/lib/src/arguments.dartdartabstract class Command extends Argument { @override String get name; String get description; bool get requiresArgument => false; late CommandRunner runner; @override String? help; @override String? defaultValue; @override String? valueHelp; }
The
runner
property is of typeCommandRunner
, which you will define later incommand_runner_base.dart
. To make Dart aware of this class, you must import its defining file. Add the following import to the top ofcommand_runner/lib/src/arguments.dart
:dartimport '../command_runner.dart';
Next, to give commands their own set of options, you'll use a private list and expose a read-only, unmodifiable view of it. This uses the
UnmodifiableSetView
class, which is part of Dart's core collection library. To use it, you must import that library.Update the imports at the top of your file to include
dart:collection
:dartimport 'dart:collection'; // New import import '../command_runner.dart';
Now, add the
options
list and getter to yourCommand
class:dartabstract class Command extends Argument { // ... existing properties ... @override String? valueHelp; // Add the following lines to the bottom of your Command class: final List<Option> _options = []; UnmodifiableSetView<Option> get options => UnmodifiableSetView(_options.toSet()); }
To make adding options easier, we'll provide two helper methods,
addFlag
andaddOption
.dartabstract class Command extends Argument { // ... existing properties and getters ... UnmodifiableSetView<Option> get options => UnmodifiableSetView(_options.toSet()); // Add the following lines to the bottom of your Command class: // A flag is an [Option] that's treated as a boolean. 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, ), ); } // An option is an [Option] that takes a value. 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, ), ); } }
Finally, every command must have logic to execute. The
run
method should be flexible, allowing it to return a value either immediately (synchronously) or after a delay (asynchronously). TheFutureOr<Object?>
type from Dart'sasync
library serves this purpose. This means the method must return a value (of any type ornull
) either synchronously or asynchronously. This is your final required import.Update the imports at the top of your file to include
dart:async
:dartimport 'dart:async'; // New import import 'dart:collection'; import '../command_runner.dart';
Now you can add the abstract
run
method and provide theusage
implementation to complete theCommand
class.dartabstract class Command extends Argument { // ... existing properties, getters, and methods ... 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, ), ); } // Add the following lines to the bottom of your Command class: FutureOr<Object?> run(ArgResults args); @override String get usage { return '$name: $description'; } }
run(ArgResults args)
: This abstract method is where a command's logic resides. Concrete subclasses must implement it.usage
: This getter provides a simple usage string, combining the command'sname
anddescription
.
The
Command
class now provides a robust foundation for all commands in your CLI app. With the class hierarchy in place, you're ready to defineArgResults
to hold the parsed input.Define a class called
ArgResults
.command_runner/lib/src/arguments.dartdart// Add this class to the end of the file class ArgResults { Command? command; String? commandArg; Map<Option, Object?> options = {}; // Returns true if the flag exists. bool flag(String name) { // Only check flags, because we're sure that flags are booleans. for (var option in options.keys.where( (option) => option.type == OptionType.flag, )) { if (option.name == name) { return options[option] as bool; } } return false; } bool hasOption(String name) { return options.keys.any((option) => option.name == name); } ({Option option, Object? input}) getOption(String name) { var mapEntry = options.entries.firstWhere( (entry) => entry.key.name == name || entry.key.abbr == name, ); return (option: mapEntry.key, input: mapEntry.value); } }
This class represents the results of parsing command-line arguments. It holds the detected command, any argument to that command, and a map of options. It also provides convenient helper methods like
flag
,hasOption
, andgetOption
.Now you have defined the basic structure for handling commands, arguments, and options in your command-line application.
Task 2: Update the CommandRunner class
#Next, update the CommandRunner
class to use the new Argument
hierarchy.
Open the
command_runner/lib/src/command_runner_base.dart
file.Replace the existing
CommandRunner
class with the following:command_runner/lib/src/command_runner_base.dartdartimport '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) { // TODO: handle error (Command's can't have names that conflict) _commands[command.name] = command; command.runner = this; } ArgResults parse(List<String> input) { var results = ArgResults(); results.command = _commands[input.first]; return results; } // Returns usage for the executable only. // Should be overridden if you aren't using [HelpCommand] // or another means of printing usage. String get usage { final exeFile = Platform.script.path.split('/').last; return 'Usage: dart bin/$exeFile <command> [commandArg?] [...options?]'; } }
This updated
CommandRunner
class now uses theCommand
class from theArgument
hierarchy. It includes methods for adding commands, parsing arguments, and running the appropriate command based on user input.Open
command_runner/lib/command_runner.dart
, and add the following exports:command_runner/lib/command_runner.dartdart/// 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'; // TODO: Export any libraries intended for clients of this package.
This makes the
arguments.dart
,command_runner_base.dart
, andhelp_command.dart
files available to other packages that depend oncommand_runner
.
Task 3: Create a HelpCommand
#Create a HelpCommand
that extends the Command
class and prints usage information.
Create the file
command_runner/lib/src/help_command.dart
.Add the following code to
command_runner/lib/src/help_command.dart
:command_runner/lib/src/help_command.dartdartimport 'dart:async'; import 'arguments.dart'; // Prints program and argument usage. // // When given a command as an argument, it prints the usage of // that command only, including its options and other details. // When the flag 'verbose' is set, it prints options and details for all commands. // // This command isn't automatically added to CommandRunner instances. // Packages users should add it themselves with [CommandRunner.addCommand], // or create their own command that prints usage. class HelpCommand extends Command { HelpCommand() { addFlag( 'verbose', abbr: 'v', help: 'When true, this command will print each command and its options.', ); addOption( 'command', abbr: 'c', help: "When a command is passed as an argument, prints only that command's verbose usage.", ); } @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; } }
This
HelpCommand
class extendsCommand
and implements therun
method to print usage information. It also uses theaddFlag
andaddOption
methods to define its own options for controlling the output.
Task 4: Update cli.dart to use the new CommandRunner
#Modify cli/bin/cli.dart
to use the new CommandRunner
and HelpCommand
.
Open the
cli/bin/cli.dart
file.Replace the existing code with the following:
cli/bin/cli.dartdartimport 'package:command_runner/command_runner.dart'; const version = '0.0.1'; void main(List<String> arguments) { var commandRunner = CommandRunner()..addCommand(HelpCommand()); commandRunner.run(arguments); }
This code creates a
CommandRunner
instance, adds theHelpCommand
to it using method cascading (..addCommand
), and then runs the command runner with the command-line arguments.
Task 5: Run the application
#Test the new CommandRunner
and HelpCommand
.
Open your terminal and navigate to the
cli
directory.Run the command
dart run bin/cli.dart help
.You should see the usage information printed to the console:
bashUsage: dart bin/cli.dart <command> [commandArg?] [...options?] help: Prints usage information to the command line.
This confirms that the
CommandRunner
andHelpCommand
are working correctly.
Review
#In this lesson, you learned about:
- Defining
abstract
classes to create a type hierarchy. - Implementing inheritance using the
extends
keyword. - Defining and using
enum
types to represent a fixed set of values. - Building a basic command-line argument parsing framework using OOP principles.
Quiz
#Question 1: In the Option
class, what is the purpose of the @override
notation?
- A) To create a new method that doesn't exist in the parent class.
- B) To indicate that a method is optional.
- C) To provide a specific implementation for a method or property defined in a parent class.
- D) To make a property private.
Question 2: What is the difference between an abstract
class and a regular class in Dart?
- A) An
abstract
class cannot have any methods. - B) An
abstract
class cannot be instantiated directly. - C) An
abstract
class can only have private methods. - D) There is no difference between an
abstract
class and a regular class.
Question 3: In the guide, what was the enum OptionType
used for?
- A) To define the different types of commands.
- B) To represent a fixed set of option types:
flag
andoption
. - C) To store the default values for options.
- D) To control the color of the command-line output.
Next lesson
#In the next lesson, you'll learn how to handle errors and exceptions in your Dart code. You'll create a custom exception class and add error handling to your CommandRunner
to make your application more robust.
Unless stated otherwise, the documentation on this site reflects Dart 3.8.1. Page last updated on 2025-07-08. View source or report an issue.