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
Prerequisites
#Before starting this chapter:
-
Complete Chapter 5 and have a
working Dart development environment with the
dartpediaproject. - 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.
Open
command_runner/lib/src/arguments.dart.-
Define an
abstract classcalledCliElementbelow theOptionTypeenum:command_runner/lib/src/arguments.dartdartabstract 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
abstractkeyword marksCliElementas a base class that cannot be instantiated directly (CliElement()). Getters without bodies (String get name;) define required properties that every subclass must implement. ThedefaultValuegetter uses typeObject?so it can return either abool(for flags) or aString(for options that accept values). -
Update the
Optionclass to extendCliElement:command_runner/lib/src/arguments.dartdartclass 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
extendskeyword establishes an inheritance relationship whereOptionbecomes a subtype ofCliElement. In Dart, every field has an implicit getter. Declaring@override final String name;satisfies the abstract getterString get name;declared inCliElement. Theabbrandtypefields remain specific toOption.
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.
-
Add required imports to the top of
command_runner/lib/src/arguments.dart:command_runner/lib/src/arguments.dartdartimport 'dart:async'; import 'dart:collection'; import 'command_runner_base.dart'; -
Start by defining the core
Commandabstract class with its properties and runner reference:command_runner/lib/src/arguments.dartdartabstract 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: EstablishesCommandas an abstract subtype ofCliElement, providing a template for all specific commands to follow.late CommandRunner runner;: A command needs a reference to theCommandRunnerexecuting it, so it can access global runner state. Thelatekeyword promises Dart that this non-nullable variable is assigned before reading it (when registered withcommand.runner = this;).
-
Next, add encapsulated option storage and helper methods to
Command:command_runner/lib/src/arguments.dartdartabstract 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_optionswith an underscore (_) makes it library-private, preventing code outsidearguments.dartfrom modifying the list directly. UnmodifiableSetView: Exposes a read-only view of the command's options, ensuring callers cannot mutate internal state directly.addFlagandaddOption: Provide controlled methods to create and register validOptioninstances into the command.
- Encapsulation with
-
Finally, add the abstract
runmethod andusagegetter to completeCommand:command_runner/lib/src/arguments.dartdartabstract 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.FutureOrallows the method to return either a raw synchronous value or aFuturefor asynchronous operations (connecting back to Chapter 3).usagegetter: Formats the command's name and description for CLI help output.
-
Update the
ArgResultsclass at the bottom ofcommand_runner/lib/src/arguments.dartto referenceCommand:command_runner/lib/src/arguments.dartdartclass ArgResults { Command? command; String? commandArg; Map<Option, Object?> options = {}; // ... existing flag, hasOption, and getOption methods ... }In Chapter 5,
ArgResults.commandwas aString?beforeCommandexisted. Now thatCommandexists, changing its type toCommand?allows the command runner to store and execute the resolvedCommandobject 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.
Open
command_runner/lib/src/command_runner_base.dart.-
Replace the file contents with the following code:
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) { _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_commandsmap 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 thelatekeyword inCommand.- Null assertion operator (
!): Inresults.command!.run(results), the!asserts thatcommandis non-null because of the precedingif (results.command != null)check.
- Spread operator (
-
Open
command_runner/lib/command_runner.dartand update the exports:command_runner/lib/command_runner.dartdart/// 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, andhelp_command.dartpart of the public API of thecommand_runnerpackage.
Task 4: Create a HelpCommand
#Create a HelpCommand that extends Command and prints usage information.
Create
command_runner/lib/src/help_command.dart.-
Add the following code:
command_runner/lib/src/help_command.dartdartimport '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
HelpCommandconstructor calls inherited helper methodsaddFlagandaddOptionto configure its supported options. Itsrunmethod readsrunner.usageand iterates overrunner.commandsto 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.
Open
cli/bin/cli.dart.-
Replace the file contents with the following code:
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); }The cascade notation
..addCommand(...)callsaddCommandon the newly constructedCommandRunnerand returns that runner instance, enabling concise method chaining before passingargumentstorun().
Task 6: Run the application
#Test the CommandRunner and HelpCommand.
-
From the
clidirectory, run:bashdart run bin/cli.dart helpThe console outputs:
bashUsage: dart bin/cli.dart <command> [commandArg?] [...options?] help: Prints usage information to the command line.This confirms that
CommandRunnerdispatches toHelpCommandand 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 / 3Option class, what is the purpose of the @override annotation?
-
To provide a specific implementation for a method or property defined in a parent class.
That's right!
@overrideindicates that you're providing a concrete implementation for an abstract member or replacing an inherited implementation. -
To create a new method that doesn't exist in the parent class.
Not quite.
@overridedoesn't create new methods. New methods are created simply by defining them in the class. -
To indicate that a method is optional.
Not quite.
@overridedoesn't affect whether methods are optional. Optional parameters use different syntax entirely. -
To make a property private.
Not quite.
Privacy is indicated by a leading underscore (
_), not by annotations.@overrideserves a different purpose related to inheritance.
abstract class and a regular class in Dart?
-
An
abstractclass 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.
-
An
abstractclass can't have any methods.Not quite.
Abstract classes can have both abstract methods (without implementation) and concrete methods (with implementation).
-
An
abstractclass can only have private methods.Not quite.
Abstract classes can have methods with any visibility level. The
abstractkeyword doesn't restrict visibility. -
There is no difference between an
abstractclass and a regular class.Not quite.
There is a significant difference. Try writing
var x = MyAbstractClass();and see what happens.
Command class expose options through an UnmodifiableSetView instead of allowing direct access to _options?
-
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.
-
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.
-
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.
-
To automatically sort the options alphabetically.
Not quite.
UnmodifiableSetViewdoes 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.
Unless stated otherwise, the documentation on this site reflects Dart 3.13.3. Page last updated on 2026-09-15. View source or report an issue.