The book that’s had the greatest impact on my understanding of programming and career as a whole is John Osterhout’s “A Philosophy of Software Design”. One idea that stood out to me is that we should try to find ways to program errors out of existence.
Every time we need to handle exceptions we need to write more code, test more edge cases, and handle branching logic. Our life would be much simpler if everything always went according to plan, but we all know that this is rarely the case. Exceptions and errors are always an effort to deal with.
In JavaScript, we don’t have an obvious way to describe if a function is throwing an error. In order for a developer to learn this they must either read the implementation, the documentation or the error they get in production because they didn’t figure it out before that.
All this translates into more complexity.
This applies not only for errors but for all kinds of special cases that make us consider multiple logical branches. We can reduce the complexity if we create a sensible default behavior for our logic that will prevent it from going into multiple states.
One example for this is defining default values for optional arguments in functions instead of returning null
when a value isn’t provided.
function greet(name) {
if (!name) {
return null
}
return `Hello, ${name}!`;
}
We can return a default value that won’t break our application:
function greet(name = 'user') {
return `Hello, ${name}!`;
}
This is not an example of an error, but shows how by making the decision to rely on defaults we can eliminate conditional statements and additional logic from other places in our application.
The same goes for data retrieval of all kinds - if we can’t find the data we’re looking for, can we still guarantee a response of the appropriate type:
const fruitColors = new Map([
['apple', 'red'],
['banana', 'yellow'],
['grape', 'purple']
]);
function getColor(fruit) {
return fruitColors.get(fruit) || 'unknown';
}
It’s not that hard to find opportunities to apply this in practice.
Another example of this behavior that you’ve probably seen is using the removeEventListener
DOM method. If you pass a reference to a function that’s not added as an event listener it won’t throw an error. It will simply ignore it and continue with the rest of the logic.
button.removeEventListener('click', handleClick);
This means that you as a user don’t need to add any additional error checks - if you mess up for some reason the browser just won’t take any action. The browser is one of the most permissive environments that we work because it follows the philosophy that the web page should be active at all times unless a catastrophic error occurs.
It’s programming errors out of existence.
In the case in which we try to remove an event handler that doesn’t exist, there’s no cause for an exception. Nothing bad will happen to the page and the user experience won’t be degraded as a result of this action not being performed.
There are many other techniques that we can apply.
However, there are places where errors are unavoidable. We can’t program errors out of the network communication, for example. Any time we’re communicating with something outside of the boundaries of our application and the network is involved, we will have to deal with failures. They may be rare and far between but they’re a guarantee. What we can do is to minimize the effect of these failures on our codebase and hide the fact that they’re happening.
Even in the cases where we need to throw exceptions we can “mask” them by handling them as close as possible to their origin and hiding the fact from the rest of our application that there’s an error to be handled at all.
function fetchData() {
try {
return someAsyncOperation();
} catch (error) {
return [];
}
}
In this example we attempt to retrieve data from an external source, and if it fails we return an empty array so the rest of our codebase can work as expected.
function getStockPriceHistory(stock) {
const { history, status } = await getHistoryFromExternalAPI()
if (!history) {
return {
name: stock,
status: 'N/A',
history: []
}
}
return {
name: stock,
status: status,
history: history,
}
}
In this example we fetch external data again and we return an object with prefilled fields to achieve the same result - our application can continue its execution. This data could probably be rendered without any problems.
Another way that I apply this idea is aggregating the errors to reduce the error handling logic in our codebase.
One example are error handlers in routes when using Express. We usually have a single handler that’s responsible to return a 500
error if something goes wrong. This way all our handlers can just ignore failing states and let the main handler take care of it.
app.use("/v1", router);
// 404
app.use(handleRouteNotFound);
// 500
app.use(handleError);
The question we need to consider is which approach adds the smallest amount of complexity?
For example, attempting to create an entity with data which is not formatted correctly would put the database in a state that will make it impossible to work with. So it makes sense that we want to raise an error, and that’s complexity that we need to take care of.
But if we think about it, this error is avoidable if we can somehow guarantee that the method is receiving the correct data beforehand. If we move the check to another layer in our application, we can remove the need for the repository to throw an error and we can essentially program the error out of existence.
We can use types and checks in such a way that some errors can never happen. One such way is to validate the data when the request is received, not waiting for that until we’re starting to work with it in our methods.
const userSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email format"),
age: z.number().int().positive("Age must be a positive integer"),
});
function validateBody(schema) {
return (req, res, next) => {
try {
req.body = schema.parse(req.body);
next();
} catch (err) {
res.status(422).json({ error: err.errors });
}
};
}
app.post('/users', validateBody(userSchema), (req, res) => {
const user = userService.create(req.body)
res.status(201).json({ message: 'User created successfully', data: user });
});
This keeps our business logic simpler since it removes a lot of conditionals and empty types.