RecordArgs is a simple command-line argument parser for Java applications that relies on records and sealed interfaces:
record ServerArgs(String url, int port) { }
// launch command: "java [...] --url localhost --port 8080"
public static void main(String[] args) throws ArgsParseException {
ServerArgs serverArgs = Args.parse(args, ServerArgs.class);
}
RecordArgs uses records' component names to identify command line arguments, their canonical constructors to create instances, and their immutability to let you freely pass them around without fear of unwanted changes. It uses sealed interfaces to model mutually exclusive sets of arguments, so-called "modes" or "actions".
- Getting started
- Arguments
- Args records
- Args interfaces and mutually exclusive arguments
- Error Handling
- Use your favorite build tool to pull RecordArgs in:
- group ID:
dev.nipafx.args
- artifact ID:
record-args
- group ID:
- Create a record like this one (called an args record):
record ServerArgs(String url, int port) { }
- Make the record public and export its package or keep it encapsulated and open the package (optionally just to dev.nipafx.args).
- Pass command-line arguments in this form:
java [...] --url localhost --port 8080
- Call
Args::parse
:public static void main(String[] args) throws ArgsParseException { ServerArgs serverArgs = Args.parse(args, ServerArgs.class); }
In most cases, the passed arguments must alternate between an argument's name (prefixed by --
) and its value (e.g. --port 8080
) but the order of these pairs can be arbitrary (e.g. first --url localhost
then --port 8080
or the other way around).
A value must be defined for all arguments that aren't of a container type (see below), so for the args record…
record ServerArgs(String url, int port) { }
…the following command would lead to an exception because port
has no value:
java [...] --url localhost
The args record's component names define the argument names. So for a record like the following…
record ServerArgs(String url, int port) { }
…the arguments --url
and --port
are parsed.
Camel-cased component names are interpreted as is, i.e. they are not kebap-cased.
So a component remoteUrl
would be mapped to the argument name --remoteUrl
, not --remote-url
.
An argument name must in most cases be followed by exactly one value that can be parsed to the argument's type. Supported simple types are:
String
,Path
Integer
,int
,Long
,long
,Float
,float
,Double
,double
Boolean
,boolean
(only values "true" and "false")
The boolean types are an exception to the rule that an argument name must always be followed by a type.
If no value is given after the argument, true
is assumed.
For example, in this situation…
record ServerArgs(String url, boolean createLog) { }
…the following arguments could be parsed…
java [...] --url localhost:8080 --createLog
java [...] --createLog --url localhost:8080
…and serverArgs.createLog()
would return true
.
Note that like all non-container arguments, boolean arguments must be present.
To turn them into classic flags instead, where absence means false
and presence means true
, use Optional<Boolean>
as component type (see below for details) and access their value with orElse(false)
.
Beyond simple types, the following container types are supported:
Optional<VALUE>
, whereVALUE
is any of the simple types above (OptionalInt
,OptionalLong
,OptionalDouble
aren't supported, useOptional<Integer>
etc. instead)List<VALUE>
, whereVALUE
is any of the simple types aboveMap<KEY, VALUE>
, whereKEY
andVALUE
are any of the simple types above
Container types are always optional.
Arguments of type Optional
are optional (talk about good naming!).
For port
to be optional, ServerArgs
must be defined as follows:
record ServerArgs(String url, Optional<Integer> port) { }
Then this command can be successfully parsed:
java [...] --url localhost
Arguments of type List
accept one or more arguments.
If not mentioned, they are empty, which makes them optional as well.
That means for the following args record…
record ServerArgs(List<String> urls, boolean createLog) { }
…any of the following command lines are acceptable:
java [...] --createLog
java [...] --createLog --urls localhost
java [...] --createLog --urls localhost 127.0.0.1
java [...] --urls localhost 127.0.0.1 --createLog
While just mentioning a list argument without providing a value…
java [...] --urls --createLog
…could be parsed to the empty list, this non-sensical command is instead interpreted as a mistake and leads to an exception.
List instances are unmodifiable, just like those created with List::of
and List::copyOf
.
Arguments of type Map
accept one or more key-value pair of the form key=value
(there must be no additional =
in the argument).
If not mentioned, they are empty, which makes them optional as well.
That means for the following args record…
record ServerArgs(Map<Integer, String> numbers, boolean createLog) { }
…any of the following command lines are acceptable:
java [...] --createLog
java [...] --createLog --numbers 1=one
java [...] --createLog --numbers 1=one 2=two 3=three
java [...] --numbers 1=one 2=two 3=three --createLog
While just mentioning a map argument without providing a value…
java [...] --numbers --createLog
…could be parsed to the empty map, this non-sensical command is instead interpreted as a mistake and leads to an exception.
Map instances are unmodifiable, just like those created with Map::of
, Map::ofEntries
, and Map::copyOf
.
RecordArgs calls a record's canonical constructor and it is advisable to implement all suitable argument verification in there - whether it's ranges for numerical values, existence of files and folders, or number of list elements. Exceptions thrown by the constructor are surfaced by the error-handling mechanism (see below).
It is possible to parse command line arguments to up to three args records with overloads of Args::parse
.
These overloads return instances of Parsed2
or Parsed3
that have accessors first()
, second()
, and maybe third()
to access the parsed args record instances:
// args records
record LogArgs(int logLevel) { }
record ServerArgs(String url, int port) { }
// parsing arguments
public static void main(String[] args) throws ArgsParseException {
Parsed2 parsed = Args.parse(args, LogArgs.class, ServerArgs.class);
LogArgs logArgs = parsed.first();
ServerArgs serverArgs = parsed.second();
}
The records must not have components of the same name or Args::parse
throws an exception.
If an application provides diverse features that take distinct execution paths, it might need argument sets for each path that have little to no overlap. Instead of parsing the arguments to one large or several small args record where most components are optional and then dealing with many absent arguments, consider using "modes" or an "action".
A mode is a sealed interface that permits only record implementations:
sealed interface Mode permits Client, Server { }
record Client(int port) implements Mode { }
record Server(String url, int port) implements Mode { }
When such an interface is passed to Args::parse
, an argument with its name and a value that is one of the implementing records' names (always first letter in lower case, e.g. --mode client
, and without a potential Args
suffix - more on that below) is used to determine which args record to fill and instantiate (this is called mode selection).
Hence, Args::parse
returns an instance of one of the records implementing the mode as chosen by the command line arguments.
For the types above, here's what main
…
public static void main(String[] args) throws ArgsParseException{
var arguments = Args.parse(args, Mode.class);
}
…and a successful invocation…
java [...] --mode client --port 8080
…would look like.
In this case, arguments
would be of type Client
.
A good way to branch execution based on the specific type is with pattern matching over the returned instance:
public static void main(String[] args) throws ArgsParseException{
var arguments = Args.parse(args, Mode.class);
switch(arguments) {
case Client config -> spawnClient(config);
case Server config -> spawnServer(config);
}
}
That means a command line with --mode client
would trigger execution of spawnClient
.
The non-selected args records are ignored and no values are expected or allowed for them. This also means that, as in the example above, alternative args records can have components with the same name.
Just as with all other values, mode selection can happen anywhere within the args
array.
And it is possible to parse multiple modes as well as modes mixed with regular args records, e.g.:
sealed interface Mode permits Client, Server { }
record Client(int port) implements Mode { }
record Server(String url, int port) implements Mode { }
record LogArgs(int logLevel) { }
// java [...] --port 8080 --logLevel 3 --mode server --url localhost
public static void main(String[] args) throws ArgsParseException {
var arguments = Args.parse(args, Mode.class, LogArgs.class);
Logging.configure(arguments.second());
switch(arguments.first()) {
case Client config -> spawnClient(config);
// this path is taken
case Server config -> spawnServer(config);
}
}
When exclusively dealing with args records, the names of the arguments are determined solely by component names.
But as described above, when using modes (and/or an action - see below), the type names also determine some argument names and values.
Type names should be descriptive (which just Client
might not be) and command line arguments should be succinct (which clientArgs
wouldn't be), though, which can lead to tension.
To offer a degree of freedom for resolving this tension, a type name suffix of Args
is ignored and hence not included in the argument names created for the type:
// maps to `--mode`
sealed interface ModeArgs permits Client, Server { }
// maps to `client`
record ClientArgs(int port) implements Mode { }
// maps to `server`
record ServerArgs(String url, int port) implements Mode { }
// java [...] --mode server --port 8080 --url localhost
public static void main(String[] args) throws ArgsParseException {
// successfully parsed
var arguments = Args.parse(args, ModeArgs.class);
}
An action is a special mode (i.e. everything stated for them that is not contradicted here applies) where the selection is not done by a pair of arguments (e.g. ... --mode client ...
) but by having just the value as the first argument in the array (e.g. client ...
).
This interpretation is automatically and exclusively applied to interfaces with the simple name Action
or ActionArgs
, e.g.:
sealed interface Action permits Create, Copy, Move { }
record Create(Path path) implements Action { }
record Copy(Path from, Path to) implements Action { }
record Move(Path from, Path to) implements Action { }
// java [...] copy --from… --to…
public static void main(String[] args) throws ArgsParseException {
var arguments = Args.parse(args, Action.class);
switch(arguments) {
case Create args -> create(args);
// this path is taken
case Copy args -> copy(args);
case Move args -> move(args);
}
}
Due to its positional nature, there can only be one action, but it can be combined with modes and other args records.
Args:parse
throws four kinds of exceptions:
IllegalArgumentException
(unchecked) when you pass an illegal argument, most likelynull
, toparse
. This indicates a developer error that can be avoided in production - make sure to check the instances you pass toparse
.ArgsDefinitionException
(unchecked) when the types passed toparse
are not valid args types. This indicates a developer error that can be avoided in production - check the error code and message for details and define your args types accordingly. Two example errors (and their codes):UNSUPPORTED_ARGUMENT_TYPE
when an args record component has a type that is not listed under simple or container typesDUPLICATE_ARGUMENT_DEFINITION
when two args records have a component of the same name- for all possible errors, check
ArgsDefinitionErrorCode
ArgsParseException
(checked) whenString[] args
can't be correctly parsed. This error must be expected in production as the arguments are human input and may be faulty. It exposes a stream ofArgsMessage
s, which capture all detected issues. They can either be converted to string messages that can be shown to the user or specific subtypes can be examined for more detailed information. Two example subtypes:record MissingArgument(String argumentName)
when the argument array did not define a value for a non-container argumentrecord FailedConstruction(Throwable exception)
when the args record constructor throws an exception- for all possible errors, check
ArgsMessage
IllegalStateException
when an unexpected internal state is encountered. This is not supposed to happen at all - if it does, it is likely a bug.