Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Could Zod provide type guards for narrowing union types? #430

Closed
redbaron opened this issue May 10, 2021 · 6 comments
Closed

Could Zod provide type guards for narrowing union types? #430

redbaron opened this issue May 10, 2021 · 6 comments

Comments

@redbaron
Copy link

redbaron commented May 10, 2021

I am using v3 branch and would like to ask if it is possible for Zod to provide type guards? This will be handy when dealing with union types:

const ASchema = z.object({a: z.string()});
type A = z.infer<typeof ASchema>;

const BSchema = z.object({b: z.string()});
type B = z.infer<typeof BSchema>;

const ABSchema = z.union([ASchema, BSchema])
const AB = z.infer<typeof ABSchema>;  // { a: string } | { b: string}


function f(x: AB) {
   if (ASchema.guard(x)) {
       x.a   // x is A
   } else {
       x.b 
   }
}
@scotttrinh
Copy link
Collaborator

No type guards in Zod@3 due to how they interact with the transform feature, however you could definitely do this with parsing:

function f(x: AB) {
  const maybeA = ASchema.safeParse(x);
  if (maybeA.success) {
    maybeA.data.a // maybeA.data is A
  }

  const maybeB = BSchema.safeParse(x);
  if (maybeB.success) {
    maybeB.data.b // maybeB.data is B
  }

  throw new Error("Could not parse with ASchema or BSchema");
}

I've found that most unions want (or already have!) some kind of discriminant though, so in that case TypeScript is already smart enough to narrow the type if you check the discriminant.

const ASchema = z.object({ type: z.literal("A"), a: z.string() });
const BSchema = z.object({ type: z.literal("B"), b: z.string() });

const ABSchema = z.union([ASchema, BSchema]);
type AB = z.infer<typeof ABSchema>;

function f(x: AB) {
  switch (x.type) {
    case "A": {
      x.a // x is A
      break;
    }
    case "B": {
      x.b // x is B
      break;
    }
  }
}

@colinhacks
Copy link
Owner

Thanks Scott 👍

@redbaron I propose using a discriminated union for this. It's the best way to handle unions like this:

  const ASchema = z.object({ kind: z.literal("a"), a: z.string() });
  const BSchema = z.object({ kind: z.literal("b"), b: z.string() });
  const ABSchema = z.union([ASchema, BSchema]);
  
  const value = ABSchema.parse("asdf");
  if (value.kind === "a") {
    value.a;
  }else{
    value.b;
  }

@redbaron
Copy link
Author

I ended up writing my own type guard :( Couldn't use the discriminated union, because the schema is not set by me. Why type guards are interacting badly with transform?

@scotttrinh
Copy link
Collaborator

scotttrinh commented May 20, 2021

I actually had that same question in #293 and it took a second to wrap my head around it, but since transformers have an input and output type, the original data is only validated for the Input type, but the Output type is only valid for the value returned by parse or safeParse. Here's an example:

import { z } from "zod";

const transformed = z.object({ foo: z.string().transform((maybeNumber) => Number(maybeNumber) });

type InputType = z.input<typeof transformed>; // { foo: string; }
type OutputType = z.output<typeof transformed>; // { foo: number; }

const validInput = { foo: "42" };

const validOutput = transformed.parse(validInput);

// validInput is of type { foo: string; }
// validOutput is of type { foo: number; }

Playground link

@scotttrinh
Copy link
Collaborator

It's a bit of a heavy approach, but sometimes I'll tag the data myself by either wrapping it like { tag: "A", data: originalData } or just adding a __tag property or something.

@imhoffd
Copy link

imhoffd commented Sep 11, 2023

I'm fairly new to Zod, but it appears this can safely be done now with the third generic parameter of ZodType. Would it make sense to add the check() method back? Am I missing something?

import { z } from 'zod'

const MySchema = z.object({
  foo: z.string().transform(value => Number(value))
})

const input = { foo: "bar" } as unknown
const output = MySchema.parse(input)

export const check =
  <Output = any, Def extends z.ZodTypeDef = z.ZodTypeDef, Input = Output>(
    schema: z.ZodType<Output, Def, Input>,
    data: unknown,
  ): data is Input =>
    schema.safeParse(data).success

console.log('this is unknown:', input)

if (check(MySchema, input)) {
  console.log('this is a string:', input.foo)
  console.log('this is a number:', output.foo)
}

playground: https://tsplay.dev/mZJz9m

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants