Skip to content

Error Handling

TypeScript (and JavaScript) use throw/try/catch for error handling — exceptions propagate up the call stack invisibly. Go takes a different approach: errors are just values. A function that can fail returns an error as its last return value. The caller decides what to do with it immediately.

This feels verbose at first. After a while you’ll appreciate that every failure path is explicit and traceable.

TypeScript
// TypeScript: exceptions
async function readConfig(path: string): Promise<Config> {
try {
const data = await fs.readFile(path, "utf-8");
return JSON.parse(data);
} catch (err) {
throw new Error("failed to read config: " + err);
}
}
Go
// Go: errors as return values
func readConfig(path string) (Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return Config{}, fmt.Errorf("failed to read config: %w", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return Config{}, fmt.Errorf("failed to parse config: %w", err)
}
return cfg, nil
}
TypeScript
// TypeScript
throw new Error("something went wrong");
class NotFoundError extends Error {
constructor(public id: string) {
super("item " + id + " not found");
}
}
Go
// Go
import "errors"
import "fmt"
err := errors.New("something went wrong")
// Sentinel error (comparable with errors.Is)
var ErrNotFound = errors.New("not found")
// Custom error type
type NotFoundError struct{ ID string }
func (e *NotFoundError) Error() string {
return fmt.Sprintf("item %s not found", e.ID)
}
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error on %s: %s", e.Field, e.Message)
}
func findUser(id int) error {
if id <= 0 {
return &ValidationError{Field: "id", Message: "must be positive"}
}
if id > 100 {
return fmt.Errorf("findUser: %w", ErrNotFound)
}
return nil
}
func main() {
// errors.Is unwraps the chain
err := findUser(999)
if errors.Is(err, ErrNotFound) {
fmt.Println("user not found (sentinel matched)")
}
// errors.As extracts the concrete type
err = findUser(-1)
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Printf("bad field: %s — %s\n", ve.Field, ve.Message)
}
}

errors.AsType — the generic errors.As (Go 1.26+)

Section titled “errors.AsType — the generic errors.As (Go 1.26+)”

errors.As needs a pre-declared pointer variable and does its check at runtime. Since Go 1.26, errors.AsType[T] returns the typed value directly — the closest Go gets to a TypeScript instanceof narrowing:

TypeScript
// TypeScript: instanceof narrows the type in place
try {
await findUser(-1);
} catch (err) {
if (err instanceof ValidationError) {
console.log(err.field); // narrowed to ValidationError
}
}
Go
// Go 1.26+: errors.AsType returns (T, bool) — no var, no pointer-to-pointer
if ve, ok := errors.AsType[*ValidationError](err); ok {
fmt.Println(ve.Field) // ve is already *ValidationError
}
// The pre-1.26 form still works and you will see it everywhere:
var ve *ValidationError
if errors.As(err, &ve) { ... }

Both walk the wrapped chain the same way; AsType is type-safe at compile time (you cannot pass the wrong pointer kind) and slightly faster. Prefer it in new code; errors.As stays for compatibility.

How does Go signal a function failure to its caller?
What does fmt.Errorf("context: %w", err) do?
errors.Is(err, target) returns true when:
When should you use panic in Go?