Creating Zod Input Validation Error after tRPC entry

I've got a ct3a project, with all the bells and whistles (including tRPC and zod). In addition, on the frontend part of the app I've set up a couple of forms with react-hook-form, and I am sharing the zod schemas being used with tRPC. What I'm looking to achieve, is AFTER the input validation by the tRPC router mutation has been completed, and the code has moved on into the logic within the mutation itself, to be able to send back an input validation error and set these errors in react-hook-form. This would be the business logic: 1. User begins registration flow. 2. User enters their email and username along with other fields. 3. React-hook-form uses a shared schema (with tRPC) to validate items such as minLength, isEmail, regex for special characters, etc. 4. User submits valid the form-data to tRPC via a mutation. 5. tRPC accepts and validates the incoming input (since the same schema is being shared on the frontend). 6. In the mutation, the username field is checked in the database to find if it already exists. 7. If username already exists, a BAD_REQUEST error (or something equivalent in the 400 range) would be sent back to frontend, which then can be inserted into react-hook-form. P.S. I have no clue if this (https://trpc.io/docs/v9/error-formatting#all-properties-sent-to-formaterror) plays into what I'm looking for.
Error Formatting | tRPC
The error formatting in your router will be inferred all the way to your client (& React components)
41 Replies
Piotrek
Piotrek•2y ago
I am using the Form component from advanced guide in react-hook-form (https://react-hook-form.com/advanced-usage/#SmartFormComponent). I use my onSubmit prop of the component to return either void/null when there's no error, or MyAppError[] (which has the same type as validation errors from Zod +tRPC) Then I just set those errors to those returned from my onSubmit prop. Example implementation:
// components/my-form.tsx

// ...
<form
onSubmit={(e) => {
e.preventDefault();

handleSubmit(async (data) => {
const errors = await onSubmit(data);

(errors || []).forEach((error) =>
setError((error?.path ?? "") as Path<T>, {
message: error?.message,
})
);
})(e);
}}
// ...


// pages/sign-up

// ...
<MyForm
onSubmit={(data) => {
await sendMutationAndDoStuff();

return errors || null;
}}
// ...
// components/my-form.tsx

// ...
<form
onSubmit={(e) => {
e.preventDefault();

handleSubmit(async (data) => {
const errors = await onSubmit(data);

(errors || []).forEach((error) =>
setError((error?.path ?? "") as Path<T>, {
message: error?.message,
})
);
})(e);
}}
// ...


// pages/sign-up

// ...
<MyForm
onSubmit={(data) => {
await sendMutationAndDoStuff();

return errors || null;
}}
// ...
Advanced Usage
Performant, flexible and extensible forms with easy-to-use validation.
Piotrek
Piotrek•2y ago
I also created an error class for faster implementation for my custom errors (you can uimplement your own formatter)
import { TRPCError } from "@trpc/server";
import {
formatCustomError,
} from "../utils/formatCustomError";

export class BadReqTRPCError extends TRPCError {
constructor(message: string, path: string) {
const errorConfig = {
code: "BAD_REQUEST" as typeof TRPCError.prototype.code,
message: formatCustomError(message, path),
};

super({
...errorConfig,
cause: new TRPCError({
...errorConfig,
}),
});
}
import { TRPCError } from "@trpc/server";
import {
formatCustomError,
} from "../utils/formatCustomError";

export class BadReqTRPCError extends TRPCError {
constructor(message: string, path: string) {
const errorConfig = {
code: "BAD_REQUEST" as typeof TRPCError.prototype.code,
message: formatCustomError(message, path),
};

super({
...errorConfig,
cause: new TRPCError({
...errorConfig,
}),
});
}
Example usage: throw new BadReqTrpcError("message", "username")
Sean Cassiere
Sean Cassiere•2y ago
I think I get it, basically, the mutation returns either null/void or error[]. If error[], then setting the errors onto in hook-form. Seems cool.
Piotrek
Piotrek•2y ago
yup or like not the mutation but the function onSubmit which runs the mutation but it's basically the same thing 90% of the time
Sean Cassiere
Sean Cassiere•2y ago
Would you mind sharing the/a repo with this?
Piotrek
Piotrek•2y ago
I don't think I have a public repo for that but I can do something quickly for you if you'd like to wait ~10 - 15 minutes
Sean Cassiere
Sean Cassiere•2y ago
No stress, it was mostly for seeing whats in the utils and if this Error class needed to be added anywhere else in tRPC.
Piotrek
Piotrek•2y ago
Nope, utils is just for translation stuff mostly (i've got project that uses i18n) And you just throw the error like any other one
Sean Cassiere
Sean Cassiere•2y ago
Gotcha gotcha
Piotrek
Piotrek•2y ago
It's my simplest form component if you'd like to see one:
import React, { ReactElement } from "react";
import { DeepPartial, FieldValues, Path, useForm } from "react-hook-form";

type FormError = { path: string; message: string };

type Props<T> = {
defaultValues?: DeepPartial<T>;
onSubmit: (data: T) => void | FormError[] | Promise<void | FormError[]>;
children: ReactElement[];
};

export const Form = <T extends FieldValues>({
defaultValues,
children,
onSubmit,
}: Props<T>) => {
const methods = useForm({ defaultValues });
const {
handleSubmit,
formState: { errors },
setError,
} = methods;

return (
<form
className="flex flex-col gap-8"
onSubmit={async (e) => {
e.preventDefault();

handleSubmit(async (data) => {
const errors = await onSubmit(data);

if (errors) {
errors.forEach((error) =>
setError(error.path as Path<T>, { message: error.message })
);
}
})(e);
}}
>
{React.Children.map(children, (child) => {
return child.props.name
? React.createElement(child.type, {
...{
...child.props,
errors,
register: methods.register,
key: child.props.name,
},
})
: child;
})}
</form>
);
};
import React, { ReactElement } from "react";
import { DeepPartial, FieldValues, Path, useForm } from "react-hook-form";

type FormError = { path: string; message: string };

type Props<T> = {
defaultValues?: DeepPartial<T>;
onSubmit: (data: T) => void | FormError[] | Promise<void | FormError[]>;
children: ReactElement[];
};

export const Form = <T extends FieldValues>({
defaultValues,
children,
onSubmit,
}: Props<T>) => {
const methods = useForm({ defaultValues });
const {
handleSubmit,
formState: { errors },
setError,
} = methods;

return (
<form
className="flex flex-col gap-8"
onSubmit={async (e) => {
e.preventDefault();

handleSubmit(async (data) => {
const errors = await onSubmit(data);

if (errors) {
errors.forEach((error) =>
setError(error.path as Path<T>, { message: error.message })
);
}
})(e);
}}
>
{React.Children.map(children, (child) => {
return child.props.name
? React.createElement(child.type, {
...{
...child.props,
errors,
register: methods.register,
key: child.props.name,
},
})
: child;
})}
</form>
);
};
Sean Cassiere
Sean Cassiere•2y ago
Cool stuff, I've got a ct3a app open right now and I'll give it a shot.
Piotrek
Piotrek•2y ago
Good luck!
Sean Cassiere
Sean Cassiere•2y ago
1 question, the return type of the utils function is string yea?
Piotrek
Piotrek•2y ago
Yeah, it was just changing key like auth.user_with_same_email to corresponding message in chosen language (eg. "User with the given e-mail adress already exists."). Don't pay attention to it, I just copied it from my multi-lingual app
Sean Cassiere
Sean Cassiere•2y ago
Thanks, testing now.
Want results from more Discord servers?
Add your server