De XML a JSON: atributos, repetición y la trampa del array de un solo elemento
Publicado el 20/7/2026 · 15 min de lectura · Herramientas para desarrolladores
Daniel Okonkwo — Desarrollador front-end y redactor de Tecnología en Allin
Rendimiento web · Formatos de archivo
Verificado con 4 fuentes
El XML no tiene forma de decir que un elemento es una lista. Los documentos <items><item>a</item></items> y <items><item>a</item><item>b</item></items> solo se diferencian en cuántos hijos existen, así que un conversor que lea uno de ellos no puede saber si item es un elemento repetible que resulta tener una sola instancia. Esta herramienta toma la vía habitual: un hijo devuelve {"items":{"item":"a"}}, una cadena, y dos devuelven {"items":{"item":["a","b"]}}, un array. Cualquier consumidor que escriba items.item[0] funciona hasta el día en que una lista tiene un elemento, y entonces lee la letra a —el primer carácter de la cadena— en lugar del elemento. El mismo documento puede producir las dos formas a la vez: <r><g><i>1</i><i>2</i></g><g><i>3</i></g></r> devuelve g como array de dos objetos, el primero con i como array de dos y el segundo con i como simple cadena. Existen cuatro convenciones para afrontarlo. Declarar la forma en un esquema, el único sitio donde la cardinalidad se escribe alguna vez; dar al conversor una lista explícita de rutas que siempre son arrays; prefijar los nombres de atributo para que no choquen con los de elemento; y reservar una clave para el texto de un elemento que además lleva atributos. Esta herramienta hace las dos últimas: los atributos pasan a @nombre, y #text lleva el texto propio de un elemento, tenga este atributos, hijos elemento o ambas cosas. El contenido mixto se conserva: <p>Hello <b>world</b>!</p> devuelve {"p":{"#text":["Hello","!"],"b":"world"}}, una entrada de array por cada tramo de texto. Lo que nada registra es el entrelazado: la salida no dice que Hello iba antes de <b> y el signo de exclamación después. Los tramos que solo son espacios se descartan y el resto se recorta, salvo que xml:space="preserve" esté en vigor, en cuyo caso el espaciado se conserva tal cual: <code xml:space="preserve"> keep </code> devuelve {"code":{"@xml:space":"preserve","#text":" keep "}}. Todo valor es una cadena: <n>42</n> pasa a "42".
Dos documentos que solo se diferencian en cuántos hijos existen producen dos formas JSON distintas, y ningún conversor puede distinguirlos sin un esquema. Además, lo que este hace de verdad con los atributos, el contenido mixto y los espacios, y lo único que sigue sin poder registrar.
El defecto está en el formato, no en el conversor
Escribe en XML un pedido con tres líneas y repites tres veces el elemento line. Escribe un pedido con una sola línea y escribes el elemento una vez. Nada en el documento distingue eso de un elemento que sencillamente no es repetible: no hay marca de plural, ni cardinalidad, ni corchete. La especificación de XML define cómo es un documento bien formado y no dice absolutamente nada sobre cuántas veces puede aparecer un hijo; esa pregunta pertenece a un esquema, y un documento no está obligado a tener uno.
Así que el conversor adivina, y solo hay dos formas de adivinar. Producir siempre un array, lo que convierte cada elemento de valor único en un array de uno y duplica el ruido de la salida. O producir un array solo cuando se ve repetición, que es lo que hace casi toda herramienta, esta incluida. Dale <items><item>a</item></items> y devuelve {"items":{"item":"a"}}. Dale <items><item>a</item><item>b</item></items> y devuelve {"items":{"item":["a","b"]}}. Dale <items></items> y devuelve {"items":""} —una cadena vacía, no un array vacío—, porque sin hijos el elemento se trata como hoja y su contenido textual es nada.
La consecuencia es que la forma de tu JSON depende de tus datos, y puede cambiar dentro de un mismo documento. Convierte <r><g><i>1</i><i>2</i></g><g><i>3</i></g></r> y obtienes g como array de dos objetos: en el primero, i es un array de dos cadenas; en el segundo, i es la cadena 3. Un código que recorre g y luego indexa i funciona en el primer elemento y lee en silencio el carácter 3 en el segundo: ni error, ni caída, solo el valor equivocado camino de lo que venga después. Es con diferencia la manera más habitual en que una integración de XML que funcionaba se rompe en producción, y se rompe el día en que los datos se hacen más pequeños, no más grandes.
Las cuatro convenciones que existen, y las dos que usa esta herramienta
La primera es un esquema. XML Schema es el único sitio de toda la pila donde la cardinalidad está escrita: una declaración de elemento lleva minOccurs y maxOccurs, y un maxOccurs mayor que uno es exactamente la afirmación de que ese elemento es una lista. Un conversor con el esquema en la mano puede producir correctamente un array de uno para un documento que resulte tener un solo hijo. Sin el esquema, esa información no existe en ninguna parte del archivo, y ninguna astucia la recupera.
La segunda es una lista de rutas siempre-array entregada al conversor por quien conoce los datos. La mayoría de las bibliotecas XML serias aceptan una: nombras order.lines.line y pasa a ser un array sea cual sea el número. Es un esquema en miniatura, escrito una vez por un humano que sabe la respuesta, y es el arreglo pragmático cuando no existe un esquema formal. Esta herramienta no tiene esa opción, así que si consumes su salida desde código, la forma defensiva es normalizar antes de usarla: fuerza tú el valor a array si no lo es ya, y luego indexa.
La tercera y la cuarta van de claves, y esta herramienta aplica las dos. Los atributos llevan el prefijo @, así que <book id="1"><title>Dune</title></book> devuelve {"book":{"@id":"1","title":"Dune"}} y un atributo nunca puede chocar con un hijo del mismo nombre: <book title="A"><title>B</title></book> conserva ambos, como @title y title. Y #text lleva el texto propio de un elemento en cuanto ese texto tiene que compartir el objeto con algo más, sean atributos o hijos elemento: <book id="1" lang="en">Dune</book> devuelve {"book":{"@id":"1","@lang":"en","#text":"Dune"}}. Un elemento sin atributos y sin hijos elemento se salta la envoltura por completo y pasa a ser directamente su texto, y por eso <title>Dune</title> es la cadena Dune y no un objeto de una clave.
El contenido mixto acaba en #text; el orden, no
El contenido mixto es un elemento cuyos hijos mezclan texto y otros elementos: un párrafo con una palabra en negrita en medio, una descripción con un enlace en línea, una cláusula legal con un término destacado. XML lo admite y lo usa constantemente; es buena parte de aquello para lo que sirve el XML documental. JSON no tiene un lugar natural donde ponerlo, porque una clave de objeto puede contener la palabra en negrita pero no hay dónde registrar que esa palabra estaba entre dos tramos de texto.
Este conversor resuelve el problema recogiendo el texto en #text. Dale <p>Hello <b>world</b>!</p> y devuelve {"p":{"#text":["Hello","!"],"b":"world"}}: una entrada por cada tramo de texto, en el orden del documento, con el marcado que los separó al lado del array bajo su propia clave. Un tramo único se queda como cadena simple en vez de array de uno: <r>lead<a>1</a></r> da "#text":"lead". Los atributos no cambian nada: <p id="1">Hello <b>x</b> tail</p> devuelve {"p":{"@id":"1","#text":["Hello","tail"],"b":"x"}}. Ya no se tira nada, así que un registro ONIX, un fragmento DocBook o una descripción RSS con marcado conservan su prosa. Lo que la forma no puede decirte es dónde estaba el marcado: nada en ese objeto dice que Hello iba antes de <b> y el signo de exclamación después, e intercambiar los dos tramos en el origen produce un JSON idéntico. Si la secuencia tiene sentido —una revisión, una transcripción, cualquier cosa donde el elemento en línea marque una posición en la frase—, guarda el párrafo como cadena de XML en vez de convertirlo.
Los espacios se tratan en dos capas. Un tramo que solo son espacios o saltos de línea entre dos etiquetas es maquetación y no contenido, así que se descarta —por eso un documento formateado no llena tu JSON de cadenas vacías— y el texto que sobrevive se recorta, de modo que <code> indented line</code> vuelve como indented line. XML tiene un atributo cuya razón de ser es decir no hagas eso, y el conversor ya lo obedece: <code xml:space="preserve"> keep </code> devuelve {"code":{"@xml:space":"preserve","#text":" keep "}}, espacios incluidos. Se hereda como manda la especificación: un preserve en un antepasado protege a todos sus descendientes hasta que uno de ellos vuelva a declarar xml:space="default".
Orden, espacios de nombres y tipos: otras tres cosas que no sobreviven
El orden del documento entre nombres de elemento distintos se pierde. <r><a>1</a><b>x</b><a>2</a></r> devuelve a como array de 1 y 2, y b como x, lo cual es correcto hasta ahí —los dos elementos a se recogen aunque no sean adyacentes—, pero el hecho de que b estuviera entre ellos ha desaparecido. En un objeto, las claves no tienen un orden en el que un consumidor pueda apoyarse, así que no hay dónde registrarlo. Para XML con forma de registro, da igual. Para todo aquello donde la secuencia significa algo —un registro de flujo de trabajo, un historial de cambios, una narración entrelazada—, importa muchísimo, y la conversión pierde información de un modo que ninguna comparación de tamaños revela.
Los espacios de nombres se transportan como texto, no se entienden. Un hijo con prefijo conserva el prefijo en la clave: <r xmlns:ns="http://example.com"><ns:a>1</ns:a></r> da la clave ns:a, y la propia declaración aparece como atributo @xmlns:ns. Un espacio de nombres por defecto se declara como @xmlns y luego los hijos pierden todo rastro de él, así que un elemento a en un espacio de nombres y un elemento a sin espacio de nombres producen la misma clave. Si se mezclan dos vocabularios en un documento y ambos usan el mismo nombre local, obtienes una colisión ante la que el conversor está ciego. Además el prefijo es una elección del autor, no parte de la identidad del elemento: el mismo documento reserializado con otro prefijo produce claves JSON distintas para datos idénticos.
Los tipos no se adivinan, y aquí la herramienta acierta. <a><n>42</n><f>1.0</f><z>007</z><t>true</t></a> devuelve cuatro cadenas, no un número, un decimal, una cadena con ceros y un booleano. Un documento XML sin esquema tampoco tiene tipos —todo es dato de caracteres—, así que inventarlos sería inventar información. La consecuencia práctica es que compararás con "true" y no con true, y harás tú la conversión donde necesites un número. Es el arbitraje correcto: pasar de cadena a número es una decisión que exige conocer el campo, y el conversor no lo conoce.
Qué rechaza de verdad este conversor
El analizador de debajo es el del propio navegador, y es estricto como debe: <br> a secas se rechaza, una etiqueta sin cerrar se rechaza, dos elementos raíz se rechazan y un atributo duplicado se rechaza. El rigor es justamente el sentido de usar un analizador de XML y no uno de HTML, y todo eso es comportamiento correcto.
Detectar el fallo es más difícil de lo que parece, porque un analizador de navegador no lanza una excepción. Devuelve un documento en el que ha injertado un elemento llamado parsererror, y se espera que quien llama vaya a buscarlo. Buscar solo el nombre es la implementación obvia y la equivocada: rechaza cualquier documento válido que lleve su propio elemento parsererror, que es justo lo que contiene un registro de compilación o un informe de validación. Los motores ni siquiera coinciden en dónde va el marcador: Firefox enraíza todo el documento en uno dentro de su propio espacio de nombres de error, mientras que Blink y WebKit inyectan uno en el espacio de nombres XHTML a media altura y dejan tu raíz en su sitio. Así que la herramienta pregunta al motor en vez de adivinar: una vez por sesión analiza algo deliberadamente roto, lee el espacio de nombres del marcador que vuelve y a partir de ahí solo mira ahí. <log><parsererror>none</parsererror><n>1</n></log> se convierte, y devuelve {"log":{"parsererror":"none","n":"1"}}.
| XML | JSON devuelto | Qué te dice |
|---|---|---|
| <items><item>a</item></items> | {"items":{"item":"a"}} — una cadena | Un hijo no es una lista; indexar [0] devuelve el primer carácter |
| <items><item>a</item><item>b</item></items> | {"items":{"item":["a","b"]}} — un array | La forma de la salida depende del recuento, no del vocabulario |
| <items></items> o <items/> | {"items":""} — una cadena vacía | Ni array vacío ni null: tres estados se reducen a uno |
| <book id="1" lang="en">Dune</book> | {"book":{"@id":"1","@lang":"en","#text":"Dune"}} | Los atributos llevan @, el texto propio lleva #text: ninguno tapa a un hijo |
| <p>Hello <b>world</b>!</p> | {"p":{"#text":["Hello","!"],"b":"world"}} — una entrada por tramo de texto | La prosa sobrevive; el entrelazado no: nada dice que Hello iba antes de <b> |
| <code xml:space="preserve"> keep </code> | {"code":{"@xml:space":"preserve","#text":" keep "}} | La instrucción se obedece y se hereda; sin ella, cada tramo se recorta |
| <log><parsererror>none</parsererror><n>1</n></log> | {"log":{"parsererror":"none","n":"1"}} — convertido, no rechazado | La comprobación de fallo pregunta al motor qué espacio de nombres usa su marcador, así que tu elemento está a salvo |
Preguntas frecuentes
- ¿Cómo escribo código que sobreviva a una lista de uno?
- Normaliza antes de leer. Allí donde tu código espere una lista, fuerza primero el valor: si ya es un array, consérvalo; si no, envuélvelo; y trata un valor ausente o vacío como el array vacío. Tres líneas en la frontera de tu analizador, aplicadas a cada ruta que sepas repetible, y el caso de un solo elemento deja de existir para el resto del programa. Hacerlo en la frontera importa más que el código exacto: una coerción espolvoreada en cada punto de uso acabará olvidándose en uno de ellos, y será el que se ejecuta a fin de mes. Si consumes el mismo flujo con regularidad, escribe la lista de rutas repetibles como constante junto al analizador, para que el siguiente vea qué forma esperabas.
- ¿Por qué no producir siempre un array?
- Porque es ilegible, y la legibilidad es casi todo aquello por lo que se convierte XML a JSON. Envuelve cada elemento y un registro con quince campos de valor único pasa a ser quince arrays de uno, cada uno a desenvolver a mano antes de poder imprimirse. Las bibliotecas que admiten esa estrategia suelen hacerla opcional ruta por ruta justo por eso. Hay además un coste más sutil: un array de uno afirma que el elemento es repetible, y si no lo es, has dejado por escrito un hecho falso sobre el vocabulario. Entre los dos errores, un conversor que adivina a partir de los datos al menos nunca miente sobre el documento que le dieron: solo te dice menos de lo que necesitabas.
- ¿Qué pasa con el texto que rodea mi etiqueta <b>?
- Va a #text, al lado del elemento hijo en vez de alrededor de él. <p>Hello <b>world</b>!</p> devuelve {"p":{"#text":["Hello","!"],"b":"world"}}: una entrada por tramo, en el orden del documento, y una cadena simple en lugar de un array cuando solo hay un tramo. Los tramos que solo son espacios se descartan, salvo que xml:space="preserve" esté en vigor. Lo que no recuperas es el entrelazado: el JSON no puede decirte que Hello iba antes de la palabra en negrita y el signo de exclamación después, y si un párrafo tiene tres tramos y tres elementos en línea, recomponer la frase a partir de ese objeto es adivinar. Si solo necesitas la prosa, lee #text y une los tramos. Si necesitas la frase exactamente como se escribió, no conviertas el párrafo: guárdalo como cadena de XML, o usa un conversor pensado para contenido mixto, que representa los hijos de un elemento como un único array ordenado de nodos de texto y de elemento en vez de como claves de objeto.
- ¿La herramienta maneja CDATA y entidades?
- Sí, ambos, y correctamente. Una sección CDATA se desenvuelve y su contenido pasa a ser texto ordinario, así que <a><![CDATA[<not>markup</not>]]></a> devuelve la cadena <not>markup</not>: los signos de menor y mayor sobreviven como caracteres, que es todo el sentido de CDATA. Las entidades con nombre se resuelven, así que & y < vuelven como el ampersand y el signo menor que, y las referencias numéricas de carácter también: café devuelve café. Los comentarios y las instrucciones de procesamiento, incluida la propia declaración XML, se eliminan por completo, ya que no son contenido de elemento. Nada de esto es obra del conversor: es el analizador de XML del navegador haciendo lo que dice la especificación, buena razón para preferir un analizador de verdad a una expresión regular en cualquier cosa que pase de un flujo fijo que controlas.
- Mi documento está bien formado pero la herramienta dice que no es válido. ¿Y ahora?
- Comprueba dos cosas, en este orden. Primero, busca costumbres de HTML que XML no permite: un <br> o un <img> sin barra de cierre, un ampersand sin escapar en una URL, un atributo duplicado en un elemento, o un carácter perdido antes de la declaración XML, como una marca de orden de bytes llegada por un copiar y pegar. Segundo, confirma que hay exactamente un elemento raíz: dos hermanos en el nivel superior son un fragmento, no un documento, y hay que envolverlos para que algo los analice. Si las dos están limpias y sigue rechazándose, pásalo por un validador de XML, que te señalará una línea en lugar de decirte solo que algo va mal.
Artículos que podrían interesarte
Todas las guías →Herramientas relacionadas
Esto describe lo que hacen estos conversores hoy, comprobado ejecutándolos, y no lo que una norma obligue a hacer a un conversor. El CSV no tiene norma prescriptiva: el RFC 4180 es informativo y describe una práctica habitual, así que dos herramientas aparentemente correctas pueden discrepar sobre el mismo archivo sin que ninguna se equivoque. El aplanado, la detección de tipos y la de arrays son convenciones, no reglas. Antes de convertir datos que no puedas volver a exportar, pasa primero por una copia y compara el número de filas y columnas en ambos extremos.
Fuentes
- W3C — Extensible Markup Language (XML) 1.0, Fifth Edition — section 2.1 on the single root element of a well-formed document, section 2.10 on white space handling and the xml:space attribute, and section 3.2.2 on mixed content
- W3C — W3C XML Schema Definition Language (XSD) 1.1 Part 1: Structures — minOccurs and maxOccurs on a particle: the only place in the XML stack where the cardinality of a repeated element is written down
- W3C — Namespaces in XML 1.0, Third Edition — an element's identity is its namespace name plus its local name, and the prefix is only a document-local shorthand chosen by the author
- WHATWG — HTML Standard, DOMParser and parseFromString — how an XML parse failure is reported as a document containing a parsererror element rather than as a thrown exception
¿Has detectado un error en este artículo?