Editas un registro, borras el contenido de un campo opcional (o quitas una relación opcional), pulsas "Guardar" y la interfaz confirma que todo salió bien. Al recargar, el valor anterior sigue ahí. No hay ningún error en el servidor. Y si intentas enviar null para forzar el borrado, el esquema de validación lo rechaza:
Invalid input: expected number, received null
(En Zod 3 el texto es Expected number, received null.) Este artículo explica por qué Prisma ignora el cambio, cómo corregir el esquema de Zod para actualizaciones parciales (PATCH) y qué debe enviar el formulario para que "vaciar" un campo funcione.
Por qué pasa
La documentación de Prisma Client fija dos reglas: null is a value (null es un valor) y undefined means do nothing (undefined significa no hacer nada). En un update, una propiedad con undefined se trata como si no estuviera, y solo null escribe NULL en la base de datos. Con este modelo de ejemplo:
model Categoria {
id Int @id @default(autoincrement())
nombre String
productos Producto[]
}
model Producto {
id Int @id @default(autoincrement())
nombre String
descripcion String?
categoriaId Int?
categoria Categoria? @relation(fields: [categoriaId], references: [id])
}
Partiendo de un producto con descripción y categoría, esto es lo que dejó cada llamada (Prisma 6.19, SQLite):
update({ data: { descripcion: undefined, categoriaId: undefined } }) // sin cambios
update({ data: { descripcion: null } }) // descripcion = NULL
update({ data: { categoriaId: null } }) // categoriaId = NULL
Ahora, ¿cómo llega undefined al update si el usuario solo vació el campo? Hay tres eslabones que fallan a la vez:
- Una transformación de Zod convierte
""enundefined. Es un patrón muy común para "limpiar" formularios:
Un campo vacío y un campo ausente quedan igual, y Prisma interpreta ambos como "no tocar".const textoOpcional = z.string().optional().transform((v) => v?.trim() || undefined); // parse({ descripcion: '' }) -> { descripcion: undefined } - El esquema de actualización no admite
null. Conz.number().int().optional(), enviarnullda el error de arriba. El formulario no tenía forma legal de pedir "ninguno". - El selector (combobox) no ofrecía "sin valor". Al quitar la selección enviaba
undefined, yJSON.stringifydescarta las propiedades conundefined:
La propiedad ni siquiera viaja en la petición.JSON.stringify({ a: undefined, b: null }) // '{"b":null}'
Solución
-
En el esquema, convierte el texto vacío en
nully admitenully ausencia.import { z } from 'zod'; const textoLimpiable = z .string() .trim() .transform((v) => (v === '' ? null : v)) .nullable() .optional(); export const actualizarProductoSchema = z.object({ nombre: z.string().trim().min(1).optional(), // obligatorio en BD: nunca null descripcion: textoLimpiable, // se puede vaciar categoriaId: z.number().int().positive().nullable().optional(), });Así se conservan los tres estados: clave ausente (no tocar),
null(borrar) y valor (guardar). En la prueba,parse({})devolvió{}sin ninguna clave,parse({ descripcion: '' })yparse({ descripcion: ' ' })devolvieron{ descripcion: null }, yparse({ categoriaId: null })devolvió{ categoriaId: null }, tanto con Zod 4.6 como con Zod 3.25. -
Pasa el resultado a Prisma tal cual. No "limpies" los
nullantes delupdate:export function actualizarProducto(id: number, body: unknown) { const data = actualizarProductoSchema.parse(body); return prisma.producto.update({ where: { id }, data }); }Las claves ausentes no se tocan y las que valen
nullse borran. Evita patrones comovalor != null ? valor : undefined: la propia documentación de Prisma lo muestra como forma de ignorar unnull, que es justo lo contrario de lo que necesitas aquí. -
En la interfaz, envía
nullde forma explícita y ofrece una opción para quitar el valor:<select value={form.categoriaId ?? ''} onChange={(e) => setForm({ ...form, categoriaId: e.target.value === '' ? null : Number(e.target.value) }) } > <option value="">Quitar categoría</option> {categorias.map((c) => ( <option key={c.id} value={c.id}>{c.nombre}</option> ))} </select>Para valores de texto basta con
valor || null. Con identificadores numéricos usa la comparación con''como arriba, porque0también es falso. -
Para relaciones opcionales, elige un estilo y no los mezcles. Con la clave foránea escalar:
prisma.producto.update({ where: { id }, data: { categoriaId: null } });O con la relación:
prisma.producto.update({ where: { id }, data: { categoria: { disconnect: true } } });Ambas dejaron
categoriaIdenNULLen la prueba. La documentación indica quedisconnectsolo está disponible si la relación es opcional. Si ponescategoriaIdycategoriaen el mismodata, Prisma lanza unPrismaClientValidationErrorcon un mensaje comoUnknown argument `categoriaId`. Did you mean `categoria`?.
Cómo verificar que quedó resuelto
Primero, prueba el esquema de forma aislada, sin base de datos:
expect(actualizarProductoSchema.parse({})).toEqual({});
expect('descripcion' in actualizarProductoSchema.parse({})).toBe(false);
expect(actualizarProductoSchema.parse({ descripcion: '' })).toEqual({ descripcion: null });
expect(actualizarProductoSchema.parse({ categoriaId: null })).toEqual({ categoriaId: null });
Después, comprueba el recorrido completo contra una base de datos de desarrollo: edita un registro, vacía la descripción y quita la categoría, guarda, recarga la página y consulta la fila (con Prisma Studio o una consulta SQL). Las dos columnas deben estar en NULL. En el recorrido de prueba con el cliente de Prisma, un PATCH con solo nombre dejó la descripción y la categoría intactas; uno con descripcion: '' dejó la descripción en NULL, y uno con categoriaId: null dejó la categoría en NULL.
Variantes y errores relacionados
Campos numéricos con z.coerce.number()
Un campo numérico vacío no se comporta como el texto: z.coerce.number().safeParse('') devolvió éxito con el valor 0. Vaciar el campo guardaría un cero. Convierte la cadena vacía antes:
const numeroLimpiable = z.preprocess(
(v) => (v === '' ? null : v),
z.coerce.number().nullable().optional(),
);
Campos obligatorios
No agregues .nullable() a las columnas que en la base de datos son obligatorias: el esquema debe rechazar el borrado antes de llegar a Prisma, con un error de validación claro.
Columnas Json
En campos Json, null es ambiguo (¿el valor JSON null o NULL de la base de datos?). Prisma ofrece Prisma.JsonNull y Prisma.DbNull para distinguirlos; revisa la documentación de campos JSON antes de usar null directamente.
Reutiliza la definición en creación y actualización
Si el esquema de creación y el de actualización definen el mismo campo de forma distinta (uno con .optional() y otro con .nullable().optional()), es fácil que vuelva a aparecer la diferencia entre vaciar y no tocar. Define los campos limpiables una sola vez (por ejemplo, textoLimpiable) y reutilízalos en ambos esquemas.
Resumen
- En Prisma,
undefinedsignifica "no tocar el campo" ynullsignifica "escribir NULL". - Una transformación como
v?.trim() || undefinedconvierte "vaciar" en "no hacer nada" sin dar error. - Para campos limpiables usa
.nullable().optional()y transforma""ennull; deja que las claves ausentes sigan ausentes. - El formulario debe enviar
nullde forma explícita (JSON.stringifyeliminaundefined) y ofrecer una opción "Quitar". - Para relaciones opcionales usa
categoriaId: nullocategoria: { disconnect: true }, nunca ambos en la misma operación.
Comentarios
Publicar un comentario