Skip to content

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

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
})

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

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 subscription

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.

Two error types flow through the Terminal API:

  • QuitError comes only from readLine. 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.runMain treats an unhandled QuitError as a graceful exit.
  • PlatformError comes from display and 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
}

The Terminal namespace exports the service tag, its interface members, the input event types, and the cancellation error.

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 environment

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.Terminal

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
})

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
})

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)
})

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)

(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")
})

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 // => false
key.shift // => false

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>

(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())
// => true
Terminal.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