C Schenone

OptParse logo

OptParse.jl

A Type Stable Composable CLI Parser for Julia, inspired by optparse-applicative and Optique.

Build Status Aqua QA Docs License: MIT

[!WARNING] Work In Progress: OptParse is in active development. The API is experimental and subject to frequent change. Type stability is tested and promising, but needs more real-world validation.

Philosophy

The aim is to provide an argument parsing package for CLI apps that supports trimming.

In OptParse, everything is a parser. Complex parsers are built from simpler ones through composition. Following the principle of "parse, don't validate," OptParse returns exactly what you ask for—or fails with a clear explanation.

Each parser is a tree of subparsers. Leaf nodes do the actual parsing, intermediate nodes compose and orchestrate parsers to create new behaviours. Parsing is done in two passes:

to reflect if it succeded or not. This is the parse step.

Quick Start

using OptParse

# Define a parser
parser = record((
    name = option("-n", "--name", str("NAME")),
    port = option("-p", "--port", integer("PORT"; min=1000)),
    verbose = flag("-v", "--verbose")
))

# Parse arguments
result = optparse(parser, ["--name", "myserver", "-p", "8080", "-v"])

@assert result.name == "myserver"
@assert result.port == 8080
@assert result.verbose == true

The style implemented in this library is the following:

For the public entrypoints:

optparse has two modes controlled through the juliac key via Preferences.jl mechanisms:

If you need stable non-throwing behavior across environments, use tryoptparse.

Core Concepts

OptParse provides four types of building blocks:

Primitives

The fundamental parsers that match command-line tokens:

# Options with different styles
port = option("-p", "--port", integer("PORT"))
result = optparse(port, ["--port=8080"])  # Long form with =
result = optparse(port, ["-p", "8080"])   # Short form

# Flags can be bundled
parser = record((
    all = switch("-a"),
    long = switch("-l"),
    human = switch("-h")
))
result = optparse(parser, ["-alh"])  # Equivalent to ["-a", "-l", "-h"]

Value Parsers

Type-safe parsers that convert strings to values:

# Type-safe parsing with constraints
port = option("-p", integer("PORT"; min=1000, max=65535))
level = option("-l", choice("LEVEL", ["debug", "info", "warn", "error"]))
config = option("-c", str("FILE"; pattern=r".*\.toml$"))

@enum Mode begin
    Debug
    Release
end

mode = option("--mode", choice("MODE", Mode))

When you want a named placeholder in help or usage, prefer the positional metavar form: str("FILE"), integer("PORT"), choice("MODE", Mode). The metavar= keyword still works, but the positional form is the main API.

Modifiers

Enhance parsers with additional behavior:

# Optional values
email = optional(option("-e", "--email", str("EMAIL")))

# With defaults
port = default(option("-p", integer("PORT")), 8080)

# Repeated values
packages = many(arg(str("PACKAGE")))  # pkg add Package1 Package2 Package3

# Verbosity levels
verbosity = many(switch("-v"))  # -v -v -v or -vvv

# One or more values
files = many1(arg(str("FILE")))

# Help annotations
serve = command("serve", record((
    host = option("--host", str("HOST")) |> help("Host", "Hostname to bind"),
    port = default(option("--port", integer("PORT")), 8080) |> help("Port", "TCP port to listen on"),
    verbose = flag("-v", "--verbose") |> help("Verbose", "Enable verbose logging"),
)))

help(...) does not change parsing semantics. It annotates the parser tree so OptParse can derive richer usage/help output from the same definitions.

Constructors

Compose parsers into complex structures:

or(...) is order-dependent: branches are tried in the order they are listed, and the first semantic match wins. Put broader positional parsers like arg(...) or many(arg(...)) last.

# Record composition
parser = record((
    input = arg(str("INPUT")),
    output = option("-o", "--output", str("OUTPUT")),
    force = flag("-f", "--force")
))

# Alternative commands with or
addCmd = command("add", record((
    action = @constant(:add),
    packages = many(arg(str("PACKAGE")))
)))

removeCmd = command("remove", record((
    action = @constant(:remove),
    packages = many(arg(str("PACKAGE")))
)))

pkgParser = or(addCmd, removeCmd)

Complete Example

Here's a more realistic example showing subcommands:

using OptParse

# Shared options
commonOpts = record((
    verbose = flag("-v", "--verbose"),
    quiet = flag("-q", "--quiet")
))

# Add command
addCmd = command("add", combine(
    commonOpts,
    record((packages = many(arg(str("PACKAGE"))),))
))

# Remove command
removeCmd = command("remove", "rm", combine(
    commonOpts,
    record((
        all = flag("--all"),
        packages = many(arg(str("PACKAGE")))
    ))
))

# Instantiate command
instantiateCmd = command("instantiate", combine(
    commonOpts,
    record((
        manifest = flag("-m", "--manifest"),
        project = flag("-p", "--project")
    ))
))

# Complete parser
parser = or(addCmd, removeCmd, instantiateCmd)

# Usage examples:
# julia pkg.jl add DataFrames Plots -v
# julia pkg.jl remove --all -q
# julia pkg.jl instantiate --manifest

Type Stability

OptParse is designed for type stability. The return type of your parser is fully determined at compile time:

parser = record((
    name = option("-n", str()),
    port = option("-p", integer())
))

# Return type: @NamedTuple{name::String, port::Int64}

parser = or(
    record((mode = @constant(:a), value = arg(integer()))),
    record((mode = @constant(:b), value = arg(str())))
)

# Return type: Union{@NamedTuple{mode::Val{:a}, ...}, @NamedTuple{mode::Val{:b}, ...}}

If you want to dispatch on parsed values, prefer constructing a named type with construct(...). valuetype is still useful when the parser result stays anonymous:

greet = command("greet", record((
    cmd = @constant(:greet),
    name = option("-n", str("NAME")),
)))

const Greet = valuetype(greet)

handle(x::Greet) = println("hello $(x.name)")

Application Entry Points And Automatic Help

OptParse now separates parser semantics from application-facing CLI behavior more explicitly.

At the parser level:

For real command-line apps, there is also runparse(parser, argv; ...), which adds a small amount of CLI policy:

parser = command("serve", record((
    host = option("--host", str("HOST")),
    port = default(option("--port", integer("PORT")), 8080),
)))

runparse(parser, ["--help"]; progname = "prog")
runparse(parser, []; progname = "prog", on_empty = ["help"])

The lower-level help API is still available when you want explicit control:

If you want positional help explicitly inside a parser tree, helpcommand() parses invocations such as help remote add into a HelpRequest, which runparse can interpret by rendering focused help from the original parser.

Typed Parsers And Construction

Anonymous parser outputs are still central to OptParse, but there is now a more complete story for constructing named application types.

The dynamic path is construct(T, parser):

struct ServerConfig
    host::String
    port::Int
end

parser = construct(ServerConfig, record((
    host = option("--host", str("HOST")),
    port = option("--port", integer("PORT")),
)))

construct delegates to StructUtils. In normal Julia runtime, that means it can use the full dynamic lifting machinery, including custom StructUtils integration and parametric construction when that information can be recovered at runtime.

For stricter matching and trimming-oriented code, OptParse also provides construct_exact(T, parser). This path requires exact shape agreement between the parser output and the target type.

On top of that, @parser provides a concise typed workflow:

@parser struct Config
    @description "Server configuration"

    "Hostname to bind"
    host = option("--host", str("HOST"))

    "TCP port"
    port = default(option("--port", integer("PORT")), 8080)

    @footer "Used by the development server."
end

The macro derives struct field types from the parser expressions themselves via valuetype(...), so the typed definition stays aligned with the parser output. The generated struct subtypes AbstractLiftedParser, and the parser is retrievable via lift(Config) or usable directly with the entrypoints (optparse(Config, args)).

That gives OptParse three useful layers:

Error Handling

OptParse exposes two entrypoints:

parser = option("-p", integer(min=1000))

# Throwing API
value = optparse(parser, ["-p", "3000"])

# Lower-level API
result = tryoptparse(parser, ["-p", "3000"])

optparse returns the parsed value on success and throws OptParse.ParseException on failure. tryoptparse returns a result container instead of throwing, which is useful if you want to inspect failures programmatically.

Rendered error messages are produced centrally from structured internal diagnostics. The exact wording may evolve, but failures are surfaced with parser-specific context, for example invalid values, missing required inputs, or unexpected arguments. In the high-level optparse path, the rendered exception also appends a focused usage line derived from the parser tree.

parser = option("-p", integer(min=1000))

try
    optparse(parser, ["-p", "abc"])
catch err
    @assert err isa OptParse.ParseException
end

Installation

using Pkg
Pkg.add("OptParse")

Documentation

Comprehensive documentation is available through Julia's help system:

julia> using OptParse

julia> ?option
julia> ?record
julia> ?or

For more detailed documentation, see the Documentation.

Additionally, the Optique's excellent documentation website or docs from the great optparse-applicative, might be of interest for more in depth analysis or phylosophy. There are some differences but the core concepts are the same since both have been an inspiration on the design of this library.

Contributing

Contributions are welcome! Please feel free to submit issues or pull requests.

License

MIT License. See LICENSE for details.

Acknowledgments

CC BY-SA 4.0 Camillo Schenone. Last modified: June 27, 2026. Website built with Franklin.jl and the Julia programming language.