Pablo
Pablo•4w ago

Ok, I created a minimum sample of what I

Ok, I created a minimum sample of what I'm trying to achieve with Convex, NextJS, React Hook Form, and Zod and hitting a wall because Convex wants undefined, and React forms wants "" as a representation of blanks. I'll drop the code in a thread to minimize noise to channel. I'd appreciate any help.
36 Replies
Pablo
PabloOP•4w ago
Here's the code: https://github.com/pupeno/convex-nextjs-playground The type error is here: fee can't be "" but it also can't be undefined: https://github.com/pupeno/convex-nextjs-playground/blob/a7d4be878c2557f526dc3fd4c5593ffb82300621/app/admin/competitions/_lib/form.tsx#L23 @Sara @Ashes if you are still around, and free, I'd appreciate any help here. The validation: https://github.com/pupeno/convex-nextjs-playground/blob/main/lib/validation/competitions.ts
import { z } from "zod";

export const CompetitionValidator = z.object({
title: z.string().trim().min(1, "Title is required"),
fee: z.number().positive().finite().optional()
});

export type Competition = z.infer<typeof CompetitionValidator>;
import { z } from "zod";

export const CompetitionValidator = z.object({
title: z.string().trim().min(1, "Title is required"),
fee: z.number().positive().finite().optional()
});

export type Competition = z.infer<typeof CompetitionValidator>;
It already is, but the schema is not the problem: https://github.com/pupeno/convex-nextjs-playground/blob/a7d4be878c2557f526dc3fd4c5593ffb82300621/convex/schema.ts#L7 Oh, I forgot to do the server side Zod validation.
Ashes
Ashes•4w ago
The simplest fix to me would be doing
await onSubmitAction({
...competition,
fee: fee || undefined
});
await onSubmitAction({
...competition,
fee: fee || undefined
});
And yeah it's annoying I'm remembering now you need the blank strings to have it always be a controlled component. Doing the spread operation helps to only sanitize the fields you want before submitting while allowing new fields to be added and not having to update that submit object
Pablo
PabloOP•4w ago
@Ashes yes, I can do that, but that doesn't solve this problem:
app/admin/competitions/_lib/form.tsx:23:7 - error TS2322: Type 'string' is not assignable to type 'number'.

23 fee: "" // Type error, because I need fee to blank for the form, but "" is not a valid type for Competition.fee.
app/admin/competitions/_lib/form.tsx:23:7 - error TS2322: Type 'string' is not assignable to type 'number'.

23 fee: "" // Type error, because I need fee to blank for the form, but "" is not a valid type for Competition.fee.
Ashes
Ashes•4w ago
fee: parseInt(fee) || undefined There are a couple cleaner solves than parseInt but it's a bit longer to type out
Pablo
PabloOP•4w ago
fee there can't be undefined:
You should avoid providing undefined as a default value, as it conflicts with the default state of a controlled component.
https://react-hook-form.com/docs/useform#defaultValues This is where I'm hitting the problem. BTW, it can manifest in other wacky ways (with inputs switching between controlled and uncontrolled depending on whether the field has a value in the server). This is just the clearest way to visualize how the incompatibility between RHF using "" and Convex using undefined breaks the ability to use the same type accross, and I'm not sure where to draw the line. I tried converting the value on onChange, but that doesn't work either. When you set it to undefined, RHF or React, grabs the value from values, making it impossible to clear the value in the form (that's how I discovered the bug).
Ashes
Ashes•4w ago
Yup yup, the default blank string I think can remain there. It's in the onSubmit function where this would be adjusted line 28:
await onSubmitAction({
...competition,
fee: parseInt(competition.fee) || undefined
});
await onSubmitAction({
...competition,
fee: parseInt(competition.fee) || undefined
});
Pablo
PabloOP•4w ago
Ok, but for the blank string to remain there, I need to change types somewhere.
Ashes
Ashes•4w ago
This should let the input remain controlled, then when the person hits submit the form parses that field. If it turns out to be a real number then it submits that to the backend, if it's not a number it submits undefined to the backend and that will behave for convex Oh is it giving you an issue that the string is changing to a number in the number field? Ooooh for zod?
Pablo
PabloOP•4w ago
No, the problem is that useForm is using the Competition type, and in that, fee is a number | undefined, not a string. I'm too new to Convex, Zod, etc to have a good intuition of where I draw the line between client side types and server side types. I'm trying not to duplicate validation. If I have to duplicate things on the server and the client, I might as well use Rails 😉
Ashes
Ashes•4w ago
Yeah Im a bit rusty with zod myself. This is a normal annoyance though. Lemme check something
Using z.coerce.number():
The z.coerce.number() schema is designed to convert values to numbers. This is useful when you expect a number but might receive a string (e.g., from an <input type="number"> element, which still returns a string in its value).
TypeScript

import { z } from 'zod';

const schema = z.object({
age: z.coerce.number().min(18, "Must be at least 18"),
});
Important consideration: z.coerce.number() will convert empty strings ("") to 0. If an empty field should be considered invalid or undefined instead of 0, this behavior needs to be addressed
Using z.coerce.number():
The z.coerce.number() schema is designed to convert values to numbers. This is useful when you expect a number but might receive a string (e.g., from an <input type="number"> element, which still returns a string in its value).
TypeScript

import { z } from 'zod';

const schema = z.object({
age: z.coerce.number().min(18, "Must be at least 18"),
});
Important consideration: z.coerce.number() will convert empty strings ("") to 0. If an empty field should be considered invalid or undefined instead of 0, this behavior needs to be addressed
I think this is what you want to change, using coerce before .number in your validator?
Pablo
PabloOP•4w ago
With z.coerce.number(), the output of export type Competition = z.infer<typeof CompetitionValidator>; still has fee as a number, which is not unreasonable, but that means:
app/admin/competitions/_lib/form.tsx:23:7 - error TS2322: Type 'string' is not assignable to type 'number'.

23 fee: "" // Type error, because I need fee to blank for the form, but "" is not a valid type for Competition.fee.
app/admin/competitions/_lib/form.tsx:23:7 - error TS2322: Type 'string' is not assignable to type 'number'.

23 fee: "" // Type error, because I need fee to blank for the form, but "" is not a valid type for Competition.fee.
Ashes
Ashes•4w ago
I would set the default value to 0 and that would work with the spread operation logic before
Pablo
PabloOP•4w ago
Give me a sec and let me show you something.
Ashes
Ashes•4w ago
Or the real stupid but easy fix fee: "" as number Which is something I personally do when I want a text field to be an ID of a convex model, e.g.: userId: "" as Id<"users"> This lets me have a blank but controlled field that accepts only the IDs for users
Pablo
PabloOP•4w ago
I think I'll hit another problem, hold on a sec. This is what I need to do to convert between string and number, in both directions, but I think I know put myself in a position where the Zod validation doesn't work on the client, I'm going to try that: https://github.com/pupeno/convex-nextjs-playground/pull/1/files
Ashes
Ashes•4w ago
It's a bit late for me but I'll be around tomorrow. I do twitch streams for the project I'm working on so if you're still running into stuff Id be able to look at it/respond much clearer
Pablo
PabloOP•4w ago
Where do I find your streams? Yup... now with that change I can't use a zod resolver to validate on the client sigh.
Sara
Sara•4w ago
I'll take a look I see your problem, so I'd suggest keeping everything as is, when you submit the form use parseFloat(fee,10) to change it to a number, and define, something like this
const fee = z
.union([z.string().length(0), z.number().positive().finite()])
.optional()
.transform(e => e === "" ? undefined : e);
const fee = z
.union([z.string().length(0), z.number().positive().finite()])
.optional()
.transform(e => e === "" ? undefined : e);
and you should be good good old stack overflow to the rescue, https://stackoverflow.com/questions/73582246/zod-schema-how-to-make-a-field-optional-or-have-a-minimum-string-contraint what this does is it goes, hey, are you empty? then you must be undefined, else you're a number, but if you're fully empty you'll just be undefined
Pablo
PabloOP•4w ago
When doing this, the type error moves to the mutation function:
convex/admin/competitions.ts:25:48 - error TS2345: Argument of type '{ title: string; fee?: string | number | undefined; prize?: string | number | undefined; }' is not assignable to parameter of type '{ fee?: number | undefined; prize?: number | undefined; title: string; }'.
Types of property 'fee' are incompatible.
Type 'string | number | undefined' is not assignable to type 'number | undefined'.
Type 'string' is not assignable to type 'number'.

25 return await ctx.db.insert("competitions", validated);
~~~~~~~~~
convex/admin/competitions.ts:25:48 - error TS2345: Argument of type '{ title: string; fee?: string | number | undefined; prize?: string | number | undefined; }' is not assignable to parameter of type '{ fee?: number | undefined; prize?: number | undefined; title: string; }'.
Types of property 'fee' are incompatible.
Type 'string | number | undefined' is not assignable to type 'number | undefined'.
Type 'string' is not assignable to type 'number'.

25 return await ctx.db.insert("competitions", validated);
~~~~~~~~~
Now that fee is allowed to be of type string, I can't pass it to insert, because the type in the schema is fee: v.optional(v.number()). This just moves the type problem from the client to the server.
Sara
Sara•4w ago
type cast it at the end if it's undefined don't call the function so:
if(!fee) return; // do nothing
await submit({
...otherVals,
fee: fee as number
})
if(!fee) return; // do nothing
await submit({
...otherVals,
fee: fee as number
})
or to be safe, parseFloat
Pablo
PabloOP•4w ago
Yeah, parseFloat or something else is a detail. But I'm not sure where that snippet of code you just pasted is supposed to go. The problem is that z.union([z.string().length(0), z.number().positive().finite()]) causes const competitionArgs = zodToConvex(CompetitionValidator); to generate a type that allows string, so in the mutation:
export const update = mutation({
args: {
id: v.id("competitions"),
...competitionArgs.fields
},
export const update = mutation({
args: {
id: v.id("competitions"),
...competitionArgs.fields
},
now it allows strings in, which may or may not be ok, but even CompetitionValidator.parse() outputs a type with string for fee.
Sara
Sara•4w ago
wait where are you using zodToConvex? in a table?
Sara
Sara•4w ago
oh! well this changes things can you pass 0 to the form?
Pablo
PabloOP•4w ago
But even if I remove it, and I write my args type by hand, CompetitionValidator.parse() would still return the wrong type, in here: https://github.com/pupeno/convex-nextjs-playground/blob/6178e4d5b1e27dcd603d8c6aa56b14b09ae14f21/convex/admin/competitions.ts#L54
Sara
Sara•4w ago
rollback to this version for a min can you pass zero to the form?
Pablo
PabloOP•4w ago
Not really, and here let me clarify something. Yes, I can find a hack that would work here, but I'm building this project to learn the Convex/NextJS way (I'm switching from Rails/Stimulus being my go-to tool), to then go apply it on bigger apps than I'm planning on building if it looks like it can do the job and I can be productive enough in it. So I'm pushing hard in all my decisions to be generic because my goal is to build my toolbox, not having this app working (if it was the later, I would have done it in a weekend or two in Rails really). I can also go and have one Zod validation for the server, one for the client, and hard-code the shape/types on each stage. That would work, but it would have a lot of boilerplate code. Yup.
Sara
Sara•4w ago
here's another solution, I wouldn't say its hacky https://stackoverflow.com/a/76878814
Stack Overflow
React hook form and zod inumber input
I have created a form with react hook form and did a validation with zod. In one input I want to write in numbers, when I write any number, the error says it is not a number: &lt;Controller
Sara
Sara•4w ago
and you can define the length to be 0
Pablo
PabloOP•4w ago
z.preprocess( (a) => parseInt(z.string().parse(a), 10), z.number().positive().min(1) ) That one?
Sara
Sara•4w ago
yep yep I think that would fix it
Pablo
PabloOP•4w ago
Unfortunately, no. The problem is in the types. It's impossible to have a type that accepts strings and doesn't accept strings at the same time, depending on where you use it. That Zod makes the fee input unknown, making it incompatible in a bunch of places where the types are used. Some of the type errors I actually don't completely understand yet.
app/admin/competitions/[id]/edit/page.tsx:37:33 - error TS2345: Argument of type '{ title: string; fee?: number | undefined; prize?: number | undefined; id: Id<"competitions">; }' is not assignable to parameter of type '{ fee?: undefined; prize?: undefined; title: string; id: Id<"competitions">; }'.
Types of property 'fee' are incompatible.
Type 'number | undefined' is not assignable to type 'undefined'.
Type 'number' is not assignable to type 'undefined'.

37 const result = await update({id, ...competition});
~~~~~~~~~~~~~~~~~~~~

app/admin/competitions/_lib/form.tsx:64:18 - error TS2322: Type '{ onChange: (...event: any[]) => void; onBlur: Noop; value: unknown; disabled?: boolean | undefined; name: "fee"; ref: RefCallBack; type: "number"; step: string; placeholder: string; }' is not assignable to type 'InputHTMLAttributes<HTMLInputElement>'.
Types of property 'value' are incompatible.
Type 'unknown' is not assignable to type 'string | number | readonly string[] | undefined'.

64 <Input type="number" step="0.01" placeholder="Optional" {...field} />
app/admin/competitions/[id]/edit/page.tsx:37:33 - error TS2345: Argument of type '{ title: string; fee?: number | undefined; prize?: number | undefined; id: Id<"competitions">; }' is not assignable to parameter of type '{ fee?: undefined; prize?: undefined; title: string; id: Id<"competitions">; }'.
Types of property 'fee' are incompatible.
Type 'number | undefined' is not assignable to type 'undefined'.
Type 'number' is not assignable to type 'undefined'.

37 const result = await update({id, ...competition});
~~~~~~~~~~~~~~~~~~~~

app/admin/competitions/_lib/form.tsx:64:18 - error TS2322: Type '{ onChange: (...event: any[]) => void; onBlur: Noop; value: unknown; disabled?: boolean | undefined; name: "fee"; ref: RefCallBack; type: "number"; step: string; placeholder: string; }' is not assignable to type 'InputHTMLAttributes<HTMLInputElement>'.
Types of property 'value' are incompatible.
Type 'unknown' is not assignable to type 'string | number | readonly string[] | undefined'.

64 <Input type="number" step="0.01" placeholder="Optional" {...field} />
Even if I get this to work, I still need to convert it to null to send it to the mutation, and then convert it to undefined to send it to patch.
Sara
Sara•4w ago
sorry we've might've overcomplicated this when it was just too simple, I can see your input is type number, default it to zero like I advised before, and in your backend function do the check to see if it equals to zero and return undefined, if it tells you that the number returns type string, just do .transform((d)=> parseFloat(d || "0",10)) in your zod schema
Pablo
PabloOP•4w ago
Thank you.
Ashes
Ashes•4w ago
Wooohoo glad ya'll solved that, one more "gotcha" to throw in the back of the brain 🙂
Pablo
PabloOP•4w ago
I haven't solved it yet and I don't see a solution other than a lot of types and a lot of conversions. But I am very thankful for all the help. My holidays just ended, so we'll see when I have time to deep dive into this again.

Did you find this page helpful?