
You've spent years staring at codebases where every layer speaks a different dialect of failure.
This service returns a boolean. That one throws. The other returns negative one because someone in 2014 thought that was clever. Your repository returns null. Your controller catches something, maybe, and logs it into the void.
You're playing telephone with ghosts (I need to find a better analogy than "playing telepone" buttttt it works here again).
Anyways, the real problem is architectural. You have a try-catch six layers deep and no idea what's being thrown, what's being swallowed, or whether the catch block is doing actual work or just whispering "it's fine" into the logs.
Result types fix this, but only where they belong.
At your domain and service boundaries, a Result is either success or failure, explicitly, at every boundary. No guessing. No checking if the integer is negative. No wondering if that null means "not found" or "everything is on fire." Your CreateOrder operation can fail because inventory is insufficient or the customer is suspended. Those are domain concepts. They deserve names, not exceptions.
But at infrastructure boundaries, let exceptions be exceptions. Your database driver throws. Your HTTP client throws. Fighting that is fighting the framework, and the framework wins. Catch the infrastructure chaos at the edge, convert it to a Result, and pass that up into your domain where people can actually reason about it.
Result types are not an error handling strategy. They are a domain modeling strategy. Use them where failure has meaning. Let exceptions stay exceptions where failure is just the universe being rude.
Gist for Typescript and PHP with an example usage in typescript
const res1 = ok<string>('hello');
if (isSuccess(res1)) {
console.log(res1.value); // 'hello' | null (type-safe)
}
const res2 = fail<number[]>('Failed', [1, 2, 3]);
if (isError(res2)) {
console.log(res2.value); // number[] | null
console.log(res2.errorMessage); // 'Failed'
}
And examples of not using Result Types:
// The chaos you inherit: every layer speaks different failure dialects
class UserRepository {
find(id: string): User | null { /* null = not found? or error? */ }
}
class PaymentService {
charge(amount: number): boolean { /* false = declined? failed? invalid? */ }
}
class LegacyInventory {
checkStock(sku: string): number { /* -1 = error, -2 = discontinued, 0 = none */ }
}
class OrderValidator {
validate(order: Order): void { /* throws... something. maybe. */ }
}
// Your controller playing telephone with ghosts
async function processOrder(orderId: string) {
try {
const user = userRepo.find(order.userId);
if (!user) { /* not found or db error? shrug */ }
const inStock = inventory.checkStock(order.sku);
if (inStock < 0) { /* is -1 different from -2? check the wiki from 2014 */ }
validator.validate(order); // this throws. or does it?
const charged = payment.charge(order.total);
if (!charged) { /* log it and pray */ }
} catch (e) {
// what even is e? ValidationError? NetworkError?
// a string someone threw because they were angry?
logger.error("it's fine", e);
}
}
Now go use Result Types.
❤️
Jake