oscklm
oscklm15mo ago

Using v.id() in a v.union()

I'm testing out something in my dev env, trying to improve how we handle the relation between a video and a call to action. I'm considering doing the following: Old approach using a string
export const videoValidator = v.object({
// Other fields
currentAction: v.optional(v.string()),
})
export const videoValidator = v.object({
// Other fields
currentAction: v.optional(v.string()),
})
New approach using a union
export const callToActionValidator = v.object({
type: v.union(
v.literal('FunFact'),
v.literal('Challenge'),
v.literal('Tutorial'),
v.literal('Quiz')
),
id: v.union(
v.id('funFacts'),
v.id('challenges'),
v.id('tutorials'),
v.id('quizzes')
),
})
export const callToActionValidator = v.object({
type: v.union(
v.literal('FunFact'),
v.literal('Challenge'),
v.literal('Tutorial'),
v.literal('Quiz')
),
id: v.union(
v.id('funFacts'),
v.id('challenges'),
v.id('tutorials'),
v.id('quizzes')
),
})
The new approach not only allows me to use the type for doing some smart dynamic rendering of a component based on the type in the frontend, and the id with the union types of the different id's for a call to action, allows me to easily delete the id in the backend. Is there any reasons not to do it this way. Before i migrate things to this new approach.
4 Replies
ian
ian15mo ago
I would do it but make the Union one of objects:
v.union(
v.object({
type: v.literal('FunFact'),
id: v.id('funFacts')
}),
v.object({
type: v.literal('Challenge'),
id: v.id('challenges')
}),
…)
v.union(
v.object({
type: v.literal('FunFact'),
id: v.id('funFacts')
}),
v.object({
type: v.literal('Challenge'),
id: v.id('challenges')
}),
…)
Then typescript will give you the ID type when you match the string type, and you can add extra per-type fields later if you want @oscklm make sense?
oscklm
oscklmOP15mo ago
Interesting. But i’m not 100% sure I follow For more context this is how im using the way i've done it currently.
// A helper function to update the videoCallToAction (i use this in a few different mutations
export async function updateVideoCallToAction(
ctx: MutationCtx,
videoId: Id<'videos'>,
newCallToAction: CallToAction
) {
const video = await ctx.db.get(videoId)

if (video) {
// Delete the current CTA if it exists
video.currentCallToAction &&
(await ctx.db.delete(video.currentCallToAction.id))

// Update videos CTA
await ctx.db.patch(videoId, {
currentCallToAction: newCallToAction,
})
}
}
// A helper function to update the videoCallToAction (i use this in a few different mutations
export async function updateVideoCallToAction(
ctx: MutationCtx,
videoId: Id<'videos'>,
newCallToAction: CallToAction
) {
const video = await ctx.db.get(videoId)

if (video) {
// Delete the current CTA if it exists
video.currentCallToAction &&
(await ctx.db.delete(video.currentCallToAction.id))

// Update videos CTA
await ctx.db.patch(videoId, {
currentCallToAction: newCallToAction,
})
}
}
// Component that will render my card depending on the type
import { ChallengeCard } from '@/components/challenge/challenge-card'
import { FunFactCard } from '@/components/funfact'
import { TutorialCard } from '@/components/tutorial/tutorial-card'
import { CallToAction } from 'backend/convex/schema'
import React from 'react'

type CallToActionCardProps = {
callToAction?: CallToAction
}

const CallToActionTypeToComponentMap: {
[key in CallToAction['type']]: React.ComponentType<{ id?: string }>
} = {
Challenge: ChallengeCard,
FunFact: FunFactCard,
Tutorial: TutorialCard,
Quiz: () => <div>Quiz</div>,
}

export const CallToActionCard = ({ callToAction }: CallToActionCardProps) => {
if (!callToAction) {
return <h1>This video has no call to action</h1>
}

const CallToActionComponent =
CallToActionTypeToComponentMap[callToAction.type]

return (
<>
<CallToActionComponent id={callToAction.id} />
</>
)
}
// Component that will render my card depending on the type
import { ChallengeCard } from '@/components/challenge/challenge-card'
import { FunFactCard } from '@/components/funfact'
import { TutorialCard } from '@/components/tutorial/tutorial-card'
import { CallToAction } from 'backend/convex/schema'
import React from 'react'

type CallToActionCardProps = {
callToAction?: CallToAction
}

const CallToActionTypeToComponentMap: {
[key in CallToAction['type']]: React.ComponentType<{ id?: string }>
} = {
Challenge: ChallengeCard,
FunFact: FunFactCard,
Tutorial: TutorialCard,
Quiz: () => <div>Quiz</div>,
}

export const CallToActionCard = ({ callToAction }: CallToActionCardProps) => {
if (!callToAction) {
return <h1>This video has no call to action</h1>
}

const CallToActionComponent =
CallToActionTypeToComponentMap[callToAction.type]

return (
<>
<CallToActionComponent id={callToAction.id} />
</>
)
}
Hmm. Do i understand correctly, that by the approach you recommend. That way i enforce that FunFact and the funFacts id will be paired together? and therefore can use the id field more conveniently? Ahh i think it clicked
ian
ian15mo ago
Yup, this would still work. Under the hood, if all the objects you have a union of have an id key, and they're all of the v.id (Id) type, then doing video.currentCallToAction.id will have a type of the union of the id types. The benefit is if you do:
if (video.currentCallToAction.type === "FunFact") {
const fact = ctx.db.get(video.currentCallToAction.id);
if (video.currentCallToAction.type === "FunFact") {
const fact = ctx.db.get(video.currentCallToAction.id);
fact will then have a type Doc<'funFacts'>, not a union of all the types. This is called "narrowing" and the union of objects is called a "discriminated union" - where there's a field that can distinguish which of the subtypes it is
oscklm
oscklmOP15mo ago
Ohh that’s super cool. Thanks for elaborating on this! I will have to read more up on the concepts you mention here

Did you find this page helpful?