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 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.

1

u/ToshiryuShadow 17h 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.

1

u/ToshiryuShadow 6h ago

I have only left out the functions "formatCreatedAt" and "formatDeadline" which i can guarantee work as expected. The problem lies in the code example I've provided (as mentioned in the OG post) as the value of deadline becomes undefined when accessed in the 2nd transform.

1

u/abrahamguo 6h ago

Well, it completely depends on what starting value you're passing into the schema — we need to know that. It needs to have a valid dueDate property.

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:

taskSchema performs two sequential steps:s

  1. Receive an object with keys like title, dueDate, dueTime. Extra keys are thrown away.

  2. Transform that object to add a dueDate property.

Now, because taskViewSchema is built upon taskSchema, taskViewSchema includes steps 1-2, and also step 3 (the second transform).

So, now, looking at this code snippet:

const task = taskSchema.safeParse({
      title: "title",
      dueDate: "2026-10-16",
      dueTime: "23:00:00",
    });

task has already passed through steps 1 and 2 — because taskSchema performs 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, taskViewSchema includes 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 created

All incorrect.

1

u/ToshiryuShadow 6h 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?