Zod: Invalid enum value, received '' al leer Excel

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:

  1. defval: "" convierte las celdas vacías en cadenas vacías. En la documentación de SheetJS, defval sustituye los valores null o undefined; sin esa opción, las celdas vacías se omiten y la clave no existe en el objeto. Leyendo un .xlsx con una celda vacía, sheet_to_json(hoja) devolvió {"nombre":"ana"} y, con defval: "", {"nombre":"ana","tipo":""}.
  2. ?? solo reemplaza null y undefined. Según MDN, la cadena vacía y el 0 son valores válidos para este operador: "" ?? "a" devuelve "".
  3. .default() de Zod solo actúa con undefined. La documentación indica que, si la entrada es undefined, se devuelve el valor por defecto. Una cadena vacía es una cadena, así que pasa al z.enum, que no la acepta.

Resultado observado con Zod 3.25 y con Zod 4.6 (misma conducta en ambas):

Entrada para tipoCon .default("a")
clave ausente o undefineddevuelve "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

  1. 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 que String(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 ?? cuando 0, "" o false son 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).

  2. O deja que Zod haga la normalización con z.preprocess. Convierte la cadena vacía en undefined antes 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 es undefined.

  3. 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 sin preprocess. Mantén defval solo 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 cubre null y undefined; úsalo cuando 0 o "" sean valores válidos.
  • Cuando "vacío" significa "valor por defecto", normaliza con String(x ?? "").trim() || valorPorDefecto.
  • Con Zod, usa z.preprocess para convertir las cadenas en blanco en undefined antes del .default(); funciona en Zod 3 y 4.
  • Vigila las columnas numéricas: z.coerce.number() convierte "" en 0.

Referencias

Comentarios