Guide

Errors are part of the interface. Callers should know what can go wrong without reading your source. Silent coercion (NaN, empty string as "zero") creates unknown unknowns downstream.

Three complementary tactics:

  1. Fail fast. Detect bad state at the boundary (invalid input, missing config) and throw or return a typed error immediately.
  2. Make errors explicit. Prefer Result types, checked domain errors, or well-named exception classes over bare string throws.
  3. Define errors out of existence. Redesign so the failure cannot happen: use types that make illegal states unrepresentable, default safe configs, or APIs that cannot be called incorrectly.

Swallowing errors (catch (e) {}) or logging and continuing as if nothing happened turns production into archaeology. If you catch, either recover with a clear fallback or rethrow with context.

Walking through a scheduling API

A scheduling API accepted endTime before startTime and returned 400 with a vague message. Defining the error out of existence: take a startTime plus durationMinutes > 0, compute endTime internally. One fewer invalid combination, one fewer branch in every client.

TypeScript
type Minutes = number

function endTimeIso(startIso: string, durationMinutes: Minutes): string {
  if (durationMinutes <= 0) {
    throw new RangeError(`durationMinutes must be > 0, got ${durationMinutes}`)
  }
  const start = Date.parse(startIso)
  if (Number.isNaN(start)) throw new RangeError(`invalid startIso: ${startIso}`)
  return new Date(start + durationMinutes * 60_000).toISOString()
}

console.log(endTimeIso("2026-03-02T10:00:00.000Z", 30))

Output:

2026-03-02T10:30:00.000Z

There is no longer a way to pass end before start. The illegal state left the API.

The checkout rewrite on naming and comments uses the same move: discountRate outside 0..1 throws at the boundary instead of silently producing garbage totals. Boolean soup (includeDeleted, asAdmin, skipCache) belongs on interfaces that age well, not as a fourth positional true.

Common mistakes

  • Empty catch blocks. The failure becomes an unknown unknown.
  • Stringly-typed errors. Callers cannot switch on what happened.
  • Keeping start and end and hoping every client validates the order.

Check yourself

Clients keep passing endTime before startTime. What is stronger than a better 400 message?

Take startTime plus a positive duration and compute endTime inside. The illegal state disappears.

You catch an error, log it, and return an empty list. What did you hide?

That the read failed. Downstream now looks like "no rows" instead of an outage.

Key takeaways

  • Fail fast at the boundary. Do not coerce bad input into NaN or zero.
  • Make errors explicit in the type or the exception class.
  • The strongest move is to make the illegal state unrepresentable.
  • If you catch, recover with a fallback or rethrow with context. Do not swallow.

Further learning

Next, when not to share code: duplication vs the wrong abstraction.