Summary
In this episode, we explore Go's error handling and why the "if error != nil" pattern is actually a good thing. We learn that Go forces you to deal with errors where they occur rather than through catch-all functions, making it clear where things went wrong. The episode demonstrates how to handle errors at different layers of an application, when to log errors, and how to use tools like `errors.Is` and `errors.As` to examine specific error types and provide better feedback to both users and developers.
Transcript
In episode two, where we talked about if statements and switch statements, I briefly used an error in the shorthand notation form of an if statement, but I didn't fully talk about what they are and how they work. Even though it should be pretty clear from the name what an error is, they are somewhat different or somewhat unique compared to other programming languages. So you might have seen this one before: if error does not equal nil, then we do something. You also might have seen people on Hacker News, Reddit, Twitter, whatever, hate on the amount of time that you need to write this statement. So let me break down why this is actually good, what errors are and what errors as values mean, so you have a proper mental model of how to think about them. First, I want to tackle this "if error equals nil" debacle, because Go forces you to deal with errors when they occur and not later on in your program through some catch-all function. This is a great thing. This is good. It makes it obvious where something went wrong and requires a conscious decision as to what to do next. Often, you just return the error and let the caller deal with it. So I want to give you an example of this. Say we have an API package here and I'm just going to call this one API.go, and in here we have something like `func NewUser`. It just takes in an argument of name that's a string and returns an error. Then we are going to call the database. We're going to say `error equals database.InsertUser`. Now, this is obviously pseudo code. We're going to have a lot of errors here, so just stick with me. I'm also going to create a pseudo database package here, and it's just called DB.go. Package database, and we have a function here called `InsertUser` that takes in a name and returns an error. Then we have some code for executing SQL against the DB. For this example, I'm just going to return an error so I can illustrate the concept. So we just say "could not insert record." Now I can go back to API and we can actually import this and we see we have this error right here. So first of all, where should we handle this error? The reason why our database `InsertUser` function could error is multiple, but we can't really do much in the database package to fix it in a logical way. It makes a lot more sense to deal with this in the API layer where we can return a message to the user or we can retry if we want to. So for the database insert function, it makes a lot more sense that we're going to return it to the caller, which in this case is the `NewUser` function in the API layer. As I mentioned, the `NewUser` function could return a helpful message to the user or issue a retry. But how would we as the programmers know that an error has occurred? And this is where we have observability or logging. Go has a package in the standard library called `slog`, which gives us different options for outputting information that is not available to the user. So that sounds great. But where should we log? Because we have two places where we have the same error in a sense, right? A good rule of thumb is that we log something where we handle it. So in this case, we just argued that in the database package here, we can't really do much about this error. We can only really return it. So the only place we can realistically handle this error is inside this `NewUser` function. And how do we go about it? Well, as I said, we have multiple options, but it makes sense that we handle this error here. So if we were to say `if error does not equal nil`, then we can return a new error here saying "could not create a new user, please try again." And then we import this `errors` package from the standard library as well. Now, it's important that we are strategic about where we log, because if we log too much, the information is going to get lost in a lot of logs, right? So here we could simply say, after we return this message to the user, `slog.Info`, then we say "an error occurred while inserting user in database," we have some context, and then we say "the error was" error. So here this `slog.Info` never gets outputted to the user. It's only something that gets outputted to us as the programmers and the maintainers. And then the user will see this "could not create new user, please try again" message. Now, if you were to log this as well in the database, you can see how we kind of will have the same log twice, right? So we have even more information we need to parse through to determine where something happened. Here it's very clear that we called this function from the database package. We were trying to make an insert and something went wrong, and then we have the error. We can go into the package and then fix that error. I said earlier that errors are values, which means that we can examine the type of error by looking at its value. Now say this error we get right here is related to database connections. This is not the fault of the users but something that we the developers should fix. In that case, we can actually change what is known as the log level to go from info to error, because this is an actual error that we need to look into, right? But how do we know that this error is related to connections? Well, if you were to go into `InsertUser` here, we could create a new variable, say `ErrorConnection`, and say `errors.New("the database connection was dropped")`, right? So this is our own custom error. But we would typically also use a package or something that connects or actually interfaces with the actual database. So if you were to mimic this with some more pseudo code here, we could say `error equals` — let's just say we have a DB package in here — `DB.Exec`. That DB package has an `Exec` function that lets us run some SQL. So "insert into users name values" — it's going to be just dollar sign one because we are going to be mimicking Postgres — and then the name. Now our error handling here, we can actually look at the specific type of error. So if error is not nil, we can use a switch here and say — let me spell switch correctly — switch, there we go, and say case `errors.Is`. `errors.Is` just checks exactly what the error is. So if `error.Is` — let's say we have a `DB.ConnectionError` — in that case we return `ErrorConnectionFailure`. We might have other errors as well. You could also have a case, paste this one, and say we have `RecordExists`, and saying something like "matching records already exist," right? Then again, you can say in this case, like figuratively, a case here that our DB package has — this is `RecordExists` — now return our `RecordExists`. There we go. And if it's none of these errors, we just return nil. Great. Now we can go back into our API package and then we can see pretty much the same thing as before. We said switch, and now `if error`, case `errors.Is(error, database.ErrorConnectionFailure)`, then we can return a more specific error message to the user, something like "an error occurred on our end, please try again." There we go. And we could also, since we now have the option to look at if the data that the user provided was bad, because `errors.Is(error, database.ErrorRecordExists)`, and now we can return a more specific error message for that case. So "a user with that name already exists," right? And even more, now we can also adjust the logs that we output. So here we would clearly have an error occurred, because this is something on our end. Now you can argue if you want to add a log here saying "a user tried to insert a record that already existed." Whether or not you want to do this, that's up to you. But now you have the option and you can do more detailed or more correct outputs of whatever went wrong, both to us as the programmer but also to the user. And then we can just have our catch-all down here where "we could not create the user, please try again." Now as already mentioned, we are using a figurative error package in this case to mimic the behavior of a real database package. And I know we have database twice here, but stick with me because I'm just trying to illustrate the concept of how you can deal with errors at different levels. But say that this DB package here also outputs — we can see they have concrete errors, right, like `ConnectionError`, `RecordExistsError`. In that case, we might want to bring that information up to the higher level to give us more information. In that case, we can use `errors.Join`, where we can add an error and then we can add our own error — multiple errors to this error chain or tree. And then here, do the same with our `RecordExists` so we can provide more context if we actually wanted to. This is a really helpful behavior so you can get the full picture of all the errors that occurred if you ever need to. Great. This might look a bit unnecessary and this is definitely a thought-up example. We are going to deal with it differently in the actual course. But say that we were to change the SQL package, the package that we use for executing SQL against the database, right? That package might have different errors. And if you start to rely on these errors, well, if you change that package, we break pretty much everything. But by creating our own errors here, we can rely on those errors and then just switch things around in this specific function if there is anything to switch, if the errors change. Now we made this join so we always have the context of our own errors but also the more specific errors of the package. What we need to do now is we need to use what is known as `errors.As` instead of `errors.Is`. `errors.As` will look for the first error in the error tree that matches the error provided here and will only return true if it finds that error. So we might have a ton of errors in this error chain or tree, but we know that okay, we know that this error can happen, and if that error happens, we want to do something specific. We still have everything that happened in this value right here. So this will have everything that happened up to this point, and then we can examine the output of that error in our logs, right? But we know that if it contains `ErrorConnectionFailure`, then we need to return this error message, right? So `errors.As` is a really good tool if you have multiple errors. `errors.Is` is really good if you only have one error. This all takes a bit of practice, and these examples I've shown you right here are not really real-world examples. This is more illustrative. But follow the guidelines here of trying to handle the error where it's handled, and try to log the error where it's handled, and try to handle it in a place where it makes sense. And again, that takes practice, right? Because whether something makes sense to handle — you could argue it makes sense to handle in the database, but it also depends on if the function can do something, right? And in the case of a record exists or connection failure, we don't know what to do because we can't fix it in the database layer. We can fix it in the API layer. We can only really return a message here saying this record exists, or let us the programmers know that there's a connection failure and we need to look into how our database is configured. So this is a very short intro to errors. We will use them a lot throughout the development of this blog, so you will get to become very familiar with how you can work with errors in a professional way.