Posts

Stop Committing Debug-Only Variables: Use Your Debugger Instead

I have seen this kind of function more times than I can count: function calculateTotal(values: readonly number[]): number { const result = values.reduce( (total, value) => total + value, 0, ); return result; } …

July 20, 2026 7 min read 1319 words

In this article

I have seen this kind of function more times than I can count:

function calculateTotal(values: readonly number[]): number {
  const result = values.reduce(
    (total, value) => total + value,
    0,
  );

  return result;
}

Sometimes result names a useful concept. Sometimes it creates a clean place for validation. And sometimes it exists for one reason: someone wanted a breakpoint immediately before the return.

In that last case, result is debugger residue. The investigation ended, but its scaffolding quietly became production structure.

The answer is not to ban intermediate variables. The answer is to use debugger-native tools for temporary questions and reserve source code for behavior and explanations the next developer still needs.

temporary question      -> debugger configuration
permanent explanation   -> source code
production evidence     -> observability

That boundary is the whole argument.

Keep Variables That Explain the Code

It is tempting to compress the first example into this:

function calculateTotal(values: readonly number[]): number {
  return values.reduce(
    (total, value) => total + value,
    0,
  );
}

That version is shorter. It is not automatically better.

A named value earns its place when the name explains the domain:

type InvoiceItem = {
  price: number;
  isTaxable: boolean;
};

type Invoice = {
  items: readonly InvoiceItem[];
  subtotal: number;
  taxRate: number;
};

function calculateInvoiceTotal(invoice: Invoice): number {
  const taxableSubtotal = invoice.items
    .filter((item) => item.isTaxable)
    .reduce(
      (total, item) => total + item.price,
      0,
    );

  const tax = taxableSubtotal * invoice.taxRate;

  return invoice.subtotal + tax;
}

taxableSubtotal and tax expose the business concepts inside the calculation. Collapsing that code into one expression would save lines and spend clarity.

Keep an intermediate variable when it:

  • names a domain concept;
  • prevents repeated or expensive evaluation;
  • separates calculation from validation;
  • narrows a type before later use;
  • makes a complicated expression easier to test or explain.

Remove it when its only surviving purpose is “the debugger would not show me what I wanted three Tuesdays ago.”

Use a Watch for a Temporary Question

If I only need to inspect a pure expression, I can pause near it and add the expression to the debugger’s Watch panel.

For the compact calculateTotal() function, that expression might be:

values.reduce((total, value) => total + value, 0)

While execution is paused in the correct stack frame, the debugger evaluates the expression without requiring a source-code change.

VS Code also lets you evaluate expressions in the Debug Console. Its data-inspection documentation covers variables, watches, hovering, and Debug Console evaluation.

Evaluate expressions, not side effects. A property access can still invoke a getter or proxy trap, so an expression that looks harmless is not automatically pure.

Do not casually run any of these from a watch or debug console:

chargeCreditCard(order);
queue.dequeue();
iterator.next();
repository.save(customer);

Debugger evaluation is real execution. A watch that mutates state can change the failure you are trying to understand—and “the debugger charged the card twice” is not the kind of incident report anyone wants to write.

For expensive, stateful, or nondeterministic work, keep the result in a local variable and inspect that value. The variable is protecting behavior, not merely accommodating the debugger.

Use Conditional Breakpoints to Ignore the Noise

Many debugging sessions are difficult because the code runs 10,000 times and only one invocation is wrong.

Instead of adding this temporary branch:

if (order.id === "order-8137") {
  console.log(order);
}

Set a conditional breakpoint with an expression such as:

order.id === "order-8137"

The debugger pauses only when the condition is true. Many debuggers also support hit counts, so you can stop on the 500th invocation without adding a counter to production code.

The condition still has to be evaluated when the breakpoint is reached. In a hot path, an expensive condition can noticeably slow the program even when it rarely pauses.

VS Code documents expression conditions, hit counts, triggered breakpoints, function breakpoints, and data breakpoints in its breakpoint guide. The exact features depend on the runtime and debugger extension, but the principle travels well: make the debugger filter executions instead of making the application do it.

Use a Logpoint When You Need Output Without a Pause

A logpoint behaves like a breakpoint that writes to the debug console without stopping execution. It is useful when pausing would disrupt timing, when a loop runs repeatedly, or when I want to compare several values over one run.

Not stopping is not the same as being free. Logpoints still evaluate expressions and produce output, so they can distort timing-sensitive behavior—especially inside a hot loop.

In VS Code, a logpoint message can include expressions inside braces. For example:

Calculating total for {values.length} values; customer={customerId}

The message belongs to the debugging session rather than the application source. It can be enabled, disabled, conditioned, or removed without leaving a console.log() behind. The official VS Code logpoint documentation describes the workflow and notes that support depends on the debugger extension.

The same side-effect rule applies. Do not put a state-changing method call inside a logpoint expression. Also remember that logpoints are local diagnostic tools—not durable production telemetry. If the team needs the event after your debugger disconnects, it belongs in the application’s observability design.

Temporary Debugging Is Not Production Observability

The persistence test is simple: does someone need this information after the debugging session ends?

If the operations team needs to know how long every repository call takes in production, a local watch will not help. That requirement deserves structured, tested instrumentation owned by the application.

Here is a small explicit wrapper for asynchronous operations:

import { performance } from "node:perf_hooks";

type LogFields = Record<string, string | number | boolean>;

type NonThrowingLogger = {
  info(fields: LogFields): void;
  error(fields: LogFields): void;
};

async function measure<Result>(
  operationName: string,
  operation: () => Promise<Result>,
  logger: NonThrowingLogger,
): Promise<Result> {
  const startedAt = performance.now();
  let result: Result;

  try {
    result = await operation();
  } catch (error) {
    logger.error({
      event: "operation.failed",
      operationName,
      durationMs: performance.now() - startedAt,
      errorType: error instanceof Error
        ? error.name
        : "UnknownError",
    });

    throw error;
  }

  logger.info({
    event: "operation.completed",
    operationName,
    durationMs: performance.now() - startedAt,
  });

  return result;
}

The call site makes the behavior visible:

const order = await measure(
  "order.load",
  () => orderRepository.findById(orderId),
  logger,
);

NonThrowingLogger names a behavioral contract; TypeScript cannot enforce it. If the underlying logger can throw, enforce the failure policy inside its adapter so a telemetry failure cannot relabel or replace the business outcome.

A real observability layer also needs decisions about correlation, sampling, cardinality, redaction, and exporters. It should not log arbitrary arguments, return values, credentials, or customer data merely because those values are available.

For system-wide traces and metrics, use the instrumentation provided by your framework or a tool designed for the job. The OpenTelemetry JavaScript instrumentation guide covers manual instrumentation and instrumentation libraries without pretending that a few console.log() calls constitute observability.

Decorators and aspects can centralize stable cross-cutting behavior, but that is an architectural decision—not a substitute for debugger tools. I cover that boundary separately in Aspect-Oriented Programming in TypeScript: Where It Fits—and Where It Doesn’t.

Choose the Smallest Tool That Matches the Need

When I am deciding where diagnostic behavior belongs, I use this order:

NeedFirst tool to consider
Inspect a value onceHover, Watch, or Debug Console
Stop on one caseConditional breakpoint
Observe repeated execution locallyLogpoint
Explain an important calculationNamed local variable
Record one durable business eventExplicit structured log
Observe every requestFramework middleware or interceptor
Trace work across servicesOpenTelemetry instrumentation

This is a persistence ladder, not a sophistication ranking. Move toward source code and production infrastructure only when the information needs to outlive the debugging session.

The Rule I Keep

Do not contort production code to answer a temporary debugging question.

Keep the variable when it explains the program. Keep the instrumentation when it explains production. Put temporary questions in the debugger, where they can disappear when the investigation does.

The cleanest code is not the code with the fewest declarations. It is the code where every declaration still has a job after the debugger is gone.