10·04CLI Patterns

A CLI command has three stages that benefit from being separate. Parsing turns `process.argv` into a typed command.

59 - CLI Patterns: Parse, Validate, Execute

Previous · Next

A CLI command has three stages that benefit from being separate. Parsing turns process.argv into a typed command.

Validation rejects nonsensical combinations. Execution runs the work, and that is the only stage that needs services.

This step covers the hand-rolled pattern that makes the seam obvious, then shows the @effect/cli builders that produce the same shape with help text, completions, and typed Options for free.

What you’ll work through

index.test.ts contains four beats: parsing argv into a typed discriminated union, running the handler that consumes the parsed command, rejecting an invalid command with a typed error, and the @effect/cli builders that produce the same shape with help text and typed Options for free.

By the end you should treat CLI handling as three independent stages — parse, validate, execute — and only the third needs services.


The Three Stages

process.argv ──► Parse ──► Command (ADT)
│
▼
Validate ──► Command (validated)
│
▼
Execute ──► Effect<void, E, R>
│
▼
Entrypoint wires services + runs

Parsing and validation depend on nothing. Execution depends on services.

The boundary is the entrypoint, which provides the layers and calls a runner.


A Command ADT

Creates a tagged-union value type, one variant per command. The parser produces one of these; the handler matches on _tag.

import { Effect, Data } from "effect"
class InvalidCommand extends Data.TaggedError("InvalidCommand")<{
readonly message: string
}> {}
type Command =
| { readonly _tag: "Greet"; readonly name: string; readonly shout: boolean }
| { readonly _tag: "Version" }
// ┌─── Effect<Command, InvalidCommand, never>
// ▼
const parseArgs = (argv: ReadonlyArray<string>) => {
if (argv[0] === "version") return Effect.succeed<Command>({ _tag: "Version" })
if (argv[0] === "greet") {
const name = argv[1] ?? "World"
const shout = argv.includes("--shout")
return Effect.succeed<Command>({ _tag: "Greet", name, shout })
}
return Effect.fail(
new InvalidCommand({ message: `Unknown command: ${argv[0] ?? "<empty>"}` }),
)
}

Details

The parser is service-free. Unit tests pass an array of strings; the entrypoint passes process.argv.slice(2).

Nothing else cares which is which.


Service-Driven Handlers

Configures the side-effects the handler needs as Context.Services, Terminal, UserService, Db, so the handler is testable with stub layers.

import { Context, Effect } from "effect"
class Terminal extends Context.Service<Terminal, { readonly writeLine: (line: string) => Effect.Effect<void> }>()("Terminal") {}
// ┌─── Effect<void, never, Terminal>
// ▼
const runCommand = (command: Command) =>
Effect.gen(function* () {
const terminal = yield* Terminal
if (command._tag === "Version") {
yield* terminal.writeLine("effect-by-effect 1.0.0")
return
}
const message = `Hello, ${command.name}!`
yield* terminal.writeLine(command.shout ? message.toUpperCase() : message)
})

When to Use

Every CLI with non-trivial logic. Even a one-flag command benefits from a Terminal tag, tests can assert on output without spying on console.log.


The Entrypoint

Wires the layers, runs the parsed command, exits with the appropriate code.

import { Effect, Layer } from "effect"
const TerminalLive = Layer.succeed(
Terminal,
Terminal.of({
writeLine: (line) => Effect.sync(() => console.log(line)),
}),
)
const main = (argv: ReadonlyArray<string>) =>
Effect.gen(function* () {
const command = yield* parseArgs(argv)
yield* runCommand(command)
})
await Effect.runPromise(main(process.argv.slice(2)).pipe(Effect.provide(TerminalLive)))

Details

runPromise rejects on failure; the Node runtime exits non-zero automatically. For finer control over exit codes, use runPromiseExit and switch on the Exit.


Adding a new subcommand

The Command ADT and runCommand handler are both keyed on _tag. Adding a new subcommand is three coordinated edits in one place: extend the discriminated union, extend parseArgs to recognise it, and extend runCommand with a new branch.

The compiler will refuse to compile until all three are in sync — switch exhaustiveness fails immediately when a variant is missing a handler.

import { Effect, Data } from "effect"
type Command =
| Data.Case & { _tag: "Greet"; name: string }
| Data.Case & { _tag: "Bye"; name: string }
| Data.Case & { _tag: "Echo"; message: string } // <-- new variant
const parseArgs = (argv: ReadonlyArray<string>) =>
argv[0] === "echo"
? Effect.succeed<Command>({ _tag: "Echo", message: argv.slice(1).join(" ") })
: // ...existing branches...
const runCommand = (cmd: Command) => {
switch (cmd._tag) {
case "Greet": return Console.log(`Hello, ${cmd.name}!`)
case "Bye": return Console.log(`Bye, ${cmd.name}!`)
case "Echo": return Console.log(cmd.message) // <-- new branch
}
}

The pattern stays linear. When the CLI is small enough, “one variant per command, one handler per variant” is the cheapest possible structure.

Once the count or the option surface grows, graduate to @effect/cli (next section).

Your turn add an `Echo` variant to the `Command` union, recognise it in `parseArgs`, and extend `runCommand` with the matching branch. Confirm the compiler complains until all three are aligned.

Graduating to @effect/cli

Past a handful of flags and subcommands, the hand-rolled parser becomes its own maintenance burden. @effect/cli provides typed Command, Args, and Options builders with built-in --help, --version, --wizard, and shell completion generation, while preserving the same parse-then-execute split shown above.

import { Args, Command, Options } from "@effect/cli"
import { NodeContext, NodeRuntime } from "@effect/platform-node"
import { Console, Effect } from "effect"
const name = Args.text({ name: "name" }).pipe(Args.withDefault("World"))
const shout = Options.boolean("shout").pipe(Options.withAlias("s"))
const greet = Command.make("greet", { name, shout }, ({ name, shout }) => {
const message = `Hello, ${name}!`
return Console.log(shout ? message.toUpperCase() : message)
})
const cli = Command.run(greet, { name: "greet", version: "1.0.0" })
cli(process.argv).pipe(Effect.provide(NodeContext.layer), NodeRuntime.runMain)

Details

  • Args.text, Args.integer, Args.repeated, Args.optional, Args.withDefault, positional arguments with full type inference.
  • Options.boolean, Options.text, Options.choice, Options.keyValueMap, Options.withAlias, Options.optional, named flags.
  • Command.make(name, config, handler), the handler receives a typed object whose keys match the config and whose values are the parsed types.
  • Command.withSubcommands([…]), composes a tree of subcommands; each child’s handler may yield the parent command to read its parsed config.
  • Command.run(command, { name, version }), produces a function that takes process.argv and returns an Effect<void>.

When to Use

The hand-rolled pattern teaches the shape. @effect/cli is the production tool.

Reach for it as soon as the CLI has more than two commands or any help-text requirement.

A Composite Example with Subcommands

Mirrors the minigit example shipped in repos/effect/packages/cli/examples/minigit.ts.

import { Args, Command, Options } from "@effect/cli"
import { NodeContext, NodeRuntime } from "@effect/platform-node"
import { Console, Effect } from "effect"
const root = Command.make("app", {}, () => Console.log("app"))
const add = Command.make(
"add",
{
paths: Args.text({ name: "path" }).pipe(Args.repeated),
verbose: Options.boolean("verbose").pipe(Options.withAlias("v")),
},
({ paths, verbose }) =>
Console.log(`add ${paths.join(" ")}${verbose ? " (verbose)" : ""}`),
)
const clone = Command.make(
"clone",
{
repository: Args.text({ name: "repository" }),
depth: Options.integer("depth").pipe(Options.optional),
},
({ repository, depth }) => Console.log(`clone ${repository} depth=${depth}`),
)
const cli = Command.run(root.pipe(Command.withSubcommands([add, clone])), {
name: "app",
version: "1.0.0",
})
cli(process.argv).pipe(Effect.provide(NodeContext.layer), NodeRuntime.runMain)

--help, --version, --wizard, and --completions come for free; no code in the handler changes when they are invoked.


Common Pitfalls

1, Mixing parsing and service access. Looking up a user from the DB to validate a CLI flag makes the parser depend on a Layer. Keep parsing pure; validate against services inside the handler effect.

2, Calling runPromise inside a handler. The handler returns an Effect; the entrypoint runs it. Calling runPromise mid-handler breaks composition and disables testing with stub layers.

3, console.log instead of a Terminal service. The hand-rolled handler should yield a Terminal tag; the @effect/cli handler should use Console.log from effect. Both route through the runtime; neither bypasses log levels or test capture.

4, Wide unknown from process.argv. Hand-rolled parsers tend to leave fields typed as string | undefined. Result narrow at the boundary or use @effect/cli’s typed Args and Options.


services. - [ ] Reject an invalid command with a typed `InvalidCommand` error. - [ ] Read the `@effect/cli` beat — same shape, with help text, completions, and typed `Options` for free.

See Also

  • Prerequisites, 21-context-basics, 23-service-driven, 24-advanced-di.
  • Next, 55-use-pattern.
  • Reference, repos/effect/packages/cli/README.md for the full builder API and repos/effect/packages/cli/examples/minigit.ts for a multi-subcommand walkthrough.

Run the tests: pnpm test 59-cli-patterns


The full test suite

The index.test.ts for this lesson, verbatim from steps/59-cli-patterns/.

index.test.ts
import { describe, expect, it } from "vite-plus/test"
import { Context, Data, Effect } from "effect"
// =============================================================================
// 33 - CLI Patterns: Parse, Validate, Execute
// =============================================================================
//
// Parser returns a Command ADT; handler depends on a Terminal service.
// Production CLIs replace the hand-rolled parser with @effect/cli — the
// shape of the handler is identical.
class InvalidCommand extends Data.TaggedError("InvalidCommand")<{
readonly message: string
}> {}
type Command =
| { readonly _tag: "Greet"; readonly name: string; readonly shout: boolean }
| { readonly _tag: "Version" }
// ┌─── Effect<Command, InvalidCommand, never>
// ▼
const parseArgs = (argv: ReadonlyArray<string>): Effect.Effect<Command, InvalidCommand> => {
if (argv[0] === "version") {
return Effect.succeed({ _tag: "Version" })
}
if (argv[0] === "greet") {
const name = argv[1] ?? "World"
const shout = argv.includes("--shout")
return Effect.succeed({ _tag: "Greet", name, shout })
}
return Effect.fail(
new InvalidCommand({ message: `Unknown command: ${argv[0] ?? "<empty>"}` }),
)
}
class Terminal extends Context.Service<Terminal, { readonly writeLine: (line: string) => Effect.Effect<void> }>()("Terminal") {}
// ┌─── Effect<void, never, Terminal>
// ▼
const runCommand = (command: Command) =>
Effect.gen(function* () {
const terminal = yield* Terminal
if (command._tag === "Version") {
yield* terminal.writeLine("effect-by-effect 1.0.0")
return
}
const message = `Hello, ${command.name}!`
yield* terminal.writeLine(command.shout ? message.toUpperCase() : message)
})
describe("33 - CLI Patterns", () => {
// @beat
// id: parse-command
// shape: one-todo
// title: Parse argv into a typed Command ADT
// teaches: A service-free parser turns process.argv into a tagged union; the handler matches on _tag.
// hint: the Greet variant has _tag "Greet" and carries the parsed name + the shout flag
it("parses argv into a typed Command", async () => {
const command = await Effect.runPromise(parseArgs(["greet", "Ada", "--shout"]))
// Predict the tagged Command value the parser produces.
expect(command).toEqual(/* @stub */ { _tag: "Greet", name: "Ada", shout: true } /* @end */)
})
// @end-beat
// @beat
// id: run-handler
// shape: one-todo
// title: Run the handler against a stub Terminal
// teaches: Side effects live behind a Context.Service so tests can replace Terminal with an in-memory buffer.
// hint: Greet without shout writes a single greeting line using the parsed name verbatim
it("runs a handler against a stub Terminal service", async () => {
const output: string[] = []
await Effect.runPromise(
runCommand({ _tag: "Greet", name: "Ada", shout: false }).pipe(
Effect.provideService(Terminal, {
writeLine: (line) =>
Effect.sync(() => {
output.push(line)
}),
}),
),
)
// Predict the captured output array after running Greet without --shout.
expect(output).toEqual(/* @stub */ ["Hello, Ada!"] /* @end */)
})
// @end-beat
// @beat
// id: invalid-command
// shape: one-todo
// title: Reject unknown verbs with a typed error
// teaches: Unknown subcommands become an InvalidCommand tag in E, never an uncaught throw at the entrypoint.
// hint: which Data.TaggedError above models a bad verb? that's the _tag the failure carries
it("fails with InvalidCommand for unknown verbs", async () => {
const exit = await Effect.runPromiseExit(parseArgs(["deploy"]))
expect(exit._tag).toBe("Failure")
if (exit._tag === "Failure" && exit.cause.reasons[0]?._tag === "Fail") {
// Predict the tag of the typed parser failure.
expect(exit.cause.reasons[0].error._tag).toBe(/* @stub */ "InvalidCommand" /* @end */)
}
})
// @end-beat
// @beat
// id: add-command
// shape: one-todo
// title: Add a new command to the parser and handler
// teaches: The Command ADT and runCommand handler are both closed over _tag — adding a command means adding a variant to the union, extending parseArgs, and extending runCommand with a new branch.
// hint: add { _tag: "Help" } to the Command type, parse "help" in parseArgs, and return terminal.writeLine("Usage: ...") in runCommand
it("parses and runs a Help command added to the ADT", async () => {
type ExtendedCommand =
| Command
| { readonly _tag: "Help" }
const parseExtended = (
argv: ReadonlyArray<string>,
): Effect.Effect<ExtendedCommand, InvalidCommand> => {
if (argv[0] === "help") {
return Effect.succeed({ _tag: "Help" })
}
return parseArgs(argv)
}
const runExtended = (command: ExtendedCommand) =>
Effect.gen(function* () {
if (command._tag === "Help") {
const terminal = yield* Terminal
yield* terminal.writeLine("Usage: greet <name> [--shout] | version | help")
return
}
yield* runCommand(command as Command)
})
const output: string[] = []
// Your turn: parse ["help"] with parseExtended, then run it with runExtended
// and a stub Terminal that captures output.
await Effect.runPromise(
/* @stub */ Effect.gen(function* () {
const cmd = yield* parseExtended(["help"])
yield* runExtended(cmd)
}).pipe(
Effect.provideService(Terminal, {
writeLine: (line) =>
Effect.sync(() => {
output.push(line)
}),
}),
) /* @end */,
)
expect(output[0]).toContain("Usage:")
})
// @end-beat
})
← PreviousObservabilityNext →Runtime