Tom Redman
Tom Redman3w ago

Is this typically a bad practice? v.union(v.id("thisList"), v.id("thatList"))

I have two different types of lists that a user can select to take a singular action on (in my case, send emails). I have a list of contacts that is a "segment", or a user can select the entire "list". A list is different from a segment, but the same action can be taken on both for all practical purposes. I have my schema like this: audience: v.union(v.id("mailchimpLists"), v.id("mailchimpSegments")), Butttttt I'm immediately realizing that disambiguating this in a function seems hacky at best. Is it better to do this, and then simply manage what's what further upstream?
mailchimpSegment: v.optional(v.id("mailchimpSegments")),
mailchimpList: v.optional(v.id("mailchimpLists")),
mailchimpSegment: v.optional(v.id("mailchimpSegments")),
mailchimpList: v.optional(v.id("mailchimpLists")),
This is fine as well, however, I find myself wanting to enforce that there must be one but not both. I could do something like this:
audienceType: v.union(
v.literal("segment"),
v.literal("list"),
),
mailchimpSegment: v.optional(v.id("mailchimpSegments")),
mailchimpList: v.optional(v.id("mailchimpLists")),
audienceType: v.union(
v.literal("segment"),
v.literal("list"),
),
mailchimpSegment: v.optional(v.id("mailchimpSegments")),
mailchimpList: v.optional(v.id("mailchimpLists")),
But this still feels brittle. I'm leaning toward the audienceType option, but would love to know if there's a better way, or if I'm missing something obvious with the v.union(v.id,v.id) idea.
10 Replies
Convex Bot
Convex Bot3w ago
Thanks for posting in <#1088161997662724167>. Reminder: If you have a Convex Pro account, use the Convex Dashboard to file support tickets. - Provide context: What are you trying to achieve, what is the end-user interaction, what are you seeing? (full error message, command output, etc.) - Use search.convex.dev to search Docs, Stack, and Discord all at once. - Additionally, you can post your questions in the Convex Community's <#1228095053885476985> channel to receive a response from AI. - Avoid tagging staff unless specifically instructed. Thank you!
RJ
RJ3w ago
A discriminated union is the way to go:
audience: v.union(
v.object({
type: v.literal("MailchimpList"),
id: v.id("mailchimpLists"),
}),
v.object({
type: v.literal("MailchimpSegments"),
id: v.id("mailchimpSegments")),
})
)
audience: v.union(
v.object({
type: v.literal("MailchimpList"),
id: v.id("mailchimpLists"),
}),
v.object({
type: v.literal("MailchimpSegments"),
id: v.id("mailchimpSegments")),
})
)
type allows you to easily discriminate at runtime which ID type you have, but without permitting nonsensical states like:
{
audienceType: "segment"
mailchimpSegment: "123",
mailchimpList: "abc",
}
{
audienceType: "segment"
mailchimpSegment: "123",
mailchimpList: "abc",
}
or
{
audienceType: "segment"
}
{
audienceType: "segment"
}
Tom Redman
Tom RedmanOP3w ago
Ah this is perfect! Thank you @RJ ! "A discriminated union" is a great band name too
gabrielw
gabrielw2w ago
glad I stumbled upon this support issue - was able to apply this discriminated union approach myself for a mutation with 3 different permutations of inputs.
export const vEventTicketingUpdate = v.union(
v.object({
type: v.literal("venueWithTicketing"),
ticketingInfo: vTicketingInfo,
geoAddressJson: vGeoAddressJson,
geoLatitude: v.string(),
geoLongitude: v.string(),
}),
v.object({
type: v.literal("ticketing"),
ticketingInfo: vTicketingInfo,
}),
v.object({
type: v.literal("venue"),
geoAddressJson: vGeoAddressJson,
geoLatitude: v.string(),
geoLongitude: v.string(),
})
);
export const vEventTicketingUpdate = v.union(
v.object({
type: v.literal("venueWithTicketing"),
ticketingInfo: vTicketingInfo,
geoAddressJson: vGeoAddressJson,
geoLatitude: v.string(),
geoLongitude: v.string(),
}),
v.object({
type: v.literal("ticketing"),
ticketingInfo: vTicketingInfo,
}),
v.object({
type: v.literal("venue"),
geoAddressJson: vGeoAddressJson,
geoLatitude: v.string(),
geoLongitude: v.string(),
})
);
RJ
RJ2w ago
Beautiful! I highly recommend using discriminated unions of this sort (with a tag or type discriminant field) in normal TypeScript code, too. Your EventTicketingUpdate data type is a great example of a union type that would be really confusing/difficult/tedious to discriminate otherwise!
gabrielw
gabrielw2w ago
yeah, the coolest thing too is that this not only makes my front end forms much cleaner, but I'm also able to handle conditional logic in a single mutation by checking the type used in the payload. Super clean, really appreciate the tip
Matt Luo
Matt Luo2w ago
how does indexing work on discriminated union? Just the same way as any other column?
djbalin
djbalin2w ago
I battled with some nested discriminated unions that ended up being too difficult for me to work with, so I resorted to just splitting it up into two separate tables. I'm interested in hearing you guys' opinions on this. It's for a notification system which can dispatch different notifications (in-app, push, e-mail etc). The challenge is that there are different types of notifications which vary on their: recipient, event, and targetId. For now, recipient kinds are profile and channel, there are ~6 events in total, where some are unique to each recipient and others shared, and then 3-4 different types of targetId (e.g. submission, video, channel). Not all tuples of recipient, event, targetId are valid, and nesting discriminated unions quickly grew us over our heads, but I would love to hear if anyone thinks this data modelling sounds feasible to implement and work with, and what we maybe did wrong?
RJ
RJ2w ago
Yeah, just like any other column/probably how you'd expect it to This is a little hard for me to follow without more code examples, but if you open up a new support post I'd be happy to have a look and share my thoughts. Without seeing additional details I can still say two things that might be helpful: 1. The strategy that Tom was going for—storing a union with different types of references to different tables—in this post is a good one, and you can do this at the top-level too. So if audience were not a field but a table, you could do something like
audiences: defineTable(
v.union(
v.object({
type: v.literal("MailchimpList"),
id: v.id("mailchimpLists"),
}),
v.object({
type: v.literal("MailchimpSegments"),
id: v.id("mailchimpSegments")),
})
)
)

audiences: defineTable(
v.union(
v.object({
type: v.literal("MailchimpList"),
id: v.id("mailchimpLists"),
}),
v.object({
type: v.literal("MailchimpSegments"),
id: v.id("mailchimpSegments")),
})
)
)

2. In my experience, whenever an object field in a table feels cumbersome, breaking it out into its own table is pretty much always a good move. Though you can define both tables and fields with v.object(), you can do more with tables than you can with object fields in tables. (This is for data modeling/correctness purposes—different rules, i.e. denormalization, may apply if you're running into performance issues.)
djbalin
djbalin2w ago
Thanks @RJ , your second point is great and supports our eventual conclusion. It's great to have the option for heterogeneous and nested data in Convex, but also important to abandon it when its too detrimental to ergonomics or understanding!

Did you find this page helpful?