zod icon indicating copy to clipboard operation
zod copied to clipboard

Q: why discriminatedUnion of one element is not possible

Open dearlordylord opened this issue 3 years ago • 3 comments

According to discriminatedUnion type definition, it accepts a list of at least two elements.

My question is why is this implemented like this?

Question arose for the reason that I have a real use case where I have a factory function that would return parsers for n>=1 string literals i.e.

const makeApiIdParser = (literals: NonEmptyArray<string>) => z.preprocess(
  (uuid) => {
    if (typeof uuid !== 'string') throw new Error('must be a string');
    const [type, id] = uuid.split(SEPARATOR);
    return { type, id };
  },
  z.discriminatedUnion('type', literals.map((type) => z.object({
    type: z.literal(type),
    id: z.string(),
  })))
);

This function would generate parsers for such strings as user::27f4ee03-e79e-470c-8d4e-fa28e8ac6089 but also a separate parsers for such strings as blog::7fe494ab-a349-4896-87f2-24e2024d2cce or collective::69e23e02-546b-4c30-a532-b3f45e8498f8

I could get by with if/else checking for my literals length, but it goes against normal intuition and would be akin to doing

const map = <T, R>(a: T[], f: (t: T) => R): R[] => {
  if (!a.length) return a;
  return a.map(f);
}

I hope this is a clear enough parallel to why I see the definition of discriminatedUnion as a bit weird.

There must be some technical restriction I don't see or some logical aspect I fail to recognize.

Could someone please help me to understand the reason for this implementation of discriminatedUnion?

dearlordylord avatar Sep 30 '22 03:09 dearlordylord

Logical aspect is that the discriminator field in discriminated union with one variant carries no information and thus is obsolete?. Such a union is equivalent to z.object({...}).

The implementation does not necessitate having >1 variants, but the authors decided add that typelevel restriction for that reason likely.

I see how in your generic code this is a problem since your code statically does allow such thing to happen - but then again zod is more of a static model definition library where you write most types plainly, so that all the const type magic works, and don't generate parsers like you do.

You can though, as you said, hack it, I think the first part of my message answered your question of why it is like it is.

necauqua avatar Oct 02 '22 05:10 necauqua

Thank you for your answer. I see the reason now, but I can't help to wonder how the logical aspect of such a decision differs from three similar use cases handled in programming languages, including TS, completely differently:

First example: const y = [].map(x => x + 1) doesn't throw exception and also compiles

Second example: const f = <T>(x: T) => x; f(1) // === 1 f doesn't bring business value here but I see it compiles well

Third example: const x = 4 + 0 - it compiles and throws no error, although adding 0 brings no business value here too

Regarding const type magic, it works fine with my definitions as well

Screenshot 2022-10-02 at 8 53 38 PM

But note that for the purpose of the question I simplified the code a little bit. I'll copy the complete code I have to clarify any confusion.

const SUBSCRIPTION_USER_API_LITERAL_TYPE = 'user' as const;
const makeApiIdParser = <L extends string>(literals: ReadonlyNonEmptyArray<L>) => {
  const makeType = (literal: L) =>
    z.object({
      type: z.literal(literal),
      id: z.string(),
    });
  // to procure type
  const first = makeType(literals[0]);
  return z.preprocess(
    (uuid) => {
      if (typeof uuid !== 'string') throw new Error('must be a string');
      const [type, id] = uuid.split(SEPARATOR);
      return { type, id };
    },
    // trick zod https://github.com/colinhacks/zod/issues/1442
    z.discriminatedUnion('type', [first, ...(literals.slice(1).map(makeType) as [typeof first, ...typeof first[]])])
  );
};

export const subscriptionApiSrcCompositeParser = makeApiIdParser([SUBSCRIPTION_USER_API_LITERAL_TYPE]);
type Src = z.infer<typeof subscriptionApiSrcCompositeParser>;
const src: Src = {
  type: 'user',
  id: 'a'
}
// won't compile
const srcNope: Src = {
  type: 'nope',
  id: 'a'
}

With all being said, now I'm even more confident that the requirement of a "minimum two elements" is an honest mistake. it should be a [T, ...T[]], not [T, T, ...T[]] as I see it but again I could lack a bigger picture.

dearlordylord avatar Oct 02 '22 14:10 dearlordylord

Hey @Firfi since I've been working on discriminatedUnion wanted to give my humble commentary on this.

First, I agree with @necauqua in the math definition of the discriminated union. But that's just math!

Click to see my opinion

It seems that the literal definition of a discriminated union (at least in math) is a disjoint union, which commonly is described in terms of a family of sets of N >= 2. You could try and argue that you could create a disjoint union with a set S and the empty set {}, but that degenerates into an ordinary union with the empty set (because by enumerating all the elements in S and all the elements in the empty set (which is 0) you end up with the same number of elements in S). Phew! We could try and get someone who knows their set theory well to weigh in here.

Anyways! We live in the real world, and as of https://github.com/colinhacks/zod/pull/1290 this works with one single entity as you desired. If this works for you feel free to close the issue and thanks! 🙏

maxArturo avatar Nov 25 '22 12:11 maxArturo

Has this issue been resolved? If so I'd like to close this issue.

JacobWeisenburger avatar Jan 06 '23 03:01 JacobWeisenburger

@JacobWeisenburger : confirming it indeed works with the latest Zod. Thank you, @maxArturo for your work 👍

dearlordylord avatar Jan 06 '23 04:01 dearlordylord