Terminal
The Terminal service abstracts standard input and output: reading a line of
text, reading raw key events, displaying messages, and inspecting the terminal
dimensions. It is the foundation for interactive command-line programs. Provide
NodeTerminal.layer on Node.js, then write your program against the abstract
Terminal interface.
import { NodeRuntime, NodeTerminal } from "@effect/platform-node"import { Effect, Terminal } from "effect"
const greet = Effect.gen(function*() { const terminal = yield* Terminal.Terminal
// `display` writes text to stdout. It does not add a newline, so include // one yourself when you want the cursor to move down. yield* terminal.display("What is your name? ")
// `readLine` reads one line from stdin. It fails with `QuitError` if the // user cancels (typically Ctrl+C), which is why it lives in the error // channel rather than throwing. const name = yield* terminal.readLine
yield* terminal.display(`Hello, ${name.trim()}!\n`)})
NodeRuntime.runMain(greet.pipe(Effect.provide(NodeTerminal.layer)))Reading input
Section titled “Reading input”readLine is the workhorse for prompts: it resolves with the typed line and
fails with Terminal.QuitError when the user quits. Build
prompt loops by recursing on the effect — validation failures simply re-prompt:
import { Effect, PlatformError, Terminal } from "effect"
// Repeatedly prompt until the user enters an integer in range.const promptNumber = Effect.fn("promptNumber")(function*( message: string): Effect.Effect<number, Terminal.QuitError | PlatformError.PlatformError> { const terminal = yield* Terminal.Terminal
yield* terminal.display(`${message} `) const input = yield* terminal.readLine
const parsed = Number.parseInt(input.trim(), 10)
if (Number.isNaN(parsed) || parsed < 1 || parsed > 100) { // Re-prompt on invalid input. yield* terminal.display("Please enter an integer from 1 to 100.\n") return yield* promptNumber(message) }
return parsed})A complete interactive program
Section titled “A complete interactive program”Putting prompting and output together, here is a number-guessing game written in
idiomatic v4 style. The secret is drawn from Random (never Math.random), and
the game loop is a self-recursive effect:
import { NodeRuntime, NodeTerminal } from "@effect/platform-node"import { Effect, PlatformError, Random, Terminal } from "effect"
const guessingGame = Effect.gen(function*() { const terminal = yield* Terminal.Terminal
// Draw the secret from the Effect Random service for determinism in tests. // `nextIntBetween` is inclusive on both ends by default. const secret = yield* Random.nextIntBetween(1, 100)
const loop: Effect.Effect< void, Terminal.QuitError | PlatformError.PlatformError > = Effect.gen(function*() { yield* terminal.display("Guess a number (1-100): ") const guess = Number.parseInt((yield* terminal.readLine).trim(), 10)
if (guess === secret) { yield* terminal.display("Correct! Well played.\n") } else { yield* terminal.display(guess < secret ? "Too low.\n" : "Too high.\n") yield* loop // keep going until the guess is right } })
yield* terminal.display("I'm thinking of a number...\n") yield* loop})
NodeRuntime.runMain(guessingGame.pipe(Effect.provide(NodeTerminal.layer)))Raw key events
Section titled “Raw key events”When a line-based prompt is not enough — for menus, single-keystroke commands,
or custom editors — use readInput. It yields a scoped Queue
of UserInput events, each carrying the raw character (as an Option) and
parsed key metadata including modifier state:
import { Effect, Option, Queue, Terminal } from "effect"
const readKeys = Effect.gen(function*() { const terminal = yield* Terminal.Terminal
// `readInput` is scoped: the input subscription is released when the scope // closes. `Queue.take` blocks until the next key event arrives. const queue = yield* terminal.readInput
yield* terminal.display("Press keys (q to quit)...\n")
const loop: Effect.Effect<void> = Effect.gen(function*() { const event = yield* Queue.take(queue)
const char = Option.getOrElse(event.input, () => "") yield* terminal.display( `key=${event.key.name} char=${JSON.stringify(char)} ctrl=${event.key.ctrl}\n` )
if (event.key.name !== "q") yield* loop })
yield* loop}).pipe(Effect.scoped) // provide and close the scope that owns the subscriptionTerminal dimensions
Section titled “Terminal dimensions”columns and rows report the size of the terminal, which is useful for
formatting output such as progress bars or tables:
import { Effect, Terminal } from "effect"
const printSize = Effect.gen(function*() { const terminal = yield* Terminal.Terminal const columns = yield* terminal.columns const rows = yield* terminal.rows yield* terminal.display(`Terminal is ${columns}x${rows}\n`)})For richer command-line applications — argument parsing, subcommands, and
built-in prompts — see the CLI section, which builds on Terminal.
Error handling
Section titled “Error handling”Two error types flow through the Terminal API:
QuitErrorcomes only fromreadLine. It signals user-requested cancellation (usually Ctrl+C). Because it is a typed failure, you decide what happens: re-prompt, clean up, or let it propagate.NodeRuntime.runMaintreats an unhandledQuitErroras a graceful exit.PlatformErrorcomes fromdisplayand represents an underlying I/O failure when writing to stdout.
import { Effect, Terminal } from "effect"
const ask = Effect.gen(function*() { const terminal = yield* Terminal.Terminal yield* terminal.display("Continue? ") return yield* terminal.readLine}).pipe( // Handle cancellation by tag instead of letting it propagate. Effect.catchTag("QuitError", () => Effect.succeed("n")), // Handle a write failure on `display`. Effect.catchTag("PlatformError", (error) => Effect.die(error) ))When the failure type is unknown (for example after Effect.catch), use the
isQuitError guard to narrow it:
import { Terminal } from "effect"
declare const error: unknown
if (Terminal.isQuitError(error)) { // error is now narrowed to Terminal.QuitError}Reference
Section titled “Reference”The Terminal namespace exports the service tag, its interface members, the
input event types, and the cancellation error.
Terminal (service tag)
Section titled “Terminal (service tag)”Context.Service tag for the terminal capability. Access it in a generator
with yield*, then use its members. Platform layers such as
NodeTerminal.layer provide the concrete implementation.
import { Effect, Terminal } from "effect"
const program = Effect.gen(function*() { const terminal = yield* Terminal.Terminal yield* terminal.display("ready\n")})// => requires Terminal in its environmentTerminal (interface)
Section titled “Terminal (interface)”The shape provided by every implementation. Its members are documented below.
import type { Terminal } from "effect"
// interface Terminal {// readonly columns: Effect<number>// readonly rows: Effect<number>// readonly readInput: Effect<Queue.Dequeue<UserInput, Cause.Done>, never, Scope>// readonly readLine: Effect<string, QuitError>// readonly display: (text: string) => Effect<void, PlatformError>// }declare const _: Terminal.Terminalterminal.columns
Section titled “terminal.columns”An Effect<number> resolving to the number of columns of the terminal.
import { Effect, Terminal } from "effect"
Effect.gen(function*() { const terminal = yield* Terminal.Terminal const columns = yield* terminal.columns // => 80})terminal.rows
Section titled “terminal.rows”An Effect<number> resolving to the number of rows of the terminal.
import { Effect, Terminal } from "effect"
Effect.gen(function*() { const terminal = yield* Terminal.Terminal const rows = yield* terminal.rows // => 24})terminal.readLine
Section titled “terminal.readLine”Effect<string, QuitError> that reads a single line from stdin. Fails with
QuitError if the user requests to quit (Ctrl+C).
import { Effect, Terminal } from "effect"
Effect.gen(function*() { const terminal = yield* Terminal.Terminal const line = yield* terminal.readLine // => "the text the user typed" (without the trailing newline)})terminal.readInput
Section titled “terminal.readInput”Effect<Queue.Dequeue<UserInput, Cause.Done>, never, Scope> that subscribes to
raw input events. It is scoped — the subscription is released when the scope
closes — so run it under Effect.scoped. The dequeue completes with
Cause.Done when input ends.
import { Effect, Queue, Terminal } from "effect"
Effect.gen(function*() { const terminal = yield* Terminal.Terminal const queue = yield* terminal.readInput const event = yield* Queue.take(queue) // => UserInput { input: Option<string>, key: Key }}).pipe(Effect.scoped)terminal.display
Section titled “terminal.display”(text: string) => Effect<void, PlatformError> that writes text to stdout. It
does not append a newline — include \n yourself when you want one.
import { Effect, Terminal } from "effect"
Effect.gen(function*() { const terminal = yield* Terminal.Terminal yield* terminal.display("no newline here") yield* terminal.display("…and this continues on the same line\n")})UserInput
Section titled “UserInput”The event type produced by readInput. It carries the raw character (input,
an Option<string> — absent for non-printing keys) and the parsed key.
import { Option, type Terminal } from "effect"
declare const event: Terminal.UserInput
const char = Option.getOrElse(event.input, () => "")// => "a" (or "" for keys like Enter / arrows)const name = event.key.name// => "a"Parsed key metadata on each UserInput: the key name plus the ctrl,
meta, and shift modifier flags.
import { type Terminal } from "effect"
declare const key: Terminal.Key
key.name // => "c"key.ctrl // => true (e.g. when Ctrl+C was pressed)key.meta // => falsekey.shift // => falseQuitError
Section titled “QuitError”The typed error produced by readLine when the user quits (usually Ctrl+C). It
is a Schema.ErrorClass with _tag: "QuitError", so it can be matched with
Effect.catchTag("QuitError", ...).
import { Effect, Terminal } from "effect"
const handled = Effect.gen(function*() { const terminal = yield* Terminal.Terminal return yield* terminal.readLine}).pipe( Effect.catchTag("QuitError", () => Effect.succeed("<cancelled>")))// => Effect<string, never, Terminal>isQuitError
Section titled “isQuitError”(u: unknown) => u is QuitError — a type guard that narrows an unknown value to
QuitError. Useful when a failure has been widened to unknown.
import { Terminal } from "effect"
Terminal.isQuitError(new Terminal.QuitError())// => trueTerminal.isQuitError(new Error("boom"))// => false(impl) => Terminal constructs a Terminal implementation from the concrete
capabilities (columns, rows, readInput, readLine, display). Use it
when writing a platform adapter or a test implementation. Most applications use
NodeTerminal.layer instead of calling make directly.
import { Cause, Effect, Queue, Terminal } from "effect"
// A minimal test terminal that feeds canned input.const testTerminal = Terminal.make({ columns: Effect.succeed(80), rows: Effect.succeed(24), readLine: Effect.succeed("hello"), // `readInput` returns a `Queue.Dequeue<UserInput, Cause.Done>`. readInput: Queue.make<Terminal.UserInput, Cause.Done>(), display: (_text) => Effect.void})// => Terminal