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

Q: why discriminatedUnion of one element is not possible #1442

Closed
dearlordylord opened this issue Sep 30, 2022 · 5 comments
Closed

Q: why discriminatedUnion of one element is not possible #1442

dearlordylord opened this issue Sep 30, 2022 · 5 comments

Comments

@dearlordylord
Copy link

dearlordylord commented Sep 30, 2022

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?

@necauqua
Copy link

necauqua commented Oct 2, 2022

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.

@dearlordylord
Copy link
Author

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.

@maxArturo
Copy link
Contributor

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 #1290 this works with one single entity as you desired. If this works for you feel free to close the issue and thanks! 🙏

@JacobWeisenburger
Copy link
Collaborator

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

@JacobWeisenburger JacobWeisenburger added the closeable? This might be ready for closing label Jan 6, 2023
@dearlordylord
Copy link
Author

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

@JacobWeisenburger JacobWeisenburger removed the closeable? This might be ready for closing label Jan 6, 2023
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