Bug, error, or expected case: what is the business intent?
When should you throw an exception, return an error, or model the absence of a value? The answer lies in the business intent.

We’ve seen in the past that it’s important to distinguish between two types of errors:
- Development errors, that is, bugs, for which it is justified to throw exceptions (since, by definition, we don’t know how to handle them);
- User input or infrastructure errors, which are predictable and will occur sooner or later. These errors are best handled using the
Resultmonad (type Result<U, E> = Success<U> | Error<E>) and Railway-Oriented Programming.
At this point, a common pitfall would be to never throw exceptions again and to “put everything in Result.” That’s a bad idea, of course. So let’s add some nuance.
To better understand the topic, let’s consider a logistics context and a ShipmentPort that expresses the domain’s persistence requirements, in a ports-and-adapters architecture (a.k.a., Hexagonal Architecture).
Let’s now look at these three signatures: getRequiredShipmentById, getShipmentById, and getNextScheduledShipmentAfterDate. We’ll see that they imply very distinct meanings from a business perspective.
-
getRequiredShipmentById: here, we expect theShipmentto exist in the database. If it does not, it means that we, as developers, have failed to respect a business invariant—it’s a bug. By definition, this is a case we do not know how to handle, so we should throw an exception (throw). In TypeScript, a function’s signature does not specify the exceptions that the function might throw. We therefore indicate this behavior through the function name:Required, or sometimesOrThrow. -
getShipmentById: here, theshipmentIdis derived from user input. The case where noShipmentmatches the ID provided by the user is therefore entirely predictable and must be accounted for. This is a business error, since the application cannot function withoutShipment, but it’s a type of error we know how to handle, using theErrorsubtype of theResultmonad. -
getNextScheduledShipmentAfterDate: here, talking about an “error” is actually a misnomer, since the absence of a shipment is part of the normal flow, for example, creating theShipmentif it doesn’t already exist. Note here the use of theEithermonad (type Either<U> = Some<U> | None), which is a way to represent the absence ofShipment(neater thanShipment | null, which forces us to perform multiple checks). “That’s a lot of monads,” you might say, but once you’ve tried them, it’s hard to go back 🙂
One might think that the third case introduces a new type of error. But as we’ve seen, this case is actually an expected case, so there are only two error cases, those mentioned in the introduction: bugs or predictable errors.
What should we take away from all this? That it’s the business that determines which case we’re in:
- Does a
Shipmenthave to exist at this stage of the logistics process? - Is its absence a realistic possibility?
- Does this absence trigger a business action?
Note also that ShipmentId is the encapsulation of an ID with nominal type emulation, an approach that helps avoid many errors related to “primitive obsession.”