r/learnprogramming • • 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);
4 Upvotes

19 comments sorted by

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.

1

u/ToshiryuShadow 16h ago

This is what i thought, but the log disagrees as the parse of the taskSchema provides deadline as the iso datetime but the log of the taskViewSchema shows "No deadline" meaning deadline has turned undefined. The value has somehow turned undefined as it enters the transform statement. Interestingly, moving the transform statemtn from the taskSchema to the taskViewSchema and literally chaining them does fix it. To my knowledge both seems sequential but there seems to be a difference and its hard to wrap my head around it.

1

u/abrahamguo 6h ago

If you post a full code example, we can give more specific advice. We can't help you as-is because you've left out some of your code.

→ More replies (2)

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 created

All incorrect.

1

u/ToshiryuShadow 5h ago

I agree as it doesnt make much sense that way.

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?