A deeper dive into error handling in go.
So let's dive deeper into advanced error handling patterns. First, let's talk about sentinel errors. These are predefined errors that you can check against to determine specific conditions in your program. Sentinel errors are often used in situations where you need to distinguish a specific error condition and handle it accordingly. In Go, sentinel errors are typically declared as constants, so let's look at how we can declare one of these.
So let's start out by creating just a simple sayHello
function, and this returns an error. This will take a name, but let's say we want to return an error when the name is not set. So to do this, we can create a sentinel error, which is ErrorCodeNameNotSet
, and we'll use the errors.New
function to create the error. And now in our code, we can do if name equals empty, then return ErrorCodeNameNotSet
. So this is how we can define a sentinel error. And then when calling our function, we need to do err := sayHello("")
, and then we can do if err == ErrorCodeNameNotSet
, then we will log 'name not set'. Otherwise, if another error is returned, we will handle that generically, and we'll do error occurred and log 'error occurred'.
And now if we run this, we should see a log message 'name not set', and we do. So in this case, where we've had this specific error type, we've gone down a totally different code path and handled that explicitly. Instead, whereas if we don't receive this error type and we receive a regular error, then we will just log our generic error here. And actually, one thing I did forget to add was actually the log to sayHello
, so let's add that as well.
So this is fine for simple cases, but imagine we have a complex application with multiple layers where we might be wrapping errors at each layer. So for example, if we just created like a wrappedSayHello
, and in here we passed our name through, and then we just called our sayHello
function and wrap the error if one occurred. So this equality check here would not work; this would actually return false because the wrapped error doesn't equal the sentinel error that we've defined above.
So this is where errors.Is
comes in, and this is a function that's part of the errors
package in the standard library, which is used to check if an error matches a sentinel error or is wrapped by a sentinel error somewhere in the chain. So let's just see how this works. So here we can do errors.Is(err, ErrorCodeNameNotSet)
, and now if we run the code again, we should be able to see that 'name is not set'. Actually, we didn't update this to use the wrappedSayHello
. If we run it again, we can see we're logging 'name not set'. However, for example, if we just did the equality check like before, you'll see that we actually got our generic error message here instead. So errors.Is
will check all the errors sequentially in the error chain to see if they match the sentinel error, and if they do, it will return true, and then we can add our custom logic here.
So another option to handle specific errors programmatically, or maybe when you have more error handling logic, is by using custom error types. So this allows you to add even more context to errors by defining structs and implementing the error
interface directly. So for example, let's do this by creating a SayHelloError
, and in here we'll create an error code property. And then let's just define some constants for error codes that we could have. Let's call it ErrorCodeNameNotSet
. And then we can also have ErrorCodeNameTooLong
as a separate code. And then to implement the error
interface, we create the Error()
function, which returns a string. And in here, we can check our error code and return the relevant message. So let's do case ErrorCodeNameNotSet
and we'll return 'name not set'. Otherwise, for ErrorCodeNameTooLong
, we'll return 'name too long', otherwise we'll return a generic error.
And now let's update our sayHello
function to return this error type, so we can do that like so, and this one will be ErrorCodeNameNotSet
. And then let's also add the condition where the name is too long, so we'll just do over 10 characters, and we'll return a SayHelloError
with the ErrorCodeNameTooLong
. Now to handle this error, we need to get the value of our error code when returned from the function. However, we're just returning the error
interface here, so we can't access that directly. And thankfully, that's why we have an errors.As
function in the standard library as well, which we can use here. So this allows you to check and extract a specific error type from the error chain. And so to do that, we define a variable like so, so we'll do sayHelloErr
, and that will be of type SayHelloError
. And then instead of using errors.Is
, we use errors.As
, and we'll pass a pointer to our variable, our error variable, into there as a second parameter. And just like the errors.Is
function, this will return a boolean to say whether or not the error type was within the chain, and then it will set the variable to the underlying error, so we can use that in our code like so. So in here, we could add some logic; we can add like a switch statement to check the error code that we get back from our function. So just like before, we could do ErrorCodeNameNotSet
and do log.Fatal("name not set")
. And then we can add another one for the ErrorCodeNameTooLong
, and we could do log.Fatal("name too long")
with the length. And to do this, we will create a variable just like that. So now we can handle our errors programmatically with different error scenarios that we have, and it also gives us a way to pass extra information in the form of a struct from our function in the layers below back up to where we have called it from. So just to show how this will work, we'll run it now with an empty name, and as you can see, we've got 'name not set' here. But if we add a name that's very, very long, we should get an error back saying the name is too long, and you can see we've got the length of 37.