In the annals of programming history, few papers have had as much impact as Edsger Dijkstra’s seminal work “GOTO Considered Harmful,” published in a 1968 Association for Computing Machinery publication. His arguments against the indiscriminate use of the GOTO statement forever changed the trajectory of software development, giving rise to “structured programming.” In today’s world, while GOTO has become a relic, could there be modern constructs playing a similar detrimental role?
10 PRINT "Would you like to continue? (YES/NO)"
20 INPUT RESPONSE$
30 IF RESPONSE$ = "YES" THEN GOTO 50
40 IF RESPONSE$ = "NO" THEN GOTO 60
45 PRINT "Invalid response. Please type YES or NO."
46 GOTO 10
50 PRINT "Continuing the program!"
55 END
60 PRINT "Ending the program now."
65 ENDDijkstra’s primary grievance with GOTO was its disruption to control flow.
Control flow, at its essence, is the sequence in which a program’s instructions are executed.
To illustrate, imagine control flow as the conductor of an orchestra, ensuring each instrument plays in harmony. Overreliance on GOTO disrupted this harmony, akin to a scratched CD causing jarring skips in a song, throwing off the intended rhythm and melody. Those who recall the days of CDs can appreciate how such disruptions mar the listening experience.
GOTO’s arbitrary jumps introduced this chaos into code, leading to what many termed ‘spaghetti code.’ Such convoluted patterns not only obfuscated logic but also became hurdles in debugging and team collaboration.
In the current programming landscape, the overuse and deep nesting of try/catch can echo the challenges posed by GOTO. As we aim for clear and maintainable code, it’s crucial to draw upon past lessons to inform our present practices.
The Problem with Try/Catch
The main issue with try/catch structures is the non-linearity it introduces to the flow of code. This is especially egregious when nested. For human readers, the eyes have to jump around to follow the execution flow, much like following the jumps of the GOTO statement. This non-linear reading pattern strains cognitive processing and can make the code harder to understand at a glance.
Consider the following Typescript code:
async function fetchUserData(): Promise<UserData|undefined> {
let userData: UserData | undefined; // cannot use const try {
const token: string = await fetchToken();
try {
userData = await fetchUserData(token);
} catch (userError) {
console.error("Error fetching user data:", userError);
}
} catch (tokenError) {
console.error("Error fetching token:", tokenError);
}
return userData;
}
To read this code, your eyes dart from the innermost function call to its associated catch, then outwards in a zigzag pattern. Wouldn’t it be more readable if the code simply flowed from top to bottom, in a linear fashion?
It’s also quite stateful, as const won’t work here due to the scoping issues created by the try/catch blocks, forcing us to declare mutable variables at the top. This prevents us from writing code in a more declarative style. This, in turn, introduces state change and possible “undefined” variable errors (Javascript’s equivalent to the null pointer exception).
Naturally, this is a very simple example. The real challenge and undue mental strain for developers arise when complexities increase.
For example, what if we wanted to re-throw new, more detailed errors instead of returning nulland logging to the console? This is actually problematic:
async function fetchUserData(): Promise<UserData|undefined> {
let userData: UserData | undefined; // cannot use const, must support undefined try {
const token = await fetchToken();
try {
userData = await fetchUserData(token);
} catch (userError) {
throw new Error(`Error fetching user data: ${userError.toString()}`);
}
} catch (tokenError) {
throw new Error(`Error fetching token: ${tokenError.toString()}`);
}
return userData;
}
In this example, the innermost Error would be caught by the outermost one. Throwing a locally caught exception like this may be seen as poor practice. What if you wanted the first exception to act like an early return? Unfortunately, this isn’t possible with nested try/catch blocks.
Furthermore, we are required to have UserData | undefined as the return type, even though the function will always return UserData unless it throws. Typescript isn’t able to help us in this situation.
A Better Way: The “Result” Pattern
Borrowing from the Go programming language’s approach to error handling, we can define a new pattern that offers a linear and readable code structure.
I call this the “Result” pattern:
async function fetchUserData(): Promise<UserData> { const [tokenErr, token] = await toResultAsync(fetchToken());
if (tokenErr) throw new Error(`Error fetching token: ${tokenError.toString()}`);
const [userDataErr, userData] = await toResultAsync(fetchUserData(token));
if (userDataErr) throw new Error(`Error fetching user data: ${userDataErr.toString()}`);
return userData;
}
This pattern greatly simplifies the code by returning a tuple. The first value is an error (if any), and the second is the result.
Here, the flow of the program is clear, straightforward, and linear. Your eyes can glide smoothly from top to bottom, capturing the essence of the function’s behavior without needing to dart around.
This code is also significantly shorter, declarative with no stateful mutation, and is able to re-throw errors in a straightforward manner. Typescript is able to confidently tell us that this function only ever returns UserData, making it easier to use as well.
Let’s define these helper functions, which are provided for both synchronous and asynchronous use cases:
export type Result<E, T> = [E] | [undefined, T];
export type PromiseResult<E, T> = Promise<Result<E, T>>;export function toResult<E extends Error, T>(executable: () => T): Result<E, T> {
try {
const result = executable();
return [undefined, result as T];
} catch (e) {
return [e as E];
}
}
export async function toResultAsync<E extends Error, T>(p: Promise<T>): PromiseResult<E, T> {
try {
const result = await p;
return [undefined, result as T];
} catch (e) {
return [e as E];
}
}
Alternative Approach: Embracing Failure as Expected Outcomes
Sometimes, what we perceive as an “error” or “exception” in a function isn’t truly exceptional but rather an expected and common outcome. Take, for example, user authentication. If a user inputs the wrong password, is that an exception? Or is it a frequent scenario that our system should handle gracefully? In cases like this, the delineation between standard return and error becomes blurred.
Instead of relying on exceptions to signal these common scenarios, we can redefine our function’s contract to return them as standard results. This not only simplifies the code but also shifts our perspective from seeing these outcomes as “failures” to viewing them as just another type of result that our functions can produce.
Using Tuples for Expected Failures
One way to handle expected failures is to return tuples directly:
function authenticateUser(username: string, password: string): [boolean, string] {
if (isValidUser(username, password)) {
return [true, "Authentication Successful"];
} else {
return [false, "Invalid Password"];
}
}The returned tuple consists of a boolean indicating success and a message string.
Discriminated Unions for Richer Results
For more complex scenarios where you might have a variety of results, you can use discriminated unions (also known as tagged unions):
type AuthenticationResult =
| { status: 'success'; user: User }
| { status: 'failure'; reason: string };function authenticateUser(username: string, password: string): AuthenticationResult {
if (isValidUser(username, password)) {
return { status: 'success', user: retrieveUser(username) };
} else {
return { status: 'failure', reason: "Invalid Password" };
}
}
Wrap Up
While try/catch is a powerful tool, its misuse or overuse can lead to tangled nests of code, reminiscent of the confusion sparked by the ‘goto’ statement. By leveraging patterns such as “Result” or “Failure as Return,” programmers can enjoy cleaner, less stateful, more maintainable code. A linear reading flow minimizes the cognitive overhead, allowing for quicker comprehension and more effective code reviews.
As always, the right tool should be used for the job, and understanding the context is essential.
Dijkstra’s paper on GOTO wasn’t merely a critique of a specific construct; it underscored the importance of linear control flow and minimal stateful mutation. The less we need to keep track of in our heads, the better. Humans can be great at abstraction and high-level concepts but generally make terrible compilers.
Whether we’re dealing with GOTO of the past or the potential pitfalls of try/catch today, the foundational principle remains the same — optimize for human understanding.