Code a command-line argument parser
Open in the interactive app: practice, quiz, mock interview →In one minute
- A parser turns a flat list of strings into a validated, typed result, or into one clear error. The hard part is not splitting on
=; it is deciding what each token is (flag, option, value, operand, subcommand) when the rules overlap. - Model the program as a tree of commands (Composite). Every node has the same shape: options, positionals and children. The root and a leaf differ only in what they contain, so parsing, usage text and help text recurse over one type.
- Declare the interface with a fluent builder, then freeze it. One declaration drives parsing, defaults, validation and generated help, so the help can never disagree with the parser.
- Errors are a product feature. Say which token failed, why, and what would have worked ("did you mean"). Return the error as data, and let only the outermost
mainprint it and choose the exit status. - The shape is trust but verify: argv is untrusted input from a shell, a script or a program that built the command line from user data. The same parse-then-validate pipeline reappears in HTTP query-string handling and config loading.
Requirements
Functional:
- Flags are boolean switches (
--verbose,-v) that default to false. Short flags can be clustered (-vq). - Options carry a value:
--out file,--out=file,-o file,-ofile. They have an optional default, a required marker, an optional type (string or integer) and an optional set of allowed choices. - Positional arguments are matched by position and may be required or optional.
- Subcommands form a tree (
app build,app remote add). Options declared on a parent apply to its descendants. --ends option parsing; everything after it is an operand, even if it starts with a dash.- Errors name the offending token and suggest the closest valid name when one is near.
- The same declaration generates a usage line and a help page.
Non-functional:
- Deterministic and side-effect free:
parsetakes a list of strings and returns a value or an error. It never reads the environment, exits the process or prints. - Declaration is separate from parsing, so one declared command can be parsed many times and tested without a process.
- Error messages are stable text, since scripts and tests match on them.
Out of scope: shell completion, config files and environment-variable fallbacks, repeatable options that collect lists, and mutually exclusive groups. Name them, park them in Extensibility, and move on.
Entities and relationships
Start with the nouns that hold declared facts, then the one noun that holds the outcome.
- Command is a node in the tree. It has a name, a help string, a list of option specs, an ordered list of positional specs and a list of child commands. This is the Composite: a child is a
Commandtoo. - OptionSpec has a long name, an optional single-letter short name, whether it is a flag, a default, a required marker, a kind (string or integer) and optional choices. A flag is an option with no value and an implicit default of false.
- PositionalSpec has a name and a required marker. Order matters.
- Result is the outcome: the path of subcommand names taken, and a map from name to value. It is a plain value, not an object with behaviour.
- ParseError carries the message. Parsing returns it; it does not print it.
Relationships: a command composes its option specs and positional specs (they exist only for that command) and aggregates child commands. Lookup of an option walks from the current command up through its ancestors, so a child sees its parents' options but not the reverse. A command with children has no positionals of its own, because the first operand would be ambiguous between a subcommand name and a value.
Class diagram
classDiagram
class Command {
+name: string
+help: string
-options: OptionSpec[]
-positionals: PositionalSpec[]
-children: Command[]
+flag(long, short, help) Command
+option(long, spec) Command
+positional(name, required) Command
+subcommand(cmd) Command
+parse(argv) Result
+usage(path) string
+helpText(path) string
}
class OptionSpec {
+long: string
+short: string
+isFlag: bool
+default: Value
+required: bool
+kind: str_or_int
+choices: Value[]
}
class PositionalSpec {
+name: string
+required: bool
}
class Result {
+path: string[]
+values: Map~string, Value~
}
class ParseError {
+message: string
}
Command "1" *-- "many" OptionSpec
Command "1" *-- "many" PositionalSpec
Command "1" o-- "many" Command : children
Command ..> Result : returns
Command ..> ParseError : returnsKey flows
parse is one left-to-right pass that classifies each token using only the current command and its ancestors, then a validation pass over the finished tree path. Two phases keep the logic simple: the scan decides what each token is, and the finish step applies defaults, required checks and positional assignment.
sequenceDiagram
participant M as main
participant R as root Command
participant C as child Command
M->>R: parse(["build", "--target", "prod", "-v", "core"])
R->>R: token "build": root has children, find child
R->>C: descend to build
C->>C: "--target": look up in build, then root
C->>C: take next token "prod" as the value, convert, check choices
C->>C: "-v": not on build, found on root, set true
C->>C: "core": operand, queue it
C->>C: end of input: defaults, required options, assign operands
C-->>M: Result(path=[build], values)
Note over M: on any failure, a ParseError comes back insteadThe classification rules, in the order the code applies them:
- After
--, every token is an operand. --nameor--name=valueis a long option.=splits at the first sign only, so a value may itself contain=.-abcis a short cluster. Walk the letters: a flag sets itself and the walk continues; an option takes the rest of the cluster as its value, or the next token if nothing remains, and the walk stops. This is the grouping rule POSIX describes: any number of flags, then at most one option that takes a value (POSIX Utility Syntax Guidelines).- A lone
-is an operand (by convention it means standard input). - Anything else is an operand, or the next subcommand name if the current command has children.
An option consumes the next token as its value even when that token looks like a flag. That is what makes --offset -5 work. The cost is that --out --verbose silently stores the string --verbose. Real parsers choose differently; the next sections come back to it.
Design patterns used and why
- Builder for the declaration. A command takes many optional pieces (short names, defaults, choices, help), and a constructor with ten parameters is unreadable. Chained calls (
.flag(...).option(...)) read like the help page they produce. The trade-off is real: the builder here mutates and returns itself, so a half-built command is a valid object. Where that matters, split into a mutable builder and an immutablebuild()that returns a frozen tree and validates the declaration (no duplicate names, no short name used twice, no positional after a variadic one). The practice code stays with the simple form; the interview answer should mention the split. - Composite for the command tree.
parse,usageandhelpTexttreat the root and a leaf the same way because both areCommand. Adding a third nesting level (app remote add) needs no new class. The GoF book places Composite among the structural patterns and Builder among the creational ones (Design Patterns, Gamma et al., chapters 3 and 4). Mature libraries use the same shape: Cobra's command tree lets a flag marked persistent apply to the command and every subcommand under it (Cobra user guide). - Result as a value, errors as data. Python raises, TypeScript throws, Go returns
error. In every case the parser itself never callsexit. Python's argparse prints a message and exits by default; itsexit_on_errorswitch, added in 3.9, exists because libraries that embed a parser need the opposite (argparse docs).
Concurrency and edge cases
Parsing is a pure function of the declaration and the input, so concurrency is easy: once a command tree is built and not mutated, any number of goroutines or threads can call parse on it. Never store parse state (the cursor, the partial result) on the Command. That is the one real mistake, and it turns a read-only tree into shared mutable state.
- Option values that look like flags.
--n -5should give -5, and the rule "an option always takes the next token" delivers it. The alternative is to reject any value that starts with a dash, which breaks negative numbers and file names. Argparse takes a middle path: it treats a token like-1as a value only if no option in the parser looks like a negative number (argparse docs). - Operands that look like flags. The user cannot be expected to know your option set, so
--is the escape hatch. POSIX asks utilities to accept it as the end of options (POSIX). Go'sflagpackage is blunter: parsing stops at the first non-flag argument, soapp file -vleaves-vas an operand (Go flag docs). Pick one rule, document it, and test it. - Booleans with values. This lesson rejects any
=valueon a flag. Go's package accepts only the-flag=falseform to turn a boolean off, because a following word could be an operand (Go flag docs). - Abbreviations. Allowing
--verbfor--verboseis friendly and dangerous: adding--verbatimnext release turns every script that used--verbinto an "ambiguous option" error. Git's own documentation tells script authors to spell options out for this reason (gitcli). - Typos. Suggest, never auto-correct. The command-line guidelines at clig.dev warn that a typo might mean something else entirely, and that running a guessed command can do damage (clig.dev).
In the wild
- Git, 2017: a hostname that started with a dash. A
ssh://URL could produce ansshcommand line whose host began with-, sosshtreated it as an option instead of a host. Git 2.7.6 and the later 2.14.1 forward-port forbade hostnames, port numbers and repository names that begin with a dash. The bug was not in an argument parser; it was a missing--and missing validation where a program built a command line from untrusted data (Git 2.7.6 release notes). - Git, 2018: option injection through submodule URLs (CVE-2018-17456). Submodule URLs come from the untrusted
.gitmodulesfile, yet were handed togit cloneas-is when cloning with--recurse-submodules. A URL starting with a dash became an option. The fix in 2.14.5 rejects malformed URLs, including ones that begin with a dash (Git 2.14.5 release notes). The same mechanism struck again about a year later: operands and options share one string list, so any operand that a program fills from outside data must be separated by--or validated. - Python argparse, 3.5 to 3.8: abbreviation matching has a cost. Python 3.5 added
allow_abbrevso programs could switch off prefix matching of long options, and 3.8 fixed a side effect in which turning abbreviations off also disabled grouping of short flags such as-vv(argparse docs, "Changed in version 3.8"). Two features that look independent, abbreviation and clustering, shared one code path. It is a reminder to test each rule separately.
Down the stack
Layer 1The contract
parse(argv) returns either a result in which every declared option and positional has a value (the supplied one, the default, or null for an optional operand nobody gave) or a single error and nothing else. It never returns a partial result. It is order-insensitive for options relative to each other, as POSIX recommends, and order-sensitive for operands. Errors are specific enough to test: unknown option, missing value, bad integer, value outside the choices, missing required option, missing operand, extra operand, missing or unknown subcommand. Given the same declaration and input, the output is identical on every run.
Layer 2The mechanism
The data structures are small. Each command keeps its option specs in a list (a map from long name and from short letter is the next step for commands with dozens of options) and a list of positional specs. Option lookup walks from the current command up the ancestor chain, so a child's declaration shadows its parent's. The scan is a single index into argv; consuming a value just advances that index. The finish step iterates the whole chain to apply defaults, which keeps "unset" separate from "set to the default" until the end and lets a required check mean something.
The suggestion uses Levenshtein edit distance, a dynamic programme over two rows of integers. The rule here is distance under 3 and under the length of the word, picking the alphabetically first among ties so the message is stable. Argparse implements long-option abbreviation by prefix search over its option map and reports "ambiguous option" for more than one hit; from 3.14 it can also suggest close names for mistyped choices and subcommands (argparse docs).
Layer 3The machine
The kernel never sees flags. A shell expands quotes, variables and globs, then calls execve with an array of C strings; each string is NUL-terminated and the whole block sits at the top of the new process's stack. Linux caps a single string at 32 pages, 128 KiB, and the total size of arguments plus environment at a limit derived from the stack resource limit, so a huge glob can fail with E2BIG before your parser runs (execve(2)). Your program receives argv as a pointer array; Go copies it into os.Args and Python into sys.argv, one allocation per string.
Parsing cost is negligible: a few hundred nanoseconds for a typical ten-token line, against roughly a millisecond or more to fork and exec the process in the first place. What deserves attention is what the arguments are on the machine: readable by other users through /proc/<pid>/cmdline and ps for the life of the process, which is why a secret in argv is a leak.
Why it's built this way
- The kernel passes a flat string array and nothing else, so all structure (flag, value, operand) must be recovered in user space. Every convention exists to make that recovery unambiguous.
- Humans type the line and scripts generate it, so the grammar has to be both short to type (
-vq) and safe to generate (--name=value,--). - Help drifts from code when written separately, so the declaration is a single source of truth that generates both parser behaviour and help text.
- Programs nest (git, kubectl), so the declaration is a tree, and a recursive type is the cheapest way to handle any depth.
- Parsing is called once per process but tested thousands of times, so it is pure, returns errors as values and leaves exit codes and printing to
main.
Extensibility
- Repeatable and list options (
-I a -I b): add arepeatflag to the spec and store a list instead of overwriting. The scan does not change; only the store step does. - Environment and config fallback: add a resolution order (flag, then environment variable, then file, then default) in the finish step. Keep it explicit so help can show the source of each default.
- Mutually exclusive and dependent options: a post-scan constraint list on the command (
exclusive(["json","csv"])). It is a validation, so it runs after the scan. - Shell completion: walk the same tree. The declaration already knows every name, so a completion generator is a read-only traversal, a good fit for the Visitor pattern.
Leadership lens
- Build first: the scan and the error messages, with a table-driven test per rule. Completion and colourful help are cheap once the declaration is trustworthy.
- Ask in review: what does
--x -1mean; what happens after--; can any operand come from untrusted data; are abbreviations allowed in scripts; does the exit status differ between usage errors and runtime failures. - A CLI is an API. Renaming a flag is a breaking change for every script that calls it, so deprecate with a warning for a release before removing, and keep error text stable enough to match on.
- Buy before build. Use the language's maintained parser (argparse, Cobra, clap, Click) unless it truly cannot express your grammar. Writing one is a fine interview exercise and a poor production decision.
Check yourself
Why should
parsereturn an error instead of printing a message and exiting?Show answer
A parser that exits cannot be embedded in a larger program, called from tests, or reused to validate input from a source other than a terminal. Returning the error lets only
mainchoose the stream, text decoration and exit status.A user runs
app --count -5. Should-5be a value or an unknown option, and what do you give up either way?Show answer
The usual rule is that an option consumes the next token, so -5 is the value; that supports negative numbers and file names starting with a dash but means
--out --verbosestores the string--verbose. Rejecting dash-leading values catches that typo but breaks legitimate input, unless you add a negative-number special case as argparse does.Why are option abbreviations risky for scripts?
Show answer
A prefix that is unique today can become ambiguous when a later release adds an option with the same prefix, turning a working script into an error. Scripts should spell options in full, which is why Git's documentation says so.
Where in the class design does the Composite pattern pay off, and where would it be overkill?
Show answer
It pays off in
parse,usageandhelpText, which recurse over a command tree of any depth with one type. It is overkill for a single flat command with no subcommands, where a plain spec list is enough.
Sources and further study
Primary
- POSIX Utility Conventions (The Open Group Base Specifications): the guidelines behind single-letter options, clustering and the
--delimiter. - Git 2.7.6 release notes: the fix for hostnames and repository names beginning with a dash.
- Git 2.14.5 release notes: the CVE-2018-17456 fix for submodule URLs.
Systems docs
- Python argparse:
allow_abbrev,exit_on_error, negative-number handling and subparsers in a mature parser. - Go flag package: the minimal alternative, with parsing that stops at the first operand.
- gitcli: Git command-line interface and conventions: how a large tool uses
--, abbreviations and the--end-of-optionsmarker. - Cobra user guide: a command tree with persistent flags, the Composite pattern in production.
- execve(2), Linux manual page: how argv reaches the process and the size limits on it.
Books
- Design Patterns: Elements of Reusable Object-Oriented Software, Gamma, Helm, Johnson and Vlissides: chapter 3 (Creational, including Builder) and chapter 4 (Structural, including Composite).
- Head First Design Patterns, Freeman and Robson: the chapters on Builder and Composite, for a gentler start.
Other curricula
- Command Line Interface Guidelines (clig.dev): an open style guide that frames the topic as human-first design: help text, errors, stdout versus stderr, and secrets.
Watch
- Composite Pattern, Design Patterns ep 14 (Christopher Okhravi): a careful treatment of the pattern behind the command tree.
Free resources
- Builder pattern (Refactoring.Guru): the creational pattern behind the fluent declaration, with step-by-step examples.
- Composite pattern (Refactoring.Guru): the tree structure applied to nested objects.
- FluentInterface (Martin Fowler): why chained calls read well and where they hurt.
- getopt (Python docs): the C-style parser argparse replaced, useful for seeing what a hand-rolled one looks like.