Control de serialización JSON con struct tags en Go

Los struct tags son metadatos pegados a los campos de una estructura que el paquete encoding/json consulta mediante reflection para decidir cómo transformar los datos entre Go y el formato JSON. Por defecto, el serializador usa el nombre del campo tal cual (respetando mayúsculas), pero en APIs profesionales esto es raro; solemos usar camelCase para el cliente y PascalCase para mantener la visibilidad en Go.

Para que esto funcione, el runtime de Go inspecciona las etiquetas de la estructura en tiempo de ejecución. Cuando ejecutas json.Marshal, el paquete lee estas cadenas de texto para renombrar campos, aplicar formatos especiales o decidir si un campo debe ser ignorado. Debes usarlos siempre que tu modelo de dominio en Go deba ser distinto al contrato de tu API. Si los usas mal —especialmente con tipos complejos—, corres el riesgo de enviar datos sensibles, perder precisión en números grandes o enviar campos con valores “cero” que el cliente interpreta de forma errónea.

Cuando necesitas que un número extremadamente grande (como un uint64 de una base de datos) se transmita sin perder precisión en JavaScript, debes usar la etiqueta ,string. Esto fuerza a que el valor se serialice como una cadena de texto en el JSON. Si usas un campo que debe ser opcional, el uso de omitempty es vital, pero requiere que entiendas qué es el zero value (el valor por defecto de un tipo: 0 para int, "" para string, nil para punteros).

Para escenarios de alto rendimiento o arquitecturas de microservicios donde recibes un payload genérico y no quieres deserializar todo el objeto de golpe, puedes usar json.RawMessage. Este tipo guarda los bytes crudos del segmento JSON, permitiéndote decidir más tarde qué estructura usar para parsear esa parte específica.

package main

import (
	"encoding/json"
	"fmt"
)

// Settings define la configuración de un usuario.
type Settings struct {
	Theme string `json:"theme"`
}

// UserProfile representa el modelo de un usuario para una API pública.
type UserProfile struct {
	// ID se envía como string para evitar pérdida de precisión en JS (Number.MAX_SAFE_INTEGER)
	ID uint64 `json:"id,string"`

	// Username es un campo estándar
	Username string `json:"username"`

	// Email se omite si es un string vacío ("")
	Email string `json:"email,omitempty"`

	// InternalCode nunca se incluye en el JSON, es solo para uso interno
	InternalCode string `json:"-"`

	// Metadata permite deferir el parseo de este fragmento JSON
	Metadata json.RawMessage `json:"metadata"`

	// Preferences es un puntero: si es nil, el campo no aparecerá en el JSON
	Preferences *Settings `json:"settings,omitempty"`

	// Theme es un struct directo: siempre aparecerá en el JSON, incluso si está vacío
	Theme Settings `json:"theme"`
}

func main() {
	// Caso 1: Perfil completo con todos los datos presentes
	user1 := UserProfile{
		ID:           9007199254740993, // Mayor que MAX_SAFE_INTEGER de JS
		Username:     "gopher_expert",
		Email:        "expert@example.com",
		InternalCode: "SECRET_HASH_12345",
		Metadata:     json.RawMessage(`{"last_login": "2024-05-20"}`),
		Preferences:  &Settings{Theme: "dark"},
		Theme:        Settings{Theme: "light"},
	}

	// Caso 2: Perfil mínimo (probando omisión de punteros y presencia de structs)
	user2 := UserProfile{
		ID:           123,
		Username:     "minimalist",
		InternalCode: "NO_SECRET",
		Metadata:     json.RawMessage(`{}`),
		Preferences:  nil, // Se omitirá por ser puntero nil
		Theme:        Settings{Theme: "default"}, // No se omite porque es un struct, no un puntero
	}

	for _, u := range []UserProfile{user1, user2} {
		data, err := json.MarshalIndent(u, "", "  ")
		if err != nil {
			fmt.Printf("Error: %v\n", err)
			continue
		}

		fmt.Printf("--- Serialización de %s ---\n", u.Username)
		fmt.Println(string(data))
		fmt.Println()
	}
}

Análisis del ejemplo

En el user1, fíjate en ID. Aunque en Go es un uint64, en el JSON resultante aparecerá como "id": "9007199254740993". Esto es crítico para evitar que el cliente de JavaScript pierda precisión decimal.

En user1, el campo InternalCode ha desaparecido por completo debido a la etiqueta json:"-". Esto es una práctica de seguridad esencial para evitar fugir información de estado interno de tu lógica de negocio en una respuesta de API.

Mira la diferencia entre Preferences y Theme. Preferences es un puntero (*Settings); al ser nil en user2, el serializador lo ignora completamente gracias a omitempty. Sin embargo, Theme es un struct de tipo valor; aunque lo inicializamos con valores por defecto, la estructura siempre se serializa como un objeto en el JSON.

El campo Metadata utiliza json.RawMessage, lo que significa que el serializador no intentó parsear el contenido, simplemente copió los bytes {"last_login": "2024-05-20"} tal cual. Esto es extremadamente eficiente si solo necesitas pasar ese bloque de datos a otro servicio sin procesarlo tú.

El error frecuente

Un error muy común es intentar usar omitempty en un struct para que este desaparezca si sus campos están vacíos.

type Config struct {
    // El desarrollador espera que si Config es {Theme: ""}, el campo "config" no aparezca.
    // Error: El struct no se omite.
    Config Config `json:"config,omitempty"`
}

En Go, un struct no es nil. Aunque todos sus campos tengan su “valor cero”, el struct en sí ocupa un espacio en la memoria de la estructura principal. Para que omitempty funcione y el campo sea omitido cuando no hay datos, el campo debe ser un puntero al struct (*Config). Si es nil, se omite; si es un puntero a un struct vacío, se incluirá en el JSON como un objeto vacío {}.

119

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio