Subes un archivo de Excel correcto, con una columna opcional que quedó vacía en todas las filas, y el servidor rechaza cada fila. Al validar con Zod aparece este error (Zod 3):
[
{
"received": "",
"code": "invalid_enum_value",
"options": ["a", "b"],
"path": ["tipo"],
"message": "Invalid enum value. Expected 'a' | 'b', received ''"
}
]
En Zod 4 el mensaje cambia a Invalid option: expected one of "a"|"b" (con el código invalid_value), pero la causa es la misma. El esquema parece correcto, porque la columna tiene un valor por defecto:
const Fila = z.object({
nombre: z.string(),
tipo: z.enum(["a", "b"]).default("a"),
});
const filas = XLSX.utils
.sheet_to_json(hoja, { defval: "" })
.map((fila) => Fila.parse(fila));
Este artículo explica por qué el valor por defecto nunca se aplica, cuándo usar ?? y cuándo ||, y cómo normalizar las celdas vacías con y sin Zod.
Por qué pasa
El fallo es la suma de tres comportamientos que, por separado, son correctos:
defval: ""convierte las celdas vacías en cadenas vacías. En la documentación de SheetJS,defvalsustituye los valoresnulloundefined; sin esa opción, las celdas vacías se omiten y la clave no existe en el objeto. Leyendo un.xlsxcon una celda vacía,sheet_to_json(hoja)devolvió{"nombre":"ana"}y, condefval: "",{"nombre":"ana","tipo":""}.??solo reemplazanullyundefined. Según MDN, la cadena vacía y el 0 son valores válidos para este operador:"" ?? "a"devuelve""..default()de Zod solo actúa conundefined. La documentación indica que, si la entrada esundefined, se devuelve el valor por defecto. Una cadena vacía es una cadena, así que pasa alz.enum, que no la acepta.
Resultado observado con Zod 3.25 y con Zod 4.6 (misma conducta en ambas):
Entrada para tipo | Con .default("a") |
|---|---|
clave ausente o undefined | devuelve "a" |
"" | error de enum |
null (probado con Zod 4) | error de enum |
Con ExcelJS el patrón es distinto. Según su documentación, row.eachCell() visita solo las celdas no nulas, y con { includeEmpty: true } visita también las vacías. En la prueba, el valor de una celda vacía fue null, no una cadena vacía, de modo que ahí ?? sí funciona, pero .default() tampoco se aplica a null.
Solución
-
Normaliza la celda en el punto de lectura. Convierte a texto, recorta espacios y sustituye lo vacío por el valor por defecto:
export function tipoDesdeCelda(valor: unknown, porDefecto = "a"): string { return String(valor ?? "").trim() || porDefecto; }Cada operador tiene un trabajo.
?? ""evita queString(null)produzca el texto"null".trim()trata una celda con solo espacios como vacía.||reemplaza la cadena vacía resultante, que es justo lo que se busca aquí. El número 0 no se pierde:String(0)es"0", una cadena no vacía.Regla práctica: usa
??cuando0,""ofalseson valores legítimos que no debes tocar; usa||cuando "vacío" significa "usa el valor por defecto" (y convierte antes a texto si el dato puede ser un número). -
O deja que Zod haga la normalización con
z.preprocess. Convierte la cadena vacía enundefinedantes del enum, y el.default()interno ya funciona:import * as XLSX from "xlsx"; import { z } from "zod"; const vacioComoUndefined = (valor: unknown): unknown => typeof valor === "string" && valor.trim() === "" ? undefined : valor; export const FilaSchema = z.object({ nombre: z.string().min(1), tipo: z.preprocess(vacioComoUndefined, z.enum(["a", "b"]).default("a")), }); export type Fila = z.infer<typeof FilaSchema>; export function leerFilas(hoja: XLSX.WorkSheet): Fila[] { const crudas = XLSX.utils.sheet_to_json<Record<string, unknown>>(hoja, { defval: "" }); return crudas.map((fila) => FilaSchema.parse(fila)); }Un valor inválido como
"x"sigue rechazándose, que es lo deseable. Evita.catch("a")para este caso: oculta también los errores reales de datos. Tampoco sirve.optional(), porque""no esundefined. -
Si no necesitas
defval, quítalo. Sin esa opción, SheetJS omite las celdas vacías y la clave llega ausente, de modo que.default("a")funciona sinpreprocess. Manténdefvalsolo si necesitas que todas las claves existan en cada fila.
Cómo verificar que quedó resuelto
Estas pruebas generan un .xlsx en memoria, lo leen de nuevo y comprueban los cuatro comportamientos que importan:
import { test } from "node:test";
import assert from "node:assert/strict";
import * as XLSX from "xlsx";
import { leerFilas, tipoDesdeCelda } from "./leer-excel.ts";
/** Crea una hoja, la guarda como .xlsx en memoria y la vuelve a leer. */
function hojaDesde(filas: unknown[][]): XLSX.WorkSheet {
const libro = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(libro, XLSX.utils.aoa_to_sheet(filas), "Hoja1");
const archivo = XLSX.write(libro, { type: "buffer", bookType: "xlsx" });
return XLSX.read(archivo).Sheets["Hoja1"];
}
test("celda vacía usa el valor por defecto", () => {
const filas = leerFilas(hojaDesde([["nombre", "tipo"], ["ana", null], ["luis", "b"]]));
assert.deepEqual(filas, [{ nombre: "ana", tipo: "a" }, { nombre: "luis", tipo: "b" }]);
});
test("celda con solo espacios usa el valor por defecto", () => {
const filas = leerFilas(hojaDesde([["nombre", "tipo"], ["ana", " "]]));
assert.equal(filas[0].tipo, "a");
});
test("un valor inválido sigue rechazándose", () => {
assert.throws(() => leerFilas(hojaDesde([["nombre", "tipo"], ["ana", "x"]])));
});
test("tipoDesdeCelda cubre null, undefined, vacío y números", () => {
assert.equal(tipoDesdeCelda(null), "a");
assert.equal(tipoDesdeCelda(undefined), "a");
assert.equal(tipoDesdeCelda(""), "a");
assert.equal(tipoDesdeCelda(" b "), "b");
assert.equal(tipoDesdeCelda(0), "0");
});
node --test leer-excel.test.ts
npx tsc --noEmit
En la reproducción local (Node 24, xlsx 0.18.5, Zod 3.25 y Zod 4.6) las cuatro pruebas pasaron y tsc terminó sin errores. El esquema con z.preprocess dio el mismo resultado en las dos versiones de Zod. Revisa también, uno por uno, los demás lugares donde tu proyecto lee archivos de Excel o CSV: cualquier parser que use un valor por defecto de cadena vacía y luego ?? tiene el mismo problema.
Variantes y errores relacionados
Columnas numéricas: la cadena vacía se convierte en 0
Según la documentación de Zod, z.coerce.number() convierte la entrada con Number(entrada), y Number("") es 0, así que una celda vacía pasa como 0 sin ningún error (probado en Zod 3 y 4). Si "vacío" debe significar "sin dato", pasa primero la entrada por la misma función vacioComoUndefined y marca el campo como .optional().
defval: null
Con null en lugar de "", .default() tampoco actúa. En la prueba, un preprocess que convierte tanto null como las cadenas en blanco en undefined funcionó en Zod 3 y 4:
const blancoAUndefined = (v: unknown): unknown =>
v === null || (typeof v === "string" && v.trim() === "") ? undefined : v;
ExcelJS: celdas vacías al final de la fila
En la prueba, row.eachCell({ includeEmpty: true }) visitó la celda vacía del medio de una fila (valor null), pero no la celda vacía final, porque la fila no la almacena. Para obtener siempre las columnas por posición usa row.getCell(n).value, que devuelve null si no hay valor.
Celdas con solo espacios
Para el usuario parecen vacías, pero " " || "a" devuelve los espacios. Por eso el patrón incluye trim().
Resumen
sheet_to_json(hoja, { defval: "" })produce""en las celdas vacías, y ni??ni.default()de Zod reaccionan a una cadena vacía.??solo cubrenullyundefined; úsalo cuando0o""sean valores válidos.- Cuando "vacío" significa "valor por defecto", normaliza con
String(x ?? "").trim() || valorPorDefecto. - Con Zod, usa
z.preprocesspara convertir las cadenas en blanco enundefinedantes del.default(); funciona en Zod 3 y 4. - Vigila las columnas numéricas:
z.coerce.number()convierte""en 0.
Comentarios
Publicar un comentario