Create your own
Lesson illustration

Consistent Rendering of Typed Results for Terminal Output

Welcome back. Your CLI and interactive REPL now share command handlers, parse quoted input, provide help and completion, and keep running after both recoverable failures and Ctrl+C cancellation. The remaining piece is making each outcome understandable at the terminal.

This lesson establishes a rendering boundary: application handlers return typed success or failure values, while terminal-facing code translates those values into concise, consistent text. That distinction matters for an instrumentation tool: “no process found,” “command cancelled,” and “provider unavailable” require different guidance, but none should leak a stack trace or Frida implementation detail into normal output.


Rendering is an interface concern

A typed result says what happened to the application:

export type Result<Value, Error> =
  | Readonly<{
      ok: true;
      value: Value;
    }>
  | Readonly<{
      ok: false;
      error: Error;
    }>;

It does not say exactly what should appear in a terminal. The same result could eventually be rendered in an interactive REPL, emitted as structured data for automation, or presented by another interface. For now, keep the application focused on typed facts and let the terminal adapter own human-facing wording.

A useful division of responsibilities is:

Layer or componentResponsibility
Application handlerReturn a typed Result describing success or an expected failure
Feature-specific terminal presenterTranslate a specific success/error union into terminal-oriented text
Shared terminal rendererApply consistent layout, stream selection, and optional styling
Node terminal adapterWrite to stdout or stderr; determine color capability
REPL safety boundaryRender unexpected exceptions without terminating the REPL

This preserves the boundary developed throughout the course. A SelectProcessHandler should not call console.log, and a generic terminal renderer should not need to know what a ProcessId or a Frida session is.

For a process-analysis tool, this separation also prevents a subtle maintenance problem: if each command writes its own text, messages gradually diverge.

Process selected: 8412
Attached to process successfully!
Recording is ON
Could not attach. :(

All four may be understandable in isolation, but they do not form a dependable interface. Users need to quickly distinguish:

  • data returned by a query,
  • confirmation that a state-changing command succeeded,
  • an expected problem they can correct,
  • cancellation they explicitly requested, and
  • an unexpected defect.

Before implementing the model, review the output and error principles that motivate it.

Clig Command Line Interface Guidelines

Read the “Output” and “Errors” sections of the Command Line Interface Guidelines (CLIG). They provide practical criteria for human-readable command output: concise success messages, intentional color, useful error wording, and a high signal-to-noise ratio.

In the “Output” section, begin with the paragraph starting the stream principle; read the surrounding discussion through the guidance on brief success output and state changes. Later in that section, read the two paragraphs beginning with intentional color, followed by the rules for disabling color outside an interactive terminal. In the “Errors” section, read the whole short section, paying particular attention to the advice that prioritizes useful information.

Two policies follow naturally.

  1. Successful query data belongs on standard output. A process list is useful output that a person may inspect or later pipe elsewhere.
  2. Failures and non-data diagnostics belong on standard error. This includes invalid input, unavailable services, and cancellation messages. Keeping them off standard output avoids mixing diagnostics with useful result data.

In an interactive REPL, both streams are visible in the same terminal, so the user still sees an ordinary conversation. The split becomes important when one-shot CLI commands are redirected or automated.


Define a small terminal presentation model

Do not let every feature invent its own writer calls. Instead, feature presenters should produce one small, typed presentation model. The shared renderer handles the common mechanics.

// src/interfaces/terminal/rendering/command-presentation.ts

export type CommandPresentation =
  | Readonly<{
      kind: "success";
      title: string | undefined;
      details: readonly string[];
      hints: readonly string[];
    }>
  | Readonly<{
      kind: "failure";
      title: string;
      details: readonly string[];
      hints: readonly string[];
    }>;

export function successPresentation(
  details: readonly string[],
  options: Readonly<{
    title?: string;
    hints?: readonly string[];
  }> = {},
): CommandPresentation {
  return {
    kind: "success",
    title: options.title,
    details,
    hints: options.hints ?? [],
  };
}

export function failurePresentation(
  title: string,
  options: Readonly<{
    details?: readonly string[];
    hints?: readonly string[];
  }> = {},
): CommandPresentation {
  return {
    kind: "failure",
    title,
    details: options.details ?? [],
    hints: options.hints ?? [],
  };
}

The model deliberately contains text, not raw domain objects. It is the output of a presenter, whose purpose is to make feature-specific facts understandable to a human.

Its fields have stable roles:

FieldUse
titleThe primary result in one concise sentence
detailsSupporting context that aids interpretation
hintsA corrective or useful next action
kindDetermines stream and semantic styling
success with no titleUseful for query output where the data itself is the result

For example, selecting a process is a state change. The user needs an explicit confirmation because the selected target becomes important context for later commands:

Selected target: explorer.exe (PID 8412)
Try: attach

A process-list query should normally avoid a decorative “Success” heading:

PID     NAME
8412    explorer.exe
11240   fixture.exe

That restraint keeps the REPL readable and gives a future non-interactive command a clean standard-output contract. In contrast, a failure should state the problem and offer a next action:

No local process with PID 8412 was found.
Try: list-processes --filter explorer

Avoid generic success output such as Done when the command changed meaningful state. Also avoid placing only developer-oriented context in normal output, such as stack traces, raw host exceptions, or Frida transport details.


Keep presentation specific; keep rendering generic

Consider a typed result from a target-selection use case. The application names conditions precisely without deciding on terminal copy:

export type SelectTargetError =
  | Readonly<{
      kind: "invalid-process-id";
      input: string;
    }>
  | Readonly<{
      kind: "process-not-found";
      pid: ProcessId;
    }>
  | Readonly<{
      kind: "process-provider-unavailable";
    }>
  | Readonly<{
      kind: "command-cancelled";
      reason: "user-interrupt";
    }>;

export type SelectedTarget = Readonly<{
  pid: ProcessId;
  executableName: string;
}>;

The terminal presenter belongs within the process-selection interface slice, not in the application handler:

// src/interfaces/terminal/commands/select-target-presenter.ts

export type ResultPresenter<Value, Error> = Readonly<{
  presentSuccess(value: Value): CommandPresentation;
  presentFailure(error: Error): CommandPresentation;
}>;

export const selectTargetPresenter: ResultPresenter<
  SelectedTarget,
  SelectTargetError
> = {
  presentSuccess(target): CommandPresentation {
    return successPresentation(
      [
        `Selected target: ${target.executableName} (PID ${target.pid})`,
      ],
      {
        hints: ["Try: attach"],
      },
    );
  },

  presentFailure(error): CommandPresentation {
    switch (error.kind) {
      case "invalid-process-id":
        return failurePresentation(
          `"${error.input}" is not a valid process ID.`,
          {
            hints: ["Enter a positive numeric PID."],
          },
        );

      case "process-not-found":
        return failurePresentation(
          `No local process with PID ${error.pid} was found.`,
          {
            hints: ["Try: list-processes"],
          },
        );

      case "process-provider-unavailable":
        return failurePresentation(
          "Process discovery is currently unavailable.",
          {
            hints: ["Check that the local instrumentation service is running."],
          },
        );

      case "command-cancelled":
        return failurePresentation("Command cancelled.");

      default:
        return assertNever(error);
    }
  },
};

function assertNever(value: never): never {
  throw new Error(
    `Unhandled terminal presentation value: ${JSON.stringify(value)}`,
  );
}

The switch narrows error by its kind discriminant. Within the "process-not-found" branch, for example, TypeScript knows that pid exists; within "command-cancelled", it knows that the cancellation reason is available. The assertNever call makes this switch exhaustive. If a later feature adds another SelectTargetError variant, TypeScript reports that this terminal presenter has not decided how to communicate it.

Notice what the presenter does not do:

  • It does not call a process provider.
  • It does not parse terminal arguments.
  • It does not inspect AbortSignal.
  • It does not write directly to stdout or stderr.
  • It does not display an underlying exception’s message field verbatim.

That final point is particularly important. Messages from operating-system APIs, third-party libraries, and target-process operations are often unstable, overly technical, or contain sensitive local paths. A typed error kind gives the presenter a controlled, useful explanation. Later, diagnostic logging can retain the original cause separately.

The following video gives a concise complementary example of consuming a typed result and mapping internal error cases to user-facing messages.

TypeScript & JavaScript error handling is pretty bad (and how to make it better)

Watch “TypeScript & JavaScript error handling is pretty bad (and how to make it better)” by Brett Codes. This segment focuses on the exact transition needed here: from a typed result to a user-friendly message.

Watch result consumption. Focus on the separation between internal error reasons and the text shown to the user, and on why an explicit success/failure branch is clearer than allowing expected conditions to escape as exceptions.


Render every typed result through one path

The generic renderer should make the success/failure decision once. It should not know the error union of every command.

First, define terminal dependencies small enough to fake in tests:

// src/interfaces/terminal/rendering/terminal-writer.ts

export type OutputStream = "stdout" | "stderr";

export type TerminalWriter = Readonly<{
  writeLine(stream: OutputStream, text: string): void;
}>;

export type TerminalTone =
  | "normal"
  | "success"
  | "error"
  | "muted";

export type TerminalStyler = Readonly<{
  style(tone: TerminalTone, text: string): string;
}>;

The TerminalStyler produces ANSI escape sequences only when allowed. The renderer need not depend on Node globals such as process.stdout.isTTY, NO_COLOR, or TERM; a Node-specific composition root can choose either an ANSI styler or an identity styler.

// src/interfaces/terminal/rendering/render-command-presentation.ts

export function renderCommandPresentation(
  presentation: CommandPresentation,
  writer: TerminalWriter,
  styler: TerminalStyler,
): void {
  const stream: OutputStream =
    presentation.kind === "failure" ? "stderr" : "stdout";

  const titleTone: TerminalTone =
    presentation.kind === "failure" ? "error" : "success";

  if (presentation.title !== undefined) {
    writer.writeLine(
      stream,
      styler.style(titleTone, presentation.title),
    );
  }

  for (const detail of presentation.details) {
    writer.writeLine(
      stream,
      styler.style("normal", detail),
    );
  }

  for (const hint of presentation.hints) {
    writer.writeLine(
      stream,
      styler.style("muted", hint),
    );
  }
}

Then add a generic bridge from a typed Result to that renderer:

// src/interfaces/terminal/rendering/render-result.ts

export function renderResult<Value, Error>(
  result: Result<Value, Error>,
  presenter: ResultPresenter<Value, Error>,
  writer: TerminalWriter,
  styler: TerminalStyler,
): void {
  const presentation = result.ok
    ? presenter.presentSuccess(result.value)
    : presenter.presentFailure(result.error);

  renderCommandPresentation(presentation, writer, styler);
}

This is where TypeScript’s control-flow narrowing is doing useful work. The literal boolean in result.ok distinguishes the two shapes:

  • In the true branch, result.value is available.
  • In the false branch, result.error is available.

A terminal command can now remain thin:

export function createSelectTargetCommand(
  handler: SelectTargetHandler,
  writer: TerminalWriter,
  styler: TerminalStyler,
): TerminalCommandDefinition {
  return {
    name: "select-target",
    aliases: ["select"],

    help: {
      usage: "select-target <pid>",
      summary: "Select a local process as the active target.",
    },

    async execute(
      command,
      _interaction,
      execution,
    ): Promise<ReplDisposition> {
      const result = await handler.handle(
        {
          pidText: command.arguments[0] ?? "",
        },
        {
          signal: execution.signal,
        },
      );

      renderResult(
        result,
        selectTargetPresenter,
        writer,
        styler,
      );

      return "continue";
    },
  };
}

The command coordinates three interface concerns: obtain parsed input, call the application handler, and render its typed result. It contains no duplicated error wording and no application logic.


Treat expected and unexpected failures differently

Expected failures are part of the handler’s return type. They should reach a feature presenter as a normal Result value.

SituationHandling approachTypical terminal output
Invalid PID syntaxTyped failureExplain valid PID input
Process does not existTyped failureState that it was not found; suggest listing processes
User presses Ctrl+CTyped failureCommand cancelled.
Provider reports a known unavailable stateTyped failureExplain availability issue and a corrective check
Bug, invariant violation, malformed third-party valueUnexpected exceptionConcise generic failure; preserve diagnostic detail separately

The defensive catch in runInteractiveRepl from the prior lesson still matters. Refactor it to use a shared unexpected-failure rendering function rather than formatting arbitrary exceptions directly:

export function renderUnexpectedReplFailure(
  writer: TerminalWriter,
  styler: TerminalStyler,
): void {
  renderCommandPresentation(
    failurePresentation(
      "The command could not be completed because of an unexpected failure.",
      {
        hints: [
          "The REPL is still available. Retry the command or use .help.",
        ],
      },
    ),
    writer,
    styler,
  );
}

A production implementation should report the caught error to a diagnostic sink or debug log before showing this concise terminal message. Do not print a stack trace as the normal user experience, and do not disguise expected failures as unexpected ones simply because an adapter happened to throw.

One further defensive concern is worth establishing now: external strings are not automatically terminal-safe. Process names, paths, and future trace values may originate outside your tool. Before rendering arbitrary values, normalize line breaks and control characters so an untrusted value cannot forge multiple output lines or terminal control sequences. Keep that normalization in the terminal interface layer, close to TerminalWriter, rather than scattering it through application handlers.


Make rendering deterministic to test

Rendering is especially suited to unit testing because it is synchronous and should be deterministic. Use an identity styler so ANSI sequences cannot complicate assertions.

// src/interfaces/terminal/rendering/render-result.test.ts

import { describe, expect, it } from "vitest";

const identityStyler: TerminalStyler = {
  style(_tone, text): string {
    return text;
  },
};

class CapturingWriter implements TerminalWriter {
  readonly entries: Array<{
    stream: OutputStream;
    text: string;
  }> = [];

  writeLine(stream: OutputStream, text: string): void {
    this.entries.push({ stream, text });
  }
}

describe("renderResult", () => {
  it("writes an expected failure to stderr with its hint", () => {
    const writer = new CapturingWriter();

    renderResult(
      {
        ok: false,
        error: {
          kind: "process-not-found",
          pid: "8412" as ProcessId,
        },
      },
      selectTargetPresenter,
      writer,
      identityStyler,
    );

    expect(writer.entries).toEqual([
      {
        stream: "stderr",
        text: "No local process with PID 8412 was found.",
      },
      {
        stream: "stderr",
        text: "Try: list-processes",
      },
    ]);
  });

  it("writes a selected target confirmation to stdout", () => {
    const writer = new CapturingWriter();

    renderResult(
      {
        ok: true,
        value: {
          pid: "11240" as ProcessId,
          executableName: "fixture.exe",
        },
      },
      selectTargetPresenter,
      writer,
      identityStyler,
    );

    expect(writer.entries).toEqual([
      {
        stream: "stdout",
        text: "Selected target: fixture.exe (PID 11240)",
      },
      {
        stream: "stdout",
        text: "Try: attach",
      },
    ]);
  });
});

Tests should focus on observable contract, not incidental implementation details:

  • Does a success go to standard output?
  • Does an expected failure go to standard error?
  • Does a state-changing command confirm the resulting state?
  • Does an error include a useful corrective hint where one exists?
  • Does cancellation render as a concise expected outcome?
  • Does the output remain identical when color is disabled?

You can add a separate test for the ANSI styler, but terminal color must remain optional. In particular, disable it when the relevant stream is not a TTY, when NO_COLOR is non-empty, when TERM is dumb, or when the user later supplies a no-color option. The renderer should receive the chosen styler; it should never have to infer terminal capabilities itself.


Implementation checklist

Apply this lesson to the command system you have built so far:

  1. Introduce CommandPresentation, TerminalWriter, and TerminalStyler in the terminal interface layer.
  2. Add renderCommandPresentation() and generic renderResult() functions.
  3. Create a typed presenter beside each application-backed terminal command.
  4. Refactor direct terminal.writeLine(...) calls for handler results into presenter-based rendering.
  5. Route cancellation through the same expected-failure presentation path.
  6. Retain the REPL’s defensive exception boundary, but make its output generic and consistent.
  7. Add deterministic tests using a capturing writer and identity styler.

Key takeaways

Consistent terminal output is not a cosmetic afterthought. It is an interface contract built from typed results:

  • Application handlers return facts through Result; they do not construct terminal messages.
  • Feature-specific presenters map typed success values and discriminated error unions into human-oriented text.
  • A shared renderer owns layout, output stream selection, and semantic styling.
  • Query data and successful state changes go to stdout; failures, cancellation, and diagnostics go to stderr.
  • Expected failures receive precise, corrective messages; unexpected exceptions receive concise safe output and separate diagnostics.
  • Exhaustive switch statements ensure new error variants cannot silently acquire missing or misleading terminal behavior.
  • Rendering tests should assert exact output and streams without relying on a real terminal.

This completes the typed CLI and REPL module. Next, the course moves into Frida host-agent foundations: separate build targets for the Node.js host and injected agent, then a controlled Windows test process for safe attachment and instrumentation.

Can't find a good explanation? Sign up and we'll make it for you

Sign up