Optique: CLI Parser Combinator

洪 民憙 (Hong Minhee) @hongminhee@hackers.pub
Recently, I created a somewhat experimental CLI parser library called Optique. As of August 21, 2025, when I'm writing this article, it hasn't even reached version 0.1.0 yet, but I thought it was an interesting concept worth sharing.
Optique was influenced by two different libraries. One is a Haskell library called optparse-applicative, from which I learned that CLI parsers can also be parser combinators, and when designed this way, they become very useful. The other is Zod, which is already familiar to TypeScript users. Although I got the core idea from optparse-applicative, Haskell and TypeScript are such different languages that there are significant differences in how the API is structured. Therefore, for API design, I referenced various validation libraries including Zod.
Optique expresses what a CLI should look like by assembling small parsers and parser combinators like Lego pieces. For example, one of the smallest components is option()
:
const parser = option("-a", "--allow", url());
To execute this parser, you can use the run()
API:
(Note that the run()
function implicitly reads process.argv.slice(2)
.)
const allow: URL = run(parser);
In the code above, I deliberately specified the URL
type, but it would be inferred as the URL
type automatically without doing so. This parser only accepts the -a
/--allow=URL
option. It will throw an error if other options or arguments are provided. It will also throw an error if the -a
/--allow=URL
option is not provided.
What if you want to make the -a
/--allow=URL
option optional rather than required? In that case, you can wrap the option()
parser with the optional()
combinator.
const parser = optional(option("-a", "--allow", url()));
What type will this parser return when executed?
const allow: URL | undefined = run(parser);
Yes, it becomes the URL | undefined
type.
Alternatively, let's make it possible to accept multiple -a
/--allow=URL
options. So you can use it like this:
prog -a https://example.com/ -a https://hackers.pub/
To allow an option to be used multiple times, use the multiple()
combinator instead of the optional()
combinator:
const parser = multiple(option("-a", "--allow", url()));
Now you can probably guess what the result type will be:
const allowList: readonly URL[] = run(parser);
Yes, it becomes the readonly URL[]
type.
But what if you want to add a mutually exclusive -d
/--disallow=URL
option that cannot be used together with the -a
/--allow=URL
option? Only one set of options should be usable at a time. In this case, you can use the or()
combinator:
const parser = or(
multiple(option("-a", "--allow", url())),
multiple(option("-d", "--disallow", url())),
);
This parser accepts the following commands correctly:
prog -a https://example.com/ --allow https://hackers.pub/
prog -d https://example.com/ --disallow https://hackers.pub/
However, it throws an error when the -a
/--allow=URL
option and -d
/--disallow=URL
option are mixed like this:
prog -a https://example.com/ --disallow https://hackers.pub/
Anyway, what type will this parser's result be?
const result: readonly URL[] = run(parser);
Oh no, since both parsers wrapped by the or()
combinator produce values of type readonly URL[]
, the result becomes readonly URL[] | readonly URL[]
, which simplifies to just readonly URL[]
. We want to change this to a proper discriminated union. A type like this would be ideal:
const Result =
| { mode: "allowList"; allowList: readonly URL[] }
| { mode: "blockList"; blockList: readonly URL[] };
When you want to create an object structure like this, you can use the object()
combinator:
const parser = or(
object({
mode: constant("allowList"),
allowList: multiple(option("-a", "--allow", url())),
}),
object({
mode: constant("blockList"),
blockList: multiple(option("-d", "--disallow", url())),
}),
);
I also used the constant()
parser to provide a discriminator. This is a somewhat unusual parser that doesn't read anything but simply produces the given value. In other words, it's a parser that always succeeds. It's primarily used to construct discriminated unions like this, but it could be used in other creative ways as well.
Now this parser produces a result value of the type we want:
const result:
| { readonly mode: "allowList"; readonly allowList: readonly URL[] }
| { readonly mode: "blockList"; readonly blockList: readonly URL[] }
= run(parser);
The or()
and object()
combinators aren't just for mutually exclusive options. Subcommands can be implemented using the same principle. Let me introduce the command()
parser that matches a command and the argument()
parser that matches positional arguments:
const parser = command(
"download",
object({
targetDirectory: optional(
option(
"-t", "--target",
file({ metavar: "DIR", type: "directory" })
)
),
urls: multiple(argument(url())),
})
)
The parser above matches commands like this:
prog download --target=out/ https://example.com/ https://example.net/
The result type of the parser is:
const result: {
readonly targetDirectory: string | undefined;
readonly urls: readonly URL[];
} = run(parser);
How would we add an upload
subcommand? That's right, we can combine them with the or()
combinator:
const parser = or(
command(
"download",
object({
action: constant("download"),
targetDirectory: optional(
option(
"-t", "--target",
file({ metavar: "DIR", type: "directory" })
)
),
urls: multiple(argument(url())),
})
),
command(
"upload",
object({
action: constant("upload"),
url: option("-d", "--dest", "--destination", url()),
files: multiple(
argument(file({ metavar: "FILE", type: "file" })),
{ min: 1 },
),
})
),
);
The parser now accepts commands like these:
prog upload ./a.txt ./b.txt -d https://example.com/
prog download -t ./out/ https://example.com/ https://hackers.pub/
The result type of this parser is:
const result:
| {
readonly action: "download";
readonly targetDirectory: string | undefined;
readonly urls: readonly URL[];
}
| {
readonly action: "upload";
readonly url: URL;
readonly files: readonly string[];
}
= run(parser);
Using the same approach, you could implement nested subcommands as well, right?
So, I've shown you how Optique expresses CLIs. What do you think? Do you see how Optique's approach is suitable for expressing complex CLIs?
Of course, Optique's approach isn't perfect. It's true that defining very typical and simple CLIs might actually require more work. Also, since Optique only serves as a CLI parser, it doesn't provide the various features that general CLI app frameworks offer. (I do plan to add more features to Optique in the future...)
Nevertheless, if you found Optique's approach interesting, please take a look at the introduction documentation or the tutorial.