Skip to content

Interactive Prompts

The Prompt module (effect/unstable/cli) builds interactive terminal questions: text fields, password masks, confirmations, single- and multi-select menus, autocomplete, numbers, dates, and file pickers. A prompt renders frames, reads keyboard input, validates the response, and produces a value.

The key idea is that a Prompt<A> is an Effect:

interface Prompt<Output>
extends Effect.Effect<Output, Terminal.QuitError, Prompt.Environment> {}

So you run a prompt by yield*-ing it inside any handler, exactly like any other effect. It may fail with Terminal.QuitError (the user pressed Ctrl-C or quit), and it requires the prompt EnvironmentTerminal, FileSystem, and Path — which NodeServices.layer provides.

The typical use is a command handler that asks for input it does not already have on the command line, then uses the result:

init.ts
import { NodeRuntime, NodeServices } from "@effect/platform-node"
import { Console, Effect } from "effect"
import { Command, Prompt } from "effect/unstable/cli"
const init = Command.make("init", {}, () =>
Effect.gen(function*() {
// A prompt is just an Effect — yield* it to get its value.
const name = yield* Prompt.text({
message: "Project name:"
})
const private_ = yield* Prompt.confirm({
message: "Make the repository private?",
initial: false
})
yield* Console.log(
`Creating ${private_ ? "private" : "public"} project "${name}"…`
)
})
)
init.pipe(
Command.run({ version: "1.0.0" }),
// NodeServices.layer provides Terminal + FileSystem + Path, which every
// prompt needs.
Effect.provide(NodeServices.layer),
NodeRuntime.runMain
)

Instead of writing the prompt by hand in the handler, you can attach it to a flag or argument with withFallbackPrompt: if the value is missing on the command line, the prompt runs to fill it in. This keeps the handler unaware of where the value came from. See Arguments & flags.

import { Flag, Prompt } from "effect/unstable/cli"
// `--name` if provided, otherwise prompt for it.
const name = Flag.string("name").pipe(
Flag.withFallbackPrompt(Prompt.text({ message: "Project name:" }))
)

The fallback only triggers for missing required input — an invalid value still fails normally, and cancelling the prompt re-fails with the original missing error.

Any prompt is an effect, so you can also run it directly (Prompt.run is the explicit form, but yield* works because prompts are Effects):

import { Effect } from "effect"
import { Prompt } from "effect/unstable/cli"
const program = Effect.gen(function*() {
const answer = yield* Prompt.text({ message: "Your name:" })
// ...use answer
})
// equivalent explicit execution
const program2 = Prompt.run(Prompt.text({ message: "Your name:" }))

Every constructor below returns a Prompt<A>. Run them with yield*.

A single-line text field that echoes input. TextOptions: message, default?, and an effectful validate? that returns the cleaned value or fails with a string error message shown to the user.

import { Effect } from "effect"
import { Prompt } from "effect/unstable/cli"
const slug = Prompt.text({
message: "Package slug:",
default: "my-app",
validate: (value) =>
/^[a-z0-9-]+$/.test(value)
? Effect.succeed(value)
: Effect.fail("Use only lowercase letters, digits, and dashes")
})
// => Prompt<string>

A text field that masks typed characters and returns the value wrapped in Redacted, so the secret is not accidentally logged. Same TextOptions as Prompt.text.

import { Prompt } from "effect/unstable/cli"
import { Redacted } from "effect"
const secret = Prompt.password({ message: "API token:" })
// => Prompt<Redacted>
// unwrap only at the boundary that needs the secret:
// const raw = Redacted.value(yield* secret)

Like Prompt.password but shows nothing at all as you type (no mask characters). Also returns a Redacted.

import { Prompt } from "effect/unstable/cli"
const passphrase = Prompt.hidden({ message: "Passphrase:" })
// => Prompt<Redacted>

A yes/no question submitted with a single keypress. ConfirmOptions: message, initial? (defaults to false), label? (the { confirm, deny } text shown after answering — defaults "yes" / "no"), and placeholder? ({ defaultConfirm, defaultDeny }, defaults "(Y/n)" / "(y/N)").

import { Prompt } from "effect/unstable/cli"
const overwrite = Prompt.confirm({
message: "File exists. Overwrite?",
initial: false,
label: { confirm: "overwrite", deny: "keep" }
})
// => Prompt<boolean>

An interactive on/off switch you flip with the arrow keys, then submit with Enter — unlike confirm, which submits immediately. ToggleOptions: message, initial? (false), active? ("on"), inactive? ("off").

import { Prompt } from "effect/unstable/cli"
const analytics = Prompt.toggle({
message: "Telemetry:",
initial: true,
active: "enabled",
inactive: "disabled"
})
// => Prompt<boolean>

Pick one value from a list. SelectOptions<A>: message, choices (SelectChoice<A>[]), and maxPerPage? (defaults to 10). Each SelectChoice<A> has { title, value, description?, disabled?, selected? }; at most one choice may set selected: true as the default highlight.

import { Prompt } from "effect/unstable/cli"
const license = Prompt.select({
message: "Choose a license:",
choices: [
{ title: "MIT", value: "mit", description: "Permissive", selected: true },
{ title: "Apache 2.0", value: "apache-2.0" },
{ title: "GPL 3.0", value: "gpl-3.0" },
{ title: "Proprietary", value: "none", disabled: true }
]
})
// => Prompt<"mit" | "apache-2.0" | "gpl-3.0" | "none">

Pick several values; returns them as an array. Takes SelectOptions<A> plus MultiSelectOptions: selectAll? / selectNone? / inverseSelection? (bulk command labels) and min? / max? selection counts. Mark defaults with selected: true on individual choices.

import { Prompt } from "effect/unstable/cli"
const features = Prompt.multiSelect({
message: "Enable features:",
min: 1,
choices: [
{ title: "TypeScript", value: "ts", selected: true },
{ title: "ESLint", value: "eslint" },
{ title: "Prettier", value: "prettier" }
]
})
// => Prompt<Array<"ts" | "eslint" | "prettier">>

A select menu the user can filter by typing. Extends SelectOptions<A> with filterLabel? ("filter"), filterPlaceholder? ("type to filter"), and emptyMessage? ("No matches").

import { Prompt } from "effect/unstable/cli"
const language = Prompt.autoComplete({
message: "Primary language:",
filterPlaceholder: "start typing…",
choices: [
{ title: "TypeScript", value: "ts" },
{ title: "Rust", value: "rs" },
{ title: "Kotlin", value: "kt" }
]
})
// => Prompt<"ts" | "rs" | "kt">

Whole-number entry with arrow-key stepping. IntegerOptions: message, default? (0), min? / max? bounds, incrementBy? / decrementBy? (arrow step, default 1), and an effectful validate?. Values outside min/max are rejected automatically.

import { Prompt } from "effect/unstable/cli"
const port = Prompt.integer({
message: "Port:",
default: 3000,
min: 1,
max: 65535
})
// => Prompt<number>

Decimal entry. Extends IntegerOptions with precision? (number of decimal places, default 2).

import { Prompt } from "effect/unstable/cli"
const ratio = Prompt.float({
message: "Sample rate (0–1):",
default: 0.5,
min: 0,
max: 1,
precision: 3
})
// => Prompt<number>

Edit a formatted date/time field part by part. DateOptions: message, initial? (defaults to the current Date), dateMask? (defaults to "YYYY-MM-DD HH:mm:ss"), effectful validate?, and locales? for custom month and weekday names.

import { Prompt } from "effect/unstable/cli"
const when = Prompt.date({
message: "Release date:",
dateMask: "YYYY-MM-DD"
})
// => Prompt<Date>

A text field whose submitted value is split into an array of strings. Extends TextOptions with delimiter? (defaults to ",").

import { Prompt } from "effect/unstable/cli"
const tags = Prompt.list({
message: "Tags (comma-separated):",
delimiter: ","
})
// => Prompt<Array<string>> e.g. "a, b" => ["a", " b"]

A file-system browser that returns the selected path. FileOptions (all optional): type? ("file" | "directory" | "either", default "file"), message? ("Choose a file"), startingPath? (default: cwd), default?, maxPerPage? (10), and filter? — a predicate or effect that keeps an entry when it returns true.

import { Prompt } from "effect/unstable/cli"
const config = Prompt.file({
message: "Pick a config file:",
type: "file",
filter: (file) => file.endsWith(".json")
})
// => Prompt<string> e.g. "/home/me/project/tsconfig.json"

Because prompts compose, you can transform results, chain dependent prompts, and collect several at once.

Transform the output value of a prompt.

import { Prompt } from "effect/unstable/cli"
const upper = Prompt.text({ message: "Name:" }).pipe(
Prompt.map((name) => name.toUpperCase())
)
// => Prompt<string>

Use one prompt’s output to build the next one — a wizard where later questions depend on earlier answers.

import { Prompt } from "effect/unstable/cli"
const driver = Prompt.select({
message: "Database:",
choices: [
{ title: "PostgreSQL", value: "pg" as const },
{ title: "SQLite", value: "sqlite" as const }
]
}).pipe(
Prompt.flatMap((db) =>
db === "pg"
? Prompt.text({ message: "Connection URL:" })
: Prompt.file({ message: "Database file:", type: "file" })
)
)
// => Prompt<string>

Run several prompts in sequence and collect their results, preserving the input shape — a tuple or a struct.

import { Prompt } from "effect/unstable/cli"
// As a struct -> Prompt<{ username: string; password: Redacted }>
const credentials = Prompt.all({
username: Prompt.text({ message: "Username:" }),
password: Prompt.password({ message: "Password:" })
})
// As a tuple -> Prompt<[string, boolean]>
const pair = Prompt.all([
Prompt.text({ message: "Name:" }),
Prompt.confirm({ message: "Continue?" })
])

A constant prompt that immediately yields a value without touching the terminal. Useful as a branch in flatMap or Prompt.all when no input is needed.

import { Prompt } from "effect/unstable/cli"
const noop = Prompt.succeed(42)
// => Prompt<number>, resolves to 42 with no rendering

Execute a prompt, producing Effect<Output, Terminal.QuitError, Environment>. Equivalent to yield*-ing the prompt; use it when you want the effect explicitly (e.g. to pass it somewhere expecting an Effect).

import { Prompt } from "effect/unstable/cli"
const program = Prompt.run(Prompt.text({ message: "Name:" }))
// => Effect<string, Terminal.QuitError, Prompt.Environment>

Type guard that returns true if a value is a Prompt.

import { Prompt } from "effect/unstable/cli"
Prompt.isPrompt(Prompt.text({ message: "x" })) // => true
Prompt.isPrompt(42) // => false

Build a bespoke prompt from an initial state (a value or an effect computing it) and a set of Handlers<State, Output>. The prompt runs as a render loop: render(state, action) returns ANSI output for the current frame, the terminal reads a Terminal.UserInput, process(input, state) returns the next Prompt.Action, and clear(state, action) returns ANSI output that erases the previous frame.

Action<State, Output> is a Data.TaggedEnum with three cases: Beep (reject input with a bell), NextFrame({ state }) (advance to a new state), and Submit({ value }) (finish with an output value).

import { Effect } from "effect"
import { Prompt } from "effect/unstable/cli"
// Sketch: most fields render/clear ANSI strings and inspect input.key.name.
declare const handlers: Prompt.Handlers<number, number>
const counter: Prompt.Prompt<number> = Prompt.custom(0, handlers)
// => Prompt<number>

Arguments & flags

Attach a prompt to a flag or argument with withFallbackPrompt so it only runs when the value is missing. Read more