Manejo de errores en Go: el idiomático if err != nil y por qué es una buena práctica Go

Manejo de errores en Go: el idiomático if err != nil y por qué es una buena práctica

DUGLAS MORENODUGLAS MORENO 👁 26

Introducción

En el desarrollo de software, los errores son inevitables. Ya sea por fallos de red, archivos inexistentes o datos inesperados, una aplicación robusta debe anticiparlos y reaccionar de forma adecuada. En Go, el manejo de errores se basa en un enfoque simple y explícito: cada función que puede fallar devuelve un valor adicional de tipo error. El idiomático if err != nil se vuelve entonces la forma estándar de comprobar si algo salió mal.

Este artículo está dirigido a programadores que ya conocen los conceptos básicos de Go (sintaxis, estructuras de control, funciones) y quieren profundizar en cómo tratar los errores de forma idiomática, segura y mantenible. Al terminar de leer, comprenderás:

  • Por qué Go eligió representar los errores como valores y no como excepciones.
  • Cómo usar el patrón if err != nil de manera efectiva.
  • Qué técnicas existen para envolver, inspeccionar y personalizar errores.
  • Cuándo es apropiado usar panic y recover y por qué generalmente se evita.
  • Buenas prácticas y errores comunes que debes evitar.

A lo largo del artículo encontrarás ejemplos completos, con su salida esperada, para que puedas copiar y ejecutar cada fragmento en tu entorno o en el playground de Go.

¿Qué es el tipo error en Go?

En la biblioteca estándar, el tipo error se define como una interfaz con un único método:

type error interface {
    Error() string
}

Cualquier tipo que implemente este método puede ser usado como un error. La simplicidad de esta definición permite que cualquier desarrollador cree su propio tipo de error añadiendo comportamiento adicional (códigos, contexto, stack traces, etc.) sin romper la compatibilidad con el código existente.

La ventaja de tratar los errores como valores es que pueden ser inspeccionados, comparados, modificados y propagados usando las mismas herramientas que usamos para cualquier otro dato. No hay necesidad de mecanismos de control de flujo externos como try/catch; el flujo sigue siendo lineal y fácil de seguir.

El idiomático if err != nil

Cuando una función firma incluye un último retorno de tipo error, el llamador tiene la responsabilidad de verificar ese valor. La forma más directa y recomendada es:

if err != nil {
    // manejar el error
}

Este bloque se ejecuta solo cuando err no es nil, es decir, cuando la función indicó que algo salió mal. Si el valor es nil, la operación fue exitosa y el programa continúa normalmente.

¿Por qué está bien? Porque obliga al programador a enfrentar explícitamente la posibilidad de fallo. No hay excepciones silenciosas que se propaguen por la pila de llamadas sin ser vistas; cada posible punto de falla debe ser considerado. Además, el código permanece legible: la ruta feliz (el caso de éxito) queda al mismo nivel de indentación que el manejo de error, sin anidamientos profundos.

Ejemplo básico: función que devuelve error

Veamos una función que intenta abrir un archivo y devuelve su contenido o un error.

package main

import (
	"fmt"
	"os"
)

func leerArchivo(nombre string) (string, error) {
	data, err := os.ReadFile(nombre)
	if err != nil {
		return "", fmt.Errorf("no se pudo leer %s: %w", nombre, err)
	}
	return string(data), nil
}

func main() {
	contenido, err := leerArchivo("ejemplo.txt
)
	if err != nil {
		fmt.Printf("Error: %v
", err)
		return
	}
	fmt.Println("Contenido del archivo:" + contenido)
}

Salida esperada (si el archivo ejemplo.txt no existe):

Error: no se pudo leer ejemplo.txt: open ejemplo.txt: no such file or directory

Si creas el archivo con algún contenido, la salida será:

Contenido del archivo:
<hola mundo>

En este ejemplo, la función leerArchivo devuelve un error envuelto con fmt.Errorf y el verbo %w para preservar el error original. En main, verificamos if err != nil y actuamos en consecuencia.

Manejo de múltiples errores

Algunas funciones pueden devolver más de un valor además del error, por ejemplo, una fila de una base de datos o un par de resultados. El patrón sigue siendo el mismo: primero revisamos el error y, solo si es nil, utilizamos los otros valores.

package main

import (
	"fmt"
)

func dividir(a, b float64) (float64, error) {
	if b == 0 {
		return 0, fmt.Errorf("división por cero\))}
	return a / b, nil
}

func main() {
	resultado, err := dividir(10, 0)
	if err != nil {
		fmt.Printf("Error al dividir: %v
", err)
		return
	}
	fmt.Printf("Resultado: %.2f
", resultado)
}

Salida esperada:

Error al dividir: división por cero

Nota cómo el mensaje de error es claro y directo, gracias al uso de fmt.Errorf.

Envolver errores con fmt.Errorf

A medida que las llamadas se apilan, es útil agregar contexto al error sin perder la información original. Desde Go 1.13, el verbo %w en fmt.Errorf permite envolver un error y crear una cadena de causalidad.

package main

import (
	"fmt"
)

func consultaBD(id int) (string, error) {
	// Simulamos un error de conexión
	return "", fmt.Errorf("fallo al conectar a la base de datos: %w", errConexion)
}

var errConexion = fmt.Errorf("timeout de red\))}

func obtenerUsuario(id int) (string, error) {
	nombre, err := consultaBD(id)
	if err != nil {
		return "", fmt.Errorf("no se pudo obtener usuario %d: %w", id, err)
	}
	return nombre, nil
}

func main() {
	_, err := obtenerUsuario(42)
	if err != nil {
		fmt.Printf("Error final: %v
", err)
	}
}

Salida esperada:

Error final: no se pudo obtener usuario 42: fallo al conectar a la base de datos: timeout de red

Observe cómo el error original (timeout de red) está preservado y puede ser inspeccionado más adelante usando errors.Is o errors.As.

Errores sentinel y errors.Is, errors.As

A veces necesitamos comparar un error con un valor conocido (por ejemplo, io.EOF). Para eso se usan los errores sentinel: variables de tipo error cuyo valor es estático y comparable con ==.

var ErrNotFound = errors.New("recurso no encontrado\))}

Para verificar si un error coincide con un sentinel, se debe usar errors.Is en lugar de ==, ya que esto también recorre la cadena de errores envueltos.

package main

import (
	"errors"
	"fmt"
)

var ErrNotFound = errors.New("recurso no encontrado\)

func buscar(clave string) (string, error) {
	return "", ErrNotFound
}

func main() {
	_, err := buscar("clave\))}
	if errors.Is(err, ErrNotFound) {
		fmt.Println("El recurso solicitado no existe\))}
	} else if err != nil {
		fmt.Printf("Otro error: %v
", err)
	}
}

Salida esperada:

El recurso solicitado no existe

errors.As sirve para intentar convertir un error a un tipo específico, útil cuando se tienen errores personalizados con campos adicionales.

package main

import (
	"errors"
	"fmt"
)

type ErrorDeValidacion struct {
	Campo string
	Razón string
}

func (e *ErrorDeValidacion) Error() string {
	return fmt.Sprintf("campo %s: %s", e.Campo, e.Razón)
}

func validarEdad(edad int) error {
	if edad < 0 {
		return &ErrorDeValidacion{Campo: "edad", Razón: "no puede ser negativa"}
	}
	return nil
}

func main() {
	err := validarEdad(-5)
	if err != nil {
		var ev *ErrorDeValidacion
		if errors.As(err, &ev) {
			fmt.Printf("Error de validación en %s: %s
", ev.Campo, ev.Razón)
		} else {
			fmt.Printf("Error inesperado: %v
", err)
		}
	}
}

Salida esperada:

Error de validación en edad: no puede ser negativa

Errores personalizados

Cuando el contexto del error es rico (códigos, información de usuario, etc.), crear un tipo propio es la mejor opción. Implementar el método Error() satisface la interfaz error y permite añadir comportamientos extra como métodos para obtener códigos de error, mensajes en distintos idiomas, etc.

package main

import (
	"fmt"
)

type ErrorDeEntrada struct {
	Valor  string
	Problema string
}

func (e *ErrorDeEntrada) Error() string {
	return fmt.Sprintf("valor %q no válido: %s", e.Valor, e.Problema)
}

func parsearEntero(s string) (int, error) {
	var n int
	_, err := fmt.Sscan(s, &n)
	if err != nil {
		return 0, &ErrorDeEntrada{Valor: s, Problema: "no es un número entero"}
	}
	return n, nil
}

func main() {
	_, err := parsearEntero("doce\))}
	if err != nil {
		fmt.Printf("Error de parseo: %v
", err)
	}
}

Salida esperada:

Error de parseo: valor "doce" no válido: no es un número entero

Este enfoque permite que el llamador inspeccione los campos Valor y Problema mediante aserciones de tipo o errors.As si necesita tomar decisiones basadas en ellos.

Uso de panic y recover

Go proporciona el mecanismo de panic para situaciones que indican un error irrecuperable o una condición que debería impedir que el programa continúe (por ejemplo, una violación de invariante crítico). panic detiene la ejecución normal, comienza a desenrollar la pila ejecutando las llamadas diferidas (defer) y, si no hay un recover, termina el programa con un mensaje de error y el stack trace.

recover solo funciona dentro de una función diferida y captura el valor pasado a panic, permitiendo que el programa retome la ejecución normalmente.

Cuando usar panic

  • Detectar una inconsistencia lógica que indique un bug (por ejemplo, recibir un nil donde nunca debería ser posible).
  • En funciones de inicialización donde un fallo hace que el programa no pueda cumplir su propósito.

Cuando evitar panic

  • Para errores esperables como entrada de usuario, fallos de red o archivos faltantes. En esos casos, devolver un error es la forma correcta.

Ejemplo de uso adecuado de panic:

package main

import (
	"fmt"
)

func calcularPromedio(nums []float64) float64 {
	if len(nums) == 0 {
		panic("calcularPromedio llamado con slice vacío\)
	}
	sum := 0.0
	for _, v := range nums {
		sum += v
	}
	return sum / float64(len(nums))
}

func main() {
	defer func() {
		if r := recover(); r != nil {
			fmt.Printf("Recuperado de panic: %v
", r)
		}
	}()
	prom := calcularPromedio([]float64{})
	fmt.Printf("Promedio: %.2f
", prom)
}

Salida esperada:

Recuperado de panic: calcularPromedio llamado con slice vacío

En este caso, el defer captura el panic y el programa continúa, mostrando el mensaje de recuperación. Si elimináramos el defer, el programa abortaría con un stack trace.

Buenas prácticas

  1. Siempre revisa el error inmediatamente después de la llamada.
    No asumas que una función no puede fallar; verifica err antes de usar cualquier otro valor de retorno.

  2. No ignores errores con _ salvo que realmente no importe.
    Si decides descartar un error, comenta por qué es seguro hacerlo.

  3. Envuelve errores con contexto usando %w cuando vayas a agregar información útil para depurar.
    Evita crear mensajes de error que oculten la causa original.

  4. Prefiere errores sentinel o tipos personalizados para condiciones específicas.
    Esto facilita que el llamador tome decisiones diferentes según el tipo de fallo.

  5. Usa errors.Is y errors.As en lugar de comparaciones directas.
    Así trabajas con cadenas de errores envueltos y con tipos personalizados de forma segura.

  6. Mantén los mensajes de error claros y en el idioma de los usuarios si van a ser mostrados directamente; para uso interno, sé conciso y técnico.

  7. No uses panic para control de flujo ordinario.
    Resérvalo para situaciones realmente excepcionales que indiquen un bug.

  8. Documenta los posibles errores que tu función puede devolver en los comentarios o en la documentación generada por godoc.

Errores comunes

  • Olvidar chequear el error: lleva a nil pointer dereference o a usar valores sin sentido.
  • Sobrescribir el error original al envolverlo sin %w, perdiendo la capacidad de comparar con errors.Is.
  • Devolver errores genéricos como errors.New("algo falló\) sin contexto, dificultando la depuración.
  • Usar panic para manejar errores de entrada; esto termina el programa inesperadamente y proporciona mala experiencia al usuario.
  • Ignorar errores en goroutines: si una goroutine falla y no se registra, el problema puede pasar desapercibido.
  • No liberar recursos (como archivos o conexiones) cuando ocurre un error antes del final de la función.

Conclusión

El manejo de errores en Go, centrado en el tipo error y el idiomático if err != nil, es una de las características que hacen al lenguaje predecible y fácil de razonar. Al tratar los errores como valores explícitos, Go obliga al desarrollador a enfrentar cada punto de posible fallo, lo que resulta en código más seguro y más sencillo de mantener.

A lo largo de este artículo vimos cómo:

  • Declarar y devolver errores usando la interfaz estándar.
  • Aplicar el patrón if err != nil de forma inmediata y legible.
  • Envolver errores con %w para agregar contexto sin perder la información original.
  • Comparar y extraer errores específicos con errors.Is y errors.As.
  • Crear tipos de error personalizados cuando se necesita más detalle.
  • Utilizar panic y recover únicamente en situaciones realmente excepcionales.
  • Seguir buenas prácticas y evitar los errores más frecuentes.

Con estas herramientas, estás listo para escribir aplicaciones Go que manejen los fallos de forma elegante, informativa y sin sorpresas. La próxima vez que escribas una función que pueda fallar, recuerda: el if err != nil no es solo una convención, es la base de la confiabilidad en Go.


¿Te quedó alguna duda? Deja un comentario o prueba modificar los ejemplos para ver cómo cambian los mensajes de error. La práctica constante es la mejor forma de interiorizar estos patrones.

Comentarios (0)

Sé el primero en comentar.

Dejá tu comentario