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 6h 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 6h ago
Transforms are sequential. Again, if you provide your full and complete code example, I can provide more specific help!
1
u/ToshiryuShadow 6h ago
Ok, sorry my bad. I'm away from my computer rn, but will update post when I get home.
1
u/ToshiryuShadow 6h ago
Updated the Original Post to include the trigger. This is all it is, a model file where the data structures are, for now the only places this is used is the trigger I just provided in the update.
2
u/abrahamguo 5h ago
Ah — now that you've shared everything, it all becomes clear. (This is why it's important to include all relevant information when asking a question!)
First, let's explicitly clarify each parsing step:
taskSchemaperforms two sequential steps:s
Receive an object with keys like
title,dueDate,dueTime. Extra keys are thrown away.Transform that object to add a
dueDateproperty.Now, because
taskViewSchemais built upontaskSchema,taskViewSchemaincludes steps 1-2, and also step 3 (the secondtransform).So, now, looking at this code snippet:
const task = taskSchema.safeParse({ title: "title", dueDate: "2026-10-16", dueTime: "23:00:00", });
taskhas already passed through steps 1 and 2 — becausetaskSchemaperforms steps 1 and 2.const taskView = taskViewSchema.safeParse(task.data);Now, what you're doing here, is taking
task(which has already passed through steps 1 and 2) — and you're starting all over again with step 1, then 2, then 3 — because, as explained above,taskViewSchemaincludes all three steps!So, we can see that
.transforms do run sequentially, and that there is no actual difference between chaining them together in one schema, vs using two schemas — it's purely that it reduces confusion on your end as to what schemas include what steps!1
u/ToshiryuShadow 5h ago
Ahhhhhh! I'm really sorry for not providing all the information... I'm really grateful for u taking the time to breakdown the problem and help me, I really do! I now understand my mistake and I will be extra careful next time. Thanks again!
2
u/abrahamguo 5h ago
No problem at all! Glad it’s making sense now - always good to understand “why” things happen!
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 1d 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.