Skip to content

Error Handling

TypeScript uses throw and try/catch. Go treats errors as ordinary return values: every function that can fail returns (result, error). You check the error explicitly — the compiler will not let you ignore it silently. Echo extends this naturally: a handler that returns a non-nil error triggers Echo’s error handler.

TypeScript
// TypeScript / Express — throw-based
async function getBook(id: string): Promise<Book> {
const book = await db.findOne(id);
if (!book) throw new NotFoundException('book not found');
return book;
}
// Express error middleware at the bottom
app.use((err, req, res, next) => {
const status = err.status ?? 500;
res.status(status).json({ message: err.message });
});
Go
// Go / Echo — return-based
func findBook(ctx context.Context, id string) (Book, error) {
book, err := store.FindByID(ctx, id)
if err != nil {
return Book{}, fmt.Errorf("findBook: %w", err)
}
return book, nil
}
// Handler — propagate the error to Echo's error handler
func getBook(c *echo.Context) error {
book, err := findBook(c.Request().Context(), c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, err.Error())
}
return c.JSON(http.StatusOK, book)
}

echo.NewHTTPError(statusCode, message) creates an *echo.HTTPError (the message is a plain string in v5). When a handler returns one, Echo’s default error handler serializes it as {"message": "..."} with the correct HTTP status code. For bare error values, Echo returns 500 Internal Server Error.

Any error that implements StatusCode() int gets the same treatment — that is how the predefined values (echo.ErrNotFound, echo.ErrUnauthorized, …) work. To read a status back out of an arbitrary error, use echo.StatusCode(err); it returns 0 when the error carries none.

// 400
return echo.NewHTTPError(http.StatusBadRequest, "invalid id format")
// 401
return echo.NewHTTPError(http.StatusUnauthorized, "missing token")
// 404
return echo.NewHTTPError(http.StatusNotFound, "book not found")
// 409
return echo.NewHTTPError(http.StatusConflict, "book already exists")

Always wrap errors using fmt.Errorf("context: %w", err) so the original error is preserved for errors.Is / errors.As checks and for structured logging.

TypeScript
// TypeScript — wrapping with cause
throw new Error('failed to save book', { cause: originalError });
// NestJS — HTTP-specific
throw new InternalServerErrorException('failed to save book');
Go
// Go — wrapping with %w
func (s *BookService) Create(ctx context.Context, req CreateBookRequest) (Book, error) {
book, err := s.store.Insert(ctx, req)
if err != nil {
// %w preserves the original error for errors.Is / errors.As
return Book{}, fmt.Errorf("BookService.Create: %w", err)
}
return book, nil
}
// In the handler — check error type
func createBook(c *echo.Context) error {
book, err := svc.Create(c.Request().Context(), req)
if err != nil {
// errors.Is walks the wrap chain
if errors.Is(err, store.ErrDuplicate) {
return echo.NewHTTPError(http.StatusConflict, "book already exists")
}
return echo.NewHTTPError(http.StatusInternalServerError, "failed to create book")
}
return c.JSON(http.StatusCreated, book)
}

Echo lets you replace its default error handler with e.HTTPErrorHandler. This is where you log errors, translate domain errors to HTTP codes, and ensure a consistent response envelope. The default is a factory — echo.DefaultHTTPErrorHandler(exposeError bool) — and it does not log; logging is your job (or a middleware’s).

TypeScript
// Express — error middleware (4-arg function, must be last)
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error(err);
if (err instanceof HttpException) {
return res.status(err.status).json({ message: err.message });
}
res.status(500).json({ message: 'internal server error' });
});
Go
// Echo — custom HTTP error handler (v5: context first, error second)
func customErrorHandler(c *echo.Context, err error) {
// Never write twice: a middleware may already have sent a response.
if resp, uerr := echo.UnwrapResponse(c.Response()); uerr == nil && resp.Committed {
return
}
// Anything carrying an HTTP status (echo.NewHTTPError, echo.ErrNotFound, ...)
if code := echo.StatusCode(err); code != 0 {
var he *echo.HTTPError
msg := http.StatusText(code)
if errors.As(err, &he) {
msg = he.Message
}
_ = c.JSON(code, map[string]any{"message": msg})
return
}
// Unknown error — log it (slog), return 500
c.Logger().Error("unhandled error", "err", err)
_ = c.JSON(http.StatusInternalServerError, map[string]any{
"message": "internal server error",
})
}
// Register in main():
e.HTTPErrorHandler = customErrorHandler

Two v5 details worth memorising: c.Response() is now a plain http.ResponseWriter, so Echo’s own bookkeeping (Status, Size, Committed) lives behind echo.UnwrapResponse; and errors.As(err, &he) only matches errors built with echo.NewHTTPError — the predefined echo.Err* values are a different (unexported) type, which is why the handler asks echo.StatusCode first.

Run this locally — it needs the Echo module and a network port.

What happens when an Echo handler returns a bare (non-HTTPError) error?
Which format string verb preserves the original error for errors.Is checks?
How do you register a custom central error handler in Echo?
In Echo v5, how do you read the HTTP status carried by an arbitrary error (including echo.ErrNotFound)?