When You Bound an Operation, the Bound Is a Typed Error
A pattern I keep running into when reviewing defensive code: someone takes a previously-unbounded operation, a filesystem call with no timeout, a recursive walk with no entry cap, and adds a bound. That part is almost always done well. The bug is in how the bound-exceeded condition is surfaced to the caller.
There are three ways to surface “this operation hit its bound,” and only one of them is correct.
Shape one: bound-exceeded as a return value
async function search(root, cap) {
const results = [];
let visited = 0;
for await (const entry of walk(root)) {
if (visited >= cap) return results; // hit the cap, just stop
visited++;
if (matches(entry)) results.push(entry);
}
return results;
}This is the worst shape. The caller receives a results array and has no way to tell whether the search completed or stopped at the cap. A search that found 12 matches in a fully-traversed tree and a search that found 12 matches and then aborted at entry 50,000 return the identical value. The bound-exceeded fact existed inside the function (the visited >= cap branch knew it) and was discarded at the return boundary. The caller cannot answer “did this miss results?” because the answer was thrown away.
Shape two: bound-exceeded as a generic Error
if (visited >= cap) {
throw new Error(`Search aborted after ${visited} entries (cap ${cap})`);
}Better. It is loud, it cannot be silently mistaken for a complete result, and the message carries the numbers. But the caller now has to discriminate it by string-matching:
try {
return await search(root, cap);
} catch (err) {
if (err.message.includes("Search aborted")) {
// handle the cap case
}
throw err;
}String-matching an error message is a contract nobody agreed to. The message is for humans. The moment someone rewords it for clarity, every caller that matched on the old wording breaks, silently, because a missed match just falls through to throw err. A generic Error makes the bound-exceeded condition visible but not programmatically addressable.
Shape three: bound-exceeded as a typed error
export class SearchTruncatedError extends Error {
constructor(public readonly visited: number, public readonly cap: number) {
super(`Search aborted after ${visited} entries (cap ${cap})`);
this.name = "SearchTruncatedError";
}
}
if (visited >= cap) {
throw new SearchTruncatedError(visited, cap);
}Now the caller discriminates by type and gets the data:
try {
return await search(root, cap);
} catch (err) {
if (err instanceof SearchTruncatedError) {
logger.warn(`partial result: ${err.visited}/${err.cap}`);
return { partial: true, reason: err };
}
throw err;
}The discrimination is instanceof, which does not break when someone rewords the message. The bound value and the actual value ride on the error object, so the caller can decide what to do with real information rather than re-deriving it. The structured fact that existed at the visited >= cap branch now survives all the way to the caller intact.
The asymmetry trap
Here is the part that actually slips through review. A module rarely bounds just one operation. The same file gets a timeout on a filesystem call and a cap on a recursive walk, often in the same change. And it is very easy to give one bound a typed error and the other a generic one, because they were written ten minutes apart, or because the timeout felt more “real” than the cap.
The result is a module where the caller does this:
catch (err) {
if (err instanceof FsTimeoutError) { /* ... */ }
if (err.message.includes("Search aborted")) { /* ... */ }
}Two bounds, two completely different discrimination mechanisms, in the same catch block. One is robust, one is a string-match waiting to rot. A caller that wants to treat “the operation did not complete” uniformly, retry it, surface a partial-result warning, fail over, cannot, because the two bounds do not present uniformly.
The rule that closes this: every bound in a module surfaces the same way. If the timeout throws a typed FsTimeoutError, the cap throws a typed SearchTruncatedError. Both carry their numbers. A caller can then write if (err instanceof BoundExceededError) against a shared base class and handle every did-not-complete case with one branch.
Why this matters more in agent systems
In an AI agent stack, the caller of a bounded operation is frequently another automated layer, not a human reading a stack trace. An agent that runs a file search and gets back a plain array treats it as ground truth. It will confidently summarize “the codebase contains no references to X” when the real answer is “the search hit its cap at 50,000 entries and stopped.” The typed error is what lets the layer above say “this result is partial” instead of laundering a truncation into a false fact.
When you add a bound, you have created a new outcome. Give it a type. Give every bound in the module the same type discipline. The cost is one error class. The payoff is that “it did not finish” stops being something a caller has to guess at.