Types
The six built-in ValueType literals — string, number, boolean, url, path, json — the `parse` escape hatch, and copy-paste recipes for common formats.
Every flag and positional argument declares a type that determines two things: the runtime value passed to your run handler, and the TypeScript type inferred for it.
Quick reference
type | Runtime value | Inferred TS type | Coercion |
|---|---|---|---|
"string" | The raw argv token | string | none |
"number" | Coerced number | number | Number(raw); throws PARSE on NaN |
"boolean" | Toggle (no value taken) | boolean | --flag → true, --no-flag → false |
"url" | URL instance | URL | new URL(raw); throws PARSE on missing protocol |
"path" | Absolute path string | string | ~ expanded, then resolved against process.cwd() |
"json" | Parsed JSON value | unknown | JSON.parse(raw); throws PARSE on invalid JSON |
The inferred type for each variant flows through default, required, and multiple exactly like the base types. Optional flags without a default infer as T | undefined; required flags or flags with a default infer as T; multiple: true wraps the inferred type in an array.
"string"
The raw argv token is passed through unchanged.
.flags({
name: { type: "string", required: true },
})
// flags.name: stringFor domain-specific parsing (dates, ports, regexes, …) attach a parse function to a string flag or argument.
"number"
The raw token is coerced via Number(raw). Values that produce NaN throw CrustError("PARSE", …).
.flags({
port: { type: "number", default: 3000 },
})
// flags.port: numberHex (0x…), scientific notation, and floats are accepted — anything Number() understands.
"boolean"
Boolean flags are toggles: presence implies true, the --no- prefix implies false. They do not take a value.
.flags({
verbose: { type: "boolean", short: "v" },
})
// flags.verbose: boolean | undefinedSee Flags → Boolean Negation for flag-specific behavior (repeat semantics, alias rules).
"url"
new Crust("fetch")
.flags({
endpoint: { type: "url", required: true },
})
.run(({ flags }) => {
// flags.endpoint is a URL instance
console.log(flags.endpoint.hostname);
});- Any protocol accepted by
new URL()works (https:,http:,file:,ftp:, …). - Values without a protocol (
example.com) throwCrustError("PARSE", …)with a hint suggestinghttps://example.com. - Shell completion does not offer filenames for
urlflags, since URLs are not filesystem paths.
"path"
new Crust("build")
.flags({
out: { type: "path", default: "./dist" },
})
.run(({ flags }) => {
// flags.out is always an absolute string
console.log(flags.out); // e.g. "/Users/alice/proj/dist"
});- A leading
~/(or bare~) is expanded againstos.homedir().~username/is not expanded (POSIX style). - The result is resolved against
process.cwd()viapath.resolve, so the value handed to your run handler is always absolute. - Path traversal (
../) is allowed — the coercer does not sandbox. - Empty input throws
CrustError("PARSE", …). - Shell completion does offer file candidates for
pathflags and positional arguments. zsh emits_filesand fish emits(__fish_complete_path)explicitly for both flags and positional args; bash emitscompgen -ffor path flag values and lets the script's globalcomplete -o defaultfilename fallback handle path positionals.
"json"
new Crust("apply")
.flags({
config: { type: "json" },
})
.run(({ flags }) => {
// flags.config is `unknown` — validate before use
if (typeof flags.config === "object" && flags.config !== null) {
// ...
}
});- Any valid JSON document is accepted: objects, arrays, strings, numbers, booleans,
null. - The inferred type is
unknownso callers must narrow before use — this matches whatJSON.parseactually returns. - Wrap JSON in single quotes on the command line so the shell does not eat the braces:
--config '{"k":1}'. - Invalid JSON throws
CrustError("PARSE", …)with the underlyingSyntaxError.messageand a shell-quoting hint. - Shell completion does not offer filenames for
jsonflags.
JSON.parse loses precision on integers above Number.MAX_SAFE_INTEGER (2^53 − 1). If you need bigints, use type: "string" with a custom parse — see the BigInt recipe.
The parse escape hatch
For domain-specific transformations not covered by the six built-ins, attach a parse function to a type: "string" flag or argument. The return type of parse becomes the inferred runtime type.
new Crust("serve")
.flags({
port: {
type: "string",
parse: (raw) => Number.parseInt(raw, 10),
default: "3000",
},
})
.run(({ flags }) => {
// flags.port is `number` — inferred from `ReturnType<parse>`
console.log(flags.port + 1); // 3001
});Where parse is allowed
parse is only allowed on type: "string" (single and multi) and string positional args. Every non-string variant declares parse?: never, so a misuse like { type: "number", parse: (s) => s } is rejected at compile time.
// ❌ Compile error — parse is forbidden on number flags
.flags({
port: { type: "number", parse: (s) => Number(s) }
})Sync only
parse must be synchronous. Async parsers would return a Promise where a value was expected; Crust rejects them at command setup with CrustError("CONFIG", …):
.flags({
endpoint: { type: "string", parse: async (s) => fetch(s) },
})
// → CrustError("CONFIG", "Async parse not supported for flag --endpoint. …")Do async work inside your run handler instead.
Multi-value parse
When combined with multiple: true, parse runs once per element. The inferred type is T[] where T = ReturnType<typeof parse>.
.flags({
nums: {
type: "string",
multiple: true,
parse: (s) => Number(s),
},
})
// --nums 1 --nums 2 → flags.nums === [1, 2]parse and default
When parse is set and argv is absent but default is provided, Crust runs parse(String(default)) so the runtime value matches the inferred type. The raw default never leaks through.
.flags({
port: {
type: "string",
parse: (s) => Number(s),
default: "3000",
},
})
// argv `[]` → flags.port === 3000 (number)parse and choices
When both choices and parse are set, choices validates the raw argv token first. Invalid values throw CrustError("PARSE", …) before parse runs.
.flags({
mode: {
type: "string",
choices: ["1", "2"] as const,
parse: (s) => Number(s),
},
})
// --mode 3 → CrustError("PARSE", `Invalid value "3" for --mode. Expected one of: 1, 2`)
// --mode 1 → flags.mode === 1Error handling inside parse
A parse function that throws an error is automatically wrapped in CrustError("PARSE", …) with the flag/argument name and the original message:
.flags({
hex: {
type: "string",
parse: (s) => {
const n = Number.parseInt(s, 16);
if (Number.isNaN(n)) throw new Error("not a hex literal");
return n;
},
},
})Common parse recipes
The built-ins stay small on purpose. For specialised formats, paste one of the snippets below into a .flags() or .args() block — the runtime type flows automatically from ReturnType<parse>, no as casts needed.
Defaults are raw strings. Crust runs parse(String(default)) so the runtime value matches the inferred type — don't pre-parse defaults. If a recipe is reused across commands, extract parse as a plain function and reference it from each definition.
Date — ISO-8601 / Date instance
.flags({
since: {
type: "string",
parse: (raw) => {
const d = new Date(raw);
if (Number.isNaN(d.getTime())) throw new Error(`Invalid date "${raw}"`);
return d;
},
},
})
// flags.since: Date | undefinedDuration — human strings to milliseconds
.flags({
timeout: {
type: "string",
parse: (raw) => {
const m = raw.match(/^(\d+(?:\.\d+)?)(ms|s|m|h)$/);
if (!m) throw new Error(`Invalid duration "${raw}" (expected e.g. 500ms, 30s, 5m)`);
const n = Number.parseFloat(m[1]);
const unit = m[2] as "ms" | "s" | "m" | "h";
const mult = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }[unit];
return n * mult;
},
default: "30s",
},
})
// flags.timeout: number (milliseconds)Port — TCP/UDP port number
.flags({
port: {
type: "string",
parse: (raw) => {
const n = Number.parseInt(raw, 10);
if (!Number.isInteger(n) || n < 1 || n > 65_535) {
throw new Error(`Port must be 1–65535, got "${raw}"`);
}
return n;
},
default: "3000",
},
})
// flags.port: number (1–65535)Regex — compiled RegExp
.flags({
filter: {
type: "string",
parse: (raw) => {
try {
return new RegExp(raw);
} catch (e) {
throw new Error(`Invalid regex "${raw}": ${(e as Error).message}`);
}
},
},
})
// flags.filter: RegExp | undefinedBytes — human sizes to bytes
.flags({
size: {
type: "string",
parse: (raw) => {
const m = raw.match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|KiB|MiB|GiB)?$/i);
if (!m) throw new Error(`Invalid size "${raw}" (e.g. 10MB, 1.5GiB)`);
const n = Number.parseFloat(m[1]);
const unit = (m[2] ?? "B").toUpperCase();
const table: Record<string, number> = {
B: 1,
KB: 1_000, MB: 1_000_000, GB: 1_000_000_000,
KIB: 1_024, MIB: 1_048_576, GIB: 1_073_741_824,
};
return n * (table[unit] ?? 1);
},
},
})
// flags.size: number (bytes)BigInt — arbitrary-precision integers
.flags({
block: {
type: "string",
parse: (raw) => {
try {
return BigInt(raw);
} catch {
throw new Error(`Invalid bigint "${raw}"`);
}
},
},
})
// flags.block: bigint | undefinedHex — hex-encoded Buffer
.flags({
key: {
type: "string",
parse: (raw) => {
const clean = raw.replace(/^0x/i, "");
if (!/^[0-9a-fA-F]+$/.test(clean) || clean.length % 2 !== 0) {
throw new Error(`Invalid hex string "${raw}"`);
}
return Buffer.from(clean, "hex");
},
},
})
// flags.key: Buffer | undefinedBase64 — base64-encoded Buffer
.flags({
payload: {
type: "string",
parse: (raw) => {
// Round-trip to detect invalid encodings (Node's decoder is permissive).
const buf = Buffer.from(raw, "base64");
if (buf.toString("base64").replace(/=+$/, "") !== raw.replace(/=+$/, "")) {
throw new Error(`Invalid base64 "${raw}"`);
}
return buf;
},
},
})
// flags.payload: Buffer | undefinedFile — path that must exist and be a file
For "I just want a path", use type: "path". The recipes below add filesystem existence checks via Node's sync fs. Skip these checks if your CLI tolerates missing inputs.
import { statSync } from "node:fs";
import { resolve } from "node:path";
.args([
{
name: "input",
type: "string",
required: true,
parse: (raw) => {
const abs = resolve(process.cwd(), raw);
const stat = statSync(abs, { throwIfNoEntry: false });
if (!stat) throw new Error(`File not found: ${raw}`);
if (!stat.isFile()) throw new Error(`Not a regular file: ${raw}`);
return abs;
},
},
])
// args.input: string (absolute path to an existing file)Dir — path that must exist and be a directory
import { statSync } from "node:fs";
import { resolve } from "node:path";
.flags({
out: {
type: "string",
parse: (raw) => {
const abs = resolve(process.cwd(), raw);
const stat = statSync(abs, { throwIfNoEntry: false });
if (!stat) throw new Error(`Directory not found: ${raw}`);
if (!stat.isDirectory()) throw new Error(`Not a directory: ${raw}`);
return abs;
},
},
})
// flags.out: string | undefined (absolute path to an existing directory)