wandres.dev
REDES EN APPS · APIs y sincronización

Codable en el mundo real: claves, fechas y respuestas que cambian

La distancia entre el Codable de los tutoriales y el JSON que devuelve una API real: renombrar claves y elegir estrategia, domar el caos de las fechas, distinguir un campo ausente de un nulo explícito, y decodificar de forma tolerante respuestas que evolucionan sin avisar, incluidas las listas con elementos corruptos y los enumerados con casos nuevos.

⏱ 20 min

Codable es una de las mejores piezas de Swift y también una de las que más rápido se sale del guion. En un tutorial, el JSON encaja con la estructura y la síntesis automática hace el trabajo entero; en producción, el servidor manda snake_case, tres formatos de fecha distintos según el endpoint, campos que a veces vienen y a veces no, nulos que significan algo diferente a la ausencia, y un enumerado que un martes cualquiera estrena un caso que tu app no conoce. Nada de eso es una anomalía: es la vida normal de una API con varios clientes y varios años. Esta lección trata Codable como lo que realmente es —un motor de traducción configurable— y enseña a apretar los tornillos correctos.

🎯 Al terminar esta lección sabrás
  • Renombrar claves con CodingKeys y elegir entre estrategia global o local.
  • Decodificar fechas reales, incluidas las que ningún formateador estándar acepta.
  • Distinguir ausencia, nulo explícito y valor por defecto, y modelarlos con intención.
  • Decodificar de forma tolerante respuestas que cambian sin romper la app.

Claves distintas: renombrar con intención

La síntesis automática empareja propiedades y claves JSON por nombre exacto. Cuando el servidor usa otra convención hay dos caminos, y elegir mal cuesta caro. El primero es la estrategia global del decodificador: keyDecodingStrategy = .convertFromSnakeCase traduce fecha_de_alta a fechaDeAlta en todos los tipos a la vez. Es cómodo y es una apuesta: das por hecho que toda la API mantiene la convención, para siempre. El segundo camino es declarar CodingKeys en cada tipo, que es más verboso y explícito, y que además documenta el contrato allí donde se lee.

struct ArticuloDTO: Decodable {
    let id: UUID
    let titulo: String
    let urlPortada: URL?

    enum CodingKeys: String, CodingKey {
        case id
        case titulo = "title"
        case urlPortada = "cover_image_url"
    }
}

La regla práctica: usa la estrategia global cuando la API sea consistente y tuya; usa CodingKeys cuando el servidor sea ajeno, cuando los nombres no sean una simple traducción de convención (title a titulo no lo es) o cuando quieras que el mapeo sea visible sin salir del archivo. Y no mezcles ambas sin darte cuenta: con la estrategia global activa, la cadena que escribas en CodingKeys debe ser la clave ya convertida, un detalle que produce fallos desconcertantes.

💡
El decodificador es parte de la configuración, no un detalle

Crea una única instancia de JSONDecoder configurada —estrategia de claves, de fechas, de datos— e inyéctala en el cliente. Un decodificador construido al vuelo dentro de cada función es la garantía de que dos endpoints acabarán interpretando la misma fecha de dos maneras distintas.

Fechas: el desacuerdo permanente

No existe una forma canónica de escribir un instante en JSON, y cada API elige la suya. Las tres familias que verás son la marca de tiempo numérica (segundos o milisegundos desde 1970), la cadena ISO 8601 con o sin fracciones de segundo, y el formato inventado por alguien en 2013. JSONDecoder cubre las dos primeras de fábrica:

let decodificador = JSONDecoder()
decodificador.dateDecodingStrategy = .iso8601        // 2026-07-31T10:15:00Z
// o bien: .secondsSince1970 / .millisecondsSince1970

El problema aparece cuando el mismo servidor mezcla variantes, algo mucho más común de lo que debería. La salida robusta es una estrategia personalizada que intenta varios lectores en orden y solo falla si ninguno acierta, dejando además un error legible:

extension JSONDecoder.DateDecodingStrategy {
    static let tolerante = custom { decodificador in
        let texto = try decodificador.singleValueContainer().decode(String.self)
        let conFraccion = Date.ISO8601FormatStyle(includingFractionalSeconds: true)
        let sinFraccion = Date.ISO8601FormatStyle(includingFractionalSeconds: false)
        if let fecha = try? conFraccion.parse(texto) { return fecha }
        if let fecha = try? sinFraccion.parse(texto) { return fecha }
        throw DecodingError.dataCorrupted(
            .init(codingPath: decodificador.codingPath, debugDescription: "Fecha no reconocida: \(texto)")
        )
    }
}

Dos advertencias que ahorran horas. Primera: ISO8601DateFormatter sin la opción de fracciones rechaza las cadenas que las llevan, y viceversa; por eso hacen falta los dos intentos. Segunda: una fecha sin zona horaria no es un instante, es una intención, y decodificarla como si fuera un instante introduce un desfase que solo se manifiesta para los usuarios de otro huso.

Ausente, nulo y por defecto no son lo mismo

Aquí es donde el modelado se vuelve semántico. En JSON, que una clave no aparezca y que aparezca con valor null son hechos distintos, y Codable los distingue si se lo pides. Con la síntesis automática, una propiedad de tipo opcional acepta ambos casos y los colapsa en nil; suele bastar, pero no siempre. En una API de actualización parcial, por ejemplo, ausente significa “no toques este campo” y nulo significa “bórralo”, y colapsarlos es un error de datos, no de estilo.

struct PerfilDTO: Decodable {
    let nombre: String
    let biografia: String?      // ausente o null, indistinguibles
    let etiquetas: [String]     // queremos lista vacia si falta

    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        nombre = try c.decode(String.self, forKey: .nombre)
        biografia = try c.decodeIfPresent(String.self, forKey: .biografia)
        etiquetas = try c.decodeIfPresent([String].self, forKey: .etiquetas) ?? []
    }
}
🕳️

Ausente

La clave no está. Suele significar que el servidor no tiene el dato o que la versión del cliente no lo pidió.

Nulo explícito

La clave está con valor nulo. Casi siempre es una afirmación: el dato existe como concepto y está vacío a propósito.

🧱

Por defecto

Decisión tuya, no del servidor. Convertir la ausencia en lista vacía o en cero elimina opcionales que no aportan nada al dominio.

Respuestas que cambian sin avisar

Un contrato de API es una promesa que se rompe. Dos roturas son especialmente frecuentes y ambas tienen defensa. La primera: un elemento corrupto dentro de una lista de mil hace fallar la decodificación entera, y la pantalla se queda vacía por culpa de un registro. La solución es un contenedor tolerante que descarta lo que no decodifica y conserva el resto.

struct ListaTolerante<Elemento: Decodable>: Decodable {
    let valores: [Elemento]
    private struct Fallo: Decodable {}

    init(from decoder: Decoder) throws {
        var c = try decoder.unkeyedContainer()
        var acumulado: [Elemento] = []
        while !c.isAtEnd {
            if let valor = try? c.decode(Elemento.self) { acumulado.append(valor) }
            else { _ = try? c.decode(Fallo.self) }   // consume y sigue
        }
        valores = acumulado
    }
}

La segunda rotura son los enumerados. Un enum decodificable modela un conjunto cerrado, pero el servidor cree que es abierto y añadirá casos. Un caso desconocido debe degradar, no explotar:

enum EstadoPedido: String, Decodable {
    case pendiente, enviado, entregado, desconocido

    init(from decoder: Decoder) throws {
        let bruto = try decoder.singleValueContainer().decode(String.self)
        self = EstadoPedido(rawValue: bruto) ?? .desconocido
    }
}
⚠️
Tolerar no es callar

Descartar un elemento o mapear un caso desconocido son decisiones legítimas siempre que dejen rastro. Registra cada descarte con la clave y el motivo: una app que decodifica tolerantemente y en silencio pierde el veinte por ciento de su catálogo sin que nadie se entere durante semanas.

Decodificar no es traducir formatos, es fijar el momento en que un dato ajeno se vuelve tuyo

La costumbre de ver Codable como una utilidad de conversión oculta lo que de verdad ocurre en ese punto del programa. Antes de la decodificación tienes bytes sobre los que no tienes ninguna autoridad: los produjo un sistema ajeno, con su historia, sus tipos y sus errores; después de la decodificación tienes valores de Swift, y con ellos has aceptado un compromiso —que existen, que tienen ese tipo y que cumplen las invariantes que su declaración promete—. La decodificación es, por tanto, el único punto del programa donde puedes decir que no. Cada decode obligatorio es una afirmación de que sin ese campo el dato carece de sentido; cada decodeIfPresent con valor por defecto es la decisión consciente de que la app sabe seguir sin él; cada caso desconocido es la admisión de que el mundo tiene más estados de los que tu enumerado nombra. Elegir mal en ese punto no produce un error de formato, produce un modelo mentiroso: opcionales que arrastras por toda la app porque no te atreviste a decidir en la frontera, o explosiones a mil kilómetros del origen porque forzaste una promesa que el servidor nunca hizo. De ahí la regla que ordena todo el resto: valida en la frontera y confía dentro. El DTO puede ser flexible, tolerante y feo, porque su trabajo es absorber la realidad; el modelo de dominio debe ser estricto y limpio, porque su trabajo es hacer imposible el estado inválido. La función que va del primero al segundo es el único lugar de tu app donde la duda es bienvenida, y precisamente por eso es el único lugar donde no puede haber un try? sin registro.

⚔️ Doma una API hostil
  1. Escribe un DTO cuyas claves no coincidan con las propiedades y resuélvelo con CodingKeys; después repítelo con la estrategia global y compara.
  2. Implementa una estrategia de fechas que acepte ISO 8601 con y sin fracciones de segundo y falle con un mensaje útil.
  3. Modela un campo donde ausente y nulo signifiquen cosas distintas, y justifica el tipo que eliges.
  4. Decodifica una lista con un elemento corrupto usando el contenedor tolerante y registra el descarte.
  5. Añade a un enumerado un caso de repliegue y verifica que un valor nuevo del servidor no rompe la pantalla.