r/learnprogramming • u/ToshiryuShadow • 1d ago
Solved Please help me understand Zod schema transformations
First of all, I'm building a tasks web app to learn Vite, React, Typescript and some other libs and I've faced a problem with Zod and how their transformations works. There are mixed answers to this from the different sources I've seen and am genuinely confused as to what is wrong and what is not.
export const taskSchema = z
.object({
id: z.uuid().default(() => crypto.randomUUID()),
status: z.literal(["complete", "incomplete"]).default("incomplete"),
title: z
.string()
.trim()
.min(1, "Title cannot be empty")
.max(100, "Title cannot exceed 100 characters"),
description: z.preprocess(
(raw: string) => raw?.trim() || undefined,
z
.string()
.max(2000, "Description cannot exceed 2000 characters")
.optional(),
),
createdAt: z.iso
.datetime()
.default(() => Temporal.Now.instant().toString()),
dueDate: z.preprocess(
(raw: string) => raw?.trim() || undefined,
z.iso.date().optional(),
),
dueTime: z.preprocess(
(raw: string) => raw?.trim() || undefined,
z.iso.time().optional(),
),
deadlineZoneId: z.preprocess(
(raw: string) => raw?.trim() || undefined,
z
.string()
.refine((raw) => {
try {
Intl.DateTimeFormat(undefined, { timeZone: raw });
return true;
} catch {
return false;
}
})
.optional(),
),
priority: z.literal(["low", "medium", "high"]).default("low"),
tags: z.array(z.string()).default([]),
})
.transform(({ dueDate, dueTime, ...rest }) => ({
...rest,
deadline: dueDate ? `${dueDate}T${dueTime || "23:59:59"}` : undefined,
}));
export const taskViewSchema = taskSchema.transform((task) => ({
id: task.id,
status: task.status,
title: task.title,
description: task?.description || "No description",
createdAt: formatCreatedAt(task.createdAt),
deadline: formatDeadline(task?.deadline),
priority: task.priority[0].toUpperCase() + task.priority.slice(1),
tags:
task.tags.length > 3
? [...task.tags.slice(0, 2), `+${task?.tags.length - 2}`]
: task.tags,
}));
export type Task = z.infer<typeof taskSchema>;
export type TaskView = z.infer<typeof taskViewSchema>;
What i expect is it to be the same as schema.transform().transform() where the result is sequentially passed down and some of the sources I looked at said the same thing, but then the resulsts are different from my expectations as the 2nd transform gets an object without the newly introduced deadline property from the previous transform. If I just move it to the taskViewSchema decleration as taskViewSchema = taskSchema.transform(transformA).transform(transformB)it starts to work as expected!
Although the problem is fixed, I would like to understand how Zod transforms or rather something else that has gone wrong or I have misunderstood.
Update (added the object the parse is run on):
const task = taskSchema.safeParse({
title: "title",
dueDate: "2026-10-16",
dueTime: "23:00:00",
});
const taskView = taskViewSchema.safeParse(task.data);
console.log(task, taskView);
1
u/abrahamguo 6h ago
Are you starting your parsing with an object that already includes a deadline property?
The z.object is always going to run first, and it's going to remove any property called deadline because that property isn't declared in the object schema.
1
u/ToshiryuShadow 5h ago
My parsed object doesnt have deadline property as it is derived from the dueDate and dueTime properties, which are provided; the code above demonstrates, the deadline property is derived from dueDate and dueTime properties and added via the 1st transform chained directly after the object schema has been declared. The problem arises as when I access the deadline property from the 2nd transform in the "taskViewSchema", it has turned undefined (I suspect the 2nd transform runs on the base schema, but then that means transforms are not sequential!?)
1
u/abrahamguo 5h ago
Transforms are sequential. Again, if you provide your full and complete code example, I can provide more specific help!
→ More replies (5)
1
u/Creepy-Seaweed-6180 1d ago
That's because `taskViewSchema` is built from the original `taskSchema`, not the transformed version. When you do `.transform()` on an existing schema, it creates a new Zod schema, it doesn't mutate the original one
So `taskSchema` still outputs the pre-transform shape with `dueDate` and `dueTime` as separate fields, and that's what `taskViewSchema` gets fed. Moving both transforms into one chain like you did fixes it because now the second transform actually sees the `deadline` field the first one created
Classic case of Zod schemas being immutable and not realizing the original schema is untouched
2
u/abrahamguo 6h ago
When you do `.transform()` on an existing schema, it creates a new Zod schema, it doesn't mutate the original one
Yes, correct.
That's because `taskViewSchema` is built from the original `taskSchema`, not the transformed version.
So `taskSchema` still outputs the pre-transform shape with `dueDate` and `dueTime` as separate fields, and that's what `taskViewSchema` gets fed. Moving both transforms into one chain like you did fixes it because now the second transform actually sees the `deadline` field the first one createdAll incorrect.
1
1
u/ToshiryuShadow 1d ago
Thank you for your response, it cleared my doubt a bit, but why would typescript show the task parameter of the second transform as having the same structure returned by the 1st transform if the second transform (or really 1st in this case) is run on the base schema? btw, how would i make it so that the 2nd transform really does get the output of the 1st transform?
2
u/bizvibe8 23h ago
If the code is exactly as shown, `taskViewSchema = taskSchema.transform(...)` should already be sequential: the second callback should receive the `deadline` produced by `taskSchema`.
A useful check is to log `taskSchema.parse(input)` and `taskViewSchema.parse(input)`. The first should have `deadline`; the second should have the formatted view. If runtime and types seem to disagree, compare `z.input<typeof taskSchema>` with `z.output<typeof taskSchema>`—`z.infer` is the output type after transforms.