Error Handling
Errors as values — the Go philosophy
Section titled “Errors as values — the Go philosophy”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 / Express — throw-basedasync 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 bottomapp.use((err, req, res, next) => { const status = err.status ?? 500; res.status(status).json({ message: err.message });});// Go / Echo — return-basedfunc 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 handlerfunc 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 — HTTP-aware errors
Section titled “echo.NewHTTPError — HTTP-aware errors”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.
// 400return echo.NewHTTPError(http.StatusBadRequest, "invalid id format")
// 401return echo.NewHTTPError(http.StatusUnauthorized, "missing token")
// 404return echo.NewHTTPError(http.StatusNotFound, "book not found")
// 409return echo.NewHTTPError(http.StatusConflict, "book already exists")Wrapping errors with context
Section titled “Wrapping errors with context”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 — wrapping with causethrow new Error('failed to save book', { cause: originalError });
// NestJS — HTTP-specificthrow new InternalServerErrorException('failed to save book');// Go — wrapping with %wfunc (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 typefunc 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)}Central error handler
Section titled “Central error handler”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).
// 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' });});// 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 = customErrorHandlerTwo 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.