flags-builder
@jondotsoy/flags
A zero-dependency, type-safe CLI argument parser with a fluent builder API.
Overview
What this skill is for
Use this skill whenever the task involves parsing command-line arguments in a Node.js or TypeScript program. It covers the full lifecycle: reading process.argv, defining typed flags, handling subcommands, and validating inputs.
When to use it
Trigger this skill when the user's request matches one of these scenarios:
Building a CLI tool from scratch
"I want to create a CLI that accepts
--portand--verbose" "Make a script that reads arguments from the terminal"
Adding flags to an existing script
"Add a
--dry-runflag to my deploy script" "Support--output=distin my build tool"
Parsing subcommands
"My CLI needs
serveandbuildcommands, each with their own flags" "How do I parsemycli deploy --env production?"
Handling repeated or typed inputs
"I need
--tagto be repeatable" "Validate that--portis a number and defaults to 3000"
User phrases that signal this skill
- "parse argv", "read CLI args", "command-line flags"
- "how do I get
--flagfromprocess.argv" - mentions of
--flag,-f, subcommands, or argument parsing in a Node.js/TypeScript context
When NOT to use it
- The project already uses another parser (e.g.
yargs,commander,minimist) — prefer extending the existing setup - Arguments come from a config file, environment variables, or an HTTP request — this library is specifically for
argv
Getting Started
import { flags, flag, command } from "@jondotsoy/flags";
const args = process.argv.slice(2); // remove "node" and script path
const parser = flags({ ... });
const [ok, error, options] = parser.safeParse(args);
if (!ok) { console.error(parser.formatError(error)); process.exit(1); }
Flag Types
All input styles are supported automatically — no extra config needed:
const parser = flags({
verbose: flag("--verbose", "-v").boolean(), // --verbose
version: flag("--version").number(), // --version 1.2
name: flag("--name", "-n").string(), // --name foo OR --name=foo
});
const [ok, error, output] = parser.safeParse(args);
if (!ok) { console.error(parser.formatError(error)); process.exit(1); }
const { verbose, version, name } = output;
.boolean()
Presence of the flag sets the value to true. Default: false.
flag("--verbose", "-v").boolean();
// --verbose → true
// (absent) → false
.string()
Reads the next token or the value after =. Returns string | null (null if absent).
flag("--name", "-n").string();
// --name foo → "foo"
// --name=foo → "foo"
// (absent) → null
.number()
Like .string() but coerces the value to a number. Returns number | null.
flag("--port", "-p").number();
// --port 3000 → 3000
// --port=3000 → 3000
// (absent) → null
.strings()
Accumulates repeated flags into an array. Returns string[] (always an array, never null).
flag("--tag").strings();
// --tag a --tag b --tag c → ["a", "b", "c"]
// (absent) → []
.keyValue()
Reads key=value pairs and merges them into a record. Returns Record<string, string> | null.
flag("--env").keyValue();
// --env NODE_ENV=production --env PORT=3000 → { NODE_ENV: "production", PORT: "3000" }
// (absent) → null
Modifiers
Chain modifiers after the type method:
flag("--port", "-p").number().default(3000); // default value
flag("--output").string().required(); // throw if missing
flag("--tag").strings(); // accumulate: --tag a --tag b → ["a", "b"]
flag("--count").number().positive(); // must be > 0
flag("--port").number().describe("HTTP port"); // help text
Subcommands — Multi-Parser Pattern
Use command().restArgs() to capture a subcommand's raw arguments, then run a second .safeParse() on them:
import { flags, flag, command } from "@jondotsoy/flags";
// Level 1: identify which subcommand was used
const mainParser = flags({
serve: command("serve").restArgs(),
});
const [ok, error, output] = mainParser.safeParse(process.argv.slice(2));
if (!ok) { console.error(mainParser.formatError(error)); process.exit(1); }
// Level 2: parse the subcommand's own flags
if (output.serve) {
const serveParser = flags({
port: flag("--port", "-p").number().default(3000),
});
const [ok2, error2, serveOutput] = serveParser.safeParse(output.serve);
if (!ok2) { console.error(serveParser.formatError(error2)); process.exit(1); }
startServer(serveOutput.port);
}
This pattern scales to any number of subcommands:
const mainParser = flags({
build: command("build").restArgs(),
deploy: command("deploy").restArgs(),
});
const [ok, error, output] = mainParser.safeParse(process.argv.slice(2));
if (!ok) { console.error(mainParser.formatError(error)); process.exit(1); }
if (output.build) {
const buildParser = flags({ outDir: flag("--out").string().default("dist") });
const [ok2, error2, buildOutput] = buildParser.safeParse(output.build);
if (!ok2) { console.error(buildParser.formatError(error2)); process.exit(1); }
}
if (output.deploy) {
const deployParser = flags({ env: flag("--env").string().required() });
const [ok2, error2, deployOutput] = deployParser.safeParse(output.deploy);
if (!ok2) { console.error(deployParser.formatError(error2)); process.exit(1); }
}
Recommended File Structure (Large Projects)
This is a recommendation, not a requirement. Always ask the user before applying this structure — it may not fit every project.
As a program grows, putting all parsers in a single file becomes hard to maintain. The recommended approach is to split each parser into its own file under src/args/, mirroring the command hierarchy.
src/args/
main.ts ← top-level parser (subcommands)
serve.ts ← flags for `serve` subcommand
containers/
main.ts ← flags for `containers` subcommand
pull.ts ← flags for `containers pull`
push.ts ← flags for `containers push`
Each file exports a named parser constant. The name should be descriptive of the command it handles:
// src/args/main.ts
import { flags, command } from "@jondotsoy/flags";
export const mainParserArgs = flags({
serve: command("serve").restArgs(),
containers: command("containers").restArgs(),
});
// src/args/serve.ts
import { flags, flag } from "@jondotsoy/flags";
export const serveParserArgs = flags({
port: flag("--port", "-p").number().default(3000),
host: flag("--host").string().default("localhost"),
});
// src/args/containers/main.ts
import { flags, command } from "@jondotsoy/flags";
export const containersParserArgs = flags({
pull: command("pull").restArgs(),
push: command("push").restArgs(),
});
Consuming parsers in the entry point:
import { mainParserArgs } from "./args/main.js";
import { serveParserArgs } from "./args/serve.js";
import { containersParserArgs } from "./args/containers/main.js";
const [ok, error, output] = mainParserArgs.safeParse(process.argv.slice(2));
if (!ok) { console.error(mainParserArgs.formatError(error)); process.exit(1); }
if (output.serve) {
const [ok2, error2, serveOutput] = serveParserArgs.safeParse(output.serve);
if (!ok2) { console.error(serveParserArgs.formatError(error2)); process.exit(1); }
}
if (output.containers) {
const [ok2, error2, containersOutput] = containersParserArgs.safeParse(output.containers);
if (!ok2) { console.error(containersParserArgs.formatError(error2)); process.exit(1); }
}
When to suggest this structure:
- The program has 3 or more subcommands
- Each subcommand has its own set of flags
- The codebase is expected to grow or be maintained long-term
When to keep it simple (single file):
- Small scripts with 1–2 flags and no subcommands
- Throwaway or single-use tools
Help Message
Call .helpMessage() on any parser to get a formatted help string. Use .program(), .describe(), and .version() to populate it, and add .describe() to individual flags for per-flag documentation.
const parser = flags({
port: flag("--port", "-p").number().default(3000).describe("Port to listen on"),
verbose: flag("--verbose", "-v").boolean().describe("Enable verbose output"),
output: flag("--output", "-o").string().required().describe("Output directory"),
})
.program("mycli")
.describe("A simple CLI tool")
.version("1.0.0");
console.log(parser.helpMessage());
Typical output:
mycli 1.0.0
A simple CLI tool
Options:
--port, -p Port to listen on (default: 3000)
--verbose, -v Enable verbose output
--output, -o Output directory (required)
Common pattern — print help on --help
const [ok, error, output] = parser.safeParse(process.argv.slice(2));
if (!ok) { console.error(parser.formatError(error)); process.exit(1); }
if (output.help) {
console.log(parser.helpMessage());
process.exit(0);
}
With the file structure pattern
Each sub-parser can expose its own help message independently:
// src/args/serve.ts
export const serveParserArgs = flags({
port: flag("--port", "-p").number().default(3000).describe("Port to listen on"),
host: flag("--host").string().default("localhost").describe("Host to bind"),
})
.program("mycli serve")
.describe("Start the development server");
if (output.serve) {
const [ok2, error2, serveOutput] = serveParserArgs.safeParse(output.serve);
if (!ok2) { console.error(serveParserArgs.formatError(error2)); process.exit(1); }
}
Safe Parse
Prefer
.safeParse()over.parse()— it makes error handling explicit and avoids unexpected exceptions propagating through the program.
.safeParse() returns a tuple [ok, error, output]. When ok is false, output is undefined and error contains the caught value. When ok is true, output is the fully typed result.
const [ok, error, output] = parser.safeParse(process.argv.slice(2));
if (!ok) {
console.error(parser.formatError(error));
process.exit(1);
}
const port = output.port; // fully typed, no undefined check needed
Tuple shape:
| Position | Name | Value when ok | Value when error |
|---|---|---|---|
[0] |
ok |
true |
false |
[1] |
error |
undefined |
the caught error (unknown) |
[2] |
output |
parsed result (fully typed) | undefined |
When to use .parse() instead
Use .parse() only when you intentionally want to skip error handling — for example in quick scripts or tests where an unhandled exception is acceptable:
// ok for tests or throwaway scripts
const output = parser.parse(["--port", "3000"]);
Never use .parse() in production CLI entry points — prefer .safeParse() there.
Error Handling
Use parser.formatError(error) to get a formatted message that includes the error and a hint to run --help. For advanced cases, the error value can also be narrowed with the exported error classes:
import { UnexpectedArgumentError, RequiredFlagMissingError } from "@jondotsoy/flags";
const [ok, error, output] = parser.safeParse(process.argv.slice(2));
if (!ok) {
console.error(parser.formatError(error));
process.exit(1);
}
Narrowing for specific messages:
if (!ok) {
if (error instanceof UnexpectedArgumentError) console.error("Unknown argument:", error.message);
else if (error instanceof RequiredFlagMissingError) console.error("Missing required flag:", error.message);
else console.error(parser.formatError(error));
process.exit(1);
}