Crust logoCrust

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

typeRuntime valueInferred TS typeCoercion
"string"The raw argv tokenstringnone
"number"Coerced numbernumberNumber(raw); throws PARSE on NaN
"boolean"Toggle (no value taken)boolean--flagtrue, --no-flagfalse
"url"URL instanceURLnew URL(raw); throws PARSE on missing protocol
"path"Absolute path stringstring~ expanded, then resolved against process.cwd()
"json"Parsed JSON valueunknownJSON.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: string

For 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: number

Hex (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 | undefined

See 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) throw CrustError("PARSE", …) with a hint suggesting https://example.com.
  • Shell completion does not offer filenames for url flags, 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 against os.homedir(). ~username/ is not expanded (POSIX style).
  • The result is resolved against process.cwd() via path.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 path flags and positional arguments. zsh emits _files and fish emits (__fish_complete_path) explicitly for both flags and positional args; bash emits compgen -f for path flag values and lets the script's global complete -o default filename 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 unknown so callers must narrow before use — this matches what JSON.parse actually 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 underlying SyntaxError.message and a shell-quoting hint.
  • Shell completion does not offer filenames for json flags.

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 === 1

Error 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 | undefined

Duration — 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 | undefined

Bytes — 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 | undefined

Hex — 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 | undefined

Base64 — 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 | undefined

File — 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)

On this page