Skip to content

Output & Errors

When you run a command with Command.run, two kinds of text reach the terminal that you never wrote by hand: help output (printed for --help, --version, or when parsing fails) and error messages (printed when a flag is missing, a value is invalid, an unknown subcommand is used, and so on). Both are produced by a single service, CliOutput.Formatter, which the runner reads from context.

This page covers how to override that formatter (most commonly to turn colors off in CI), the HelpDoc data model the formatter consumes, and the structured CliError types the parser raises — which you can pattern-match when a command is embedded inside a larger program.

All three modules import from effect/unstable/cli:

import { CliError, CliOutput, HelpDoc } from "effect/unstable/cli"

By default the formatter auto-detects color support from process.stdout.isTTY and the NO_COLOR environment variable. In CI you often want to force plain text so logs stay readable. Build a no-color formatter with CliOutput.defaultFormatter({ colors: false }) and provide it through CliOutput.layer:

app.ts
import { NodeRuntime, NodeServices } from "@effect/platform-node"
import { Console, Effect } from "effect"
import { Command, CliOutput, Flag } from "effect/unstable/cli"
const greet = Command.make("greet", {
name: Flag.string("name")
}, (config) => Console.log(`Hello, ${config.name}!`))
// A formatter that never emits ANSI color codes.
const NoColor = CliOutput.layer(
CliOutput.defaultFormatter({ colors: false })
)
Command.run(greet, { version: "1.0.0" }).pipe(
// Override the formatter the runner pulls from context.
Effect.provide(NoColor),
Effect.provide(NodeServices.layer),
NodeRuntime.runMain
)

Now greet --help, greet --version, and any parse error render as plain text. Because CliOutput.Formatter is a Context.Reference with a default value, you do not have to provide it — the layer simply replaces the default.

A fully custom formatter is just an object implementing the Formatter interface — useful for machine-readable output such as JSON:

import { CliOutput } from "effect/unstable/cli"
const jsonFormatter: CliOutput.Formatter = {
formatHelpDoc: (doc) => JSON.stringify(doc, null, 2),
formatCliError: (error) => JSON.stringify({ error: error.message }),
formatError: (error) => JSON.stringify({ type: "error", message: error.message }),
formatVersion: (name, version) => JSON.stringify({ name, version }),
formatErrors: (errors) => JSON.stringify(errors.map((e) => e.message))
}
const JsonLayer = CliOutput.layer(jsonFormatter)

The CliOutput module is small: an interface, a default implementation, and a layer to install it.

The service interface describing how help, usage, version, and errors render. It is exposed as a Context.Reference, so it always has a default and is accessed with yield* CliOutput.Formatter. The interface has five methods:

MethodSignatureRenders
formatHelpDoc(doc: HelpDoc) => stringthe full help screen for --help
formatVersion(name: string, version: string) => stringthe --version line
formatError(error: CliError) => stringa single error block
formatErrors(errors: ReadonlyArray<CliError>) => stringseveral errors, grouped by tag
formatCliError(error: CliError) => stringthe bare error message (no styling)
import { Effect } from "effect"
import { CliOutput } from "effect/unstable/cli"
const program = Effect.gen(function*() {
// Read the formatter the runner would use from context.
const formatter = yield* CliOutput.Formatter
return formatter.formatVersion("my-cli", "2.1.0")
})
// With the default (no-color) formatter:
// => "my-cli v2.1.0"

CliOutput.defaultFormatter(options?: { colors?: boolean }) builds the standard formatter. With no argument it auto-detects color support; pass colors to force it on or off. This is the value behind the Formatter reference’s default.

import { CliError, CliOutput } from "effect/unstable/cli"
const plain = CliOutput.defaultFormatter({ colors: false })
plain.formatVersion("my-tool", "1.2.3")
// => "my-tool v1.2.3"
plain.formatError(
new CliError.MissingOption({ option: "name" })
)
// => "\nERROR\n Missing required flag: --name"
const colored = CliOutput.defaultFormatter({ colors: true })
colored.formatVersion("my-tool", "1.2.3")
// => "\x1b[1mmy-tool\x1b[0m \x1b[2mv\x1b[0m\x1b[1m1.2.3\x1b[0m"

The help renderer produces sections in a fixed order — DESCRIPTION, USAGE, ARGUMENTS, FLAGS, GLOBAL FLAGS, SUBCOMMANDS, and EXAMPLES — measuring visible width after stripping ANSI codes so colored and plain output stay aligned.

CliOutput.layer(formatter: Formatter): Layer.Layer<never> provides a formatter, replacing the default. Provide it to Command.run to change how the whole CLI renders.

import { Effect } from "effect"
import { Command, CliOutput, Flag } from "effect/unstable/cli"
const cmd = Command.make("hi", { name: Flag.string("name") }, () =>
Effect.void)
const run = Command.run(cmd, { version: "1.0.0" }).pipe(
Effect.provide(CliOutput.layer(CliOutput.defaultFormatter({ colors: false })))
)
// => `--help`/`--version`/errors now render without ANSI colors

HelpDoc is the format-agnostic description of a command’s help. The runner builds a HelpDoc from your command tree and hands it to formatter.formatHelpDoc; the formatter decides layout, styling, and alignment. You only touch these types when writing a custom formatter — building or inspecting a doc rather than rendering it yourself.

The top-level shape. description, usage, and flags are always present; args, globalFlags, subcommands, and examples are optional and omitted when empty. annotations is a Context.Context<never> carrying custom command metadata.

import { Context, Option } from "effect"
import type { HelpDoc } from "effect/unstable/cli"
const doc: HelpDoc.HelpDoc = {
description: "Deploy your application",
usage: "myapp deploy <target>",
annotations: Context.empty(),
flags: [],
args: [
{
name: "target",
type: "string",
description: Option.some("Where to deploy"),
required: true,
variadic: false
}
]
}
// => consumed by formatter.formatHelpDoc(doc)

Describes one flag: its primary name (without the --), aliases, a type label like "string" / "boolean" / "integer", an Option<string> description, and whether it is required.

import { Option } from "effect"
import type { HelpDoc } from "effect/unstable/cli"
const verbose: HelpDoc.FlagDoc = {
name: "verbose",
aliases: ["-v"],
type: "boolean",
description: Option.some("Enable verbose output"),
required: false
}
// => renders as: --verbose, -v Enable verbose output

Describes one positional argument. Like FlagDoc but adds variadic (accepts multiple values) and has no aliases.

import { Option } from "effect"
import type { HelpDoc } from "effect/unstable/cli"
const files: HelpDoc.ArgDoc = {
name: "files",
type: "file",
description: Option.some("Files to process"),
required: false,
variadic: true
}
// => renders the name as "files..." in the ARGUMENTS table

Describes a single subcommand row: name, an optional alias, an optional shortDescription (preferred in listings when present), and a full description.

import type { HelpDoc } from "effect/unstable/cli"
const deploy: HelpDoc.SubcommandDoc = {
name: "deploy",
alias: "d",
shortDescription: "Deploy app",
description: "Deploy the application to the cloud"
}
// => renders as: deploy, d Deploy app

Groups subcommands under an optional heading. group: undefined is the default ungrouped SUBCOMMANDS section; a string becomes a named heading. commands is a non-empty array of SubcommandDoc.

import type { HelpDoc } from "effect/unstable/cli"
const group: HelpDoc.SubcommandGroupDoc = {
group: undefined, // ungrouped → "SUBCOMMANDS" heading
commands: [
{ name: "deploy", alias: "d", shortDescription: undefined, description: "Deploy" }
]
}

A concrete invocation example: a command string and an optional description rendered as a # comment above it in the EXAMPLES section.

import type { HelpDoc } from "effect/unstable/cli"
const example: HelpDoc.ExampleDoc = {
command: "myapp deploy production --force",
description: "Force a production deploy"
}
// => renders as:
// # Force a production deploy
// myapp deploy production --force

When parsing fails, the runner accumulates CliError values and prints them through the formatter. Every variant is a Schema.ErrorClass, so it carries structured fields (not just a string) and a _tag you can match on, and each computes a human-readable message getter.

You rarely construct these yourself; the parser does. But because they are tagged errors, you can catch them with Effect.catchTag when you embed a command’s effect inside a larger program rather than letting Command.run format and exit.

An unknown flag was passed. Fields: option, optional command (the command path it appeared in), and suggestions (close matches for a “Did you mean?” hint).

import { CliError } from "effect/unstable/cli"
new CliError.UnrecognizedOption({
option: "--forc",
command: ["deploy"],
suggestions: ["--force"]
}).message
// => "Unrecognized flag: --forc in command deploy
//
// Did you mean this?
// --force"

A flag name is defined on both a parent command and one of its subcommands. The parent always claims the flag, so this is reported as a definition problem. Fields: option, parentCommand, childCommand.

import { CliError } from "effect/unstable/cli"
new CliError.DuplicateOption({
option: "--verbose",
parentCommand: "myapp",
childCommand: "deploy"
}).message
// => 'Duplicate flag name "--verbose" in parent command "myapp" and subcommand
// "deploy". Parent will always claim this flag (Mode A semantics)...'

A required flag was not supplied. Field: option (the name, without --).

import { CliError } from "effect/unstable/cli"
new CliError.MissingOption({ option: "api-key" }).message
// => "Missing required flag: --api-key"

A required positional argument was not supplied. Field: argument.

import { CliError } from "effect/unstable/cli"
new CliError.MissingArgument({ argument: "target" }).message
// => "Missing required argument: target"

A flag or argument value failed validation (e.g. a non-integer passed to an integer flag). Fields: option, value, expected, and kind ("flag" | "argument"), which selects the message phrasing.

import { CliError } from "effect/unstable/cli"
new CliError.InvalidValue({
option: "port",
value: "abc",
expected: "integer between 1 and 65535",
kind: "flag"
}).message
// => 'Invalid value for flag --port: "abc". Expected: integer between 1 and 65535'
new CliError.InvalidValue({
option: "count",
value: "abc",
expected: "integer",
kind: "argument"
}).message
// => 'Invalid value for argument <count>: "abc". Expected: integer'

A subcommand name did not match any defined child. Fields: subcommand, optional parent (the parent command path), and suggestions.

import { CliError } from "effect/unstable/cli"
new CliError.UnknownSubcommand({
subcommand: "deplyo",
parent: ["myapp"],
suggestions: ["deploy", "destroy"]
}).message
// => 'Unknown subcommand "deplyo" for "myapp"
//
// Did you mean this?
// deploy
// destroy'

Wraps a failure thrown from inside a command handler so it travels in the CLI error channel. Field: cause (the original defect/error). Unlike the parse errors above, it has no custom message getter — inspect cause directly.

import { CliError } from "effect/unstable/cli"
const err = new CliError.UserError({
cause: new Error("Database connection failed")
})
err._tag // => "UserError"
err.cause // => Error: Database connection failed

A control-flow signal, not a parse failure. It is raised for explicit --help requests and for parse failures that should be shown alongside help. Fields: commandPath (which command’s help to render) and errors (a possibly-empty list of the non-help errors below). It exits with code 0 when errors is empty and 1 otherwise.

import { CliError } from "effect/unstable/cli"
const help = new CliError.ShowHelp({
commandPath: ["myapp", "deploy"],
errors: [new CliError.MissingOption({ option: "target" })]
})
help.message // => "Help requested"
help.errors.length // => 1 (so it exits with code 1)

The Schema.Union of every concrete error except ShowHelp. ShowHelp.errors is typed as an array of these, so a help screen can carry the underlying failures without nesting another help-control value. The corresponding type alias is CliError.NonShowHelpErrors.

import { CliError } from "effect/unstable/cli"
// Decode an unknown value into one of the concrete (non-ShowHelp) errors.
CliError.NonShowHelpErrors
// => Schema.Union of UnrecognizedOption | DuplicateOption | MissingOption
// | MissingArgument | InvalidValue | UnknownSubcommand | UserError

The union of every variant: UnrecognizedOption | DuplicateOption | MissingOption | MissingArgument | InvalidValue | UnknownSubcommand | ShowHelp | UserError. This is the error type that appears in Command.run’s signature.

A type guard, (u: unknown) => u is CliError, that checks for the internal CLI error brand. Handy at the boundary of a larger program where you have caught an unknown and want to distinguish CLI failures from other defects.

import { CliError } from "effect/unstable/cli"
const err = new CliError.MissingOption({ option: "name" })
CliError.isCliError(err) // => true
CliError.isCliError(new Error("boom")) // => false

Command.run formats these errors and sets the process exit code for you. But if you embed a command’s effect inside a bigger pipeline, the CliError surfaces in the typed error channel and you can recover from specific variants with Effect.catchTag:

import { Console, Effect } from "effect"
import { Command, CliError, Flag } from "effect/unstable/cli"
const cmd = Command.make("deploy", { target: Flag.string("target") }, () =>
Console.log("deploying..."))
const program = Command.run(cmd, { version: "1.0.0" }).pipe(
// Recover from a missing required flag instead of letting it exit.
Effect.catchTag("MissingOption", (error) =>
Console.error(`You forgot --${error.option}`)
)
)

See Catching Errors for the full set of recovery combinators (catch, catchTag, catchTags, catchReason).