Craft of code
Error handling and illegal states
Fail fast, make errors explicit, and redesign so some failures cannot happen. Swallowing errors turns production into archaeology.
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:
- Fail fast. Detect bad state at the boundary (invalid input, missing config) and throw or return a typed error immediately.
- Make errors explicit. Prefer
Resulttypes, checked domain errors, or well-named exception classes over barestringthrows. - 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.
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.000ZThere 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
catchblocks. The failure becomes an unknown unknown. - Stringly-typed errors. Callers cannot switch on what happened.
- Keeping
startandendand 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
NaNor 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.
Related
Writing code others can maintain
Managing complexity with deep modules, clear naming, honest comments, consistent style, and error strategies that shrink the blast radius of change.
Naming and comments
Names encode role and units. Functions do one thing at one level. Comments explain why, not what.
Testing strategy
What to test, at which level, and how to keep a suite fast, reliable, and worth the maintenance — from pyramid trade-offs to production safety nets.