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•3y 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•3y 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 CassiereOP•3y 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•3y 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 CassiereOP•3y ago
Would you mind sharing the/a repo with this?
Piotrek
Piotrek•3y 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 CassiereOP•3y 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•3y 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 CassiereOP•3y ago
Gotcha gotcha
Piotrek
Piotrek•3y 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 CassiereOP•3y ago
Cool stuff, I've got a ct3a app open right now and I'll give it a shot.
Piotrek
Piotrek•3y ago
Good luck!
Sean Cassiere
Sean CassiereOP•3y ago
1 question, the return type of the utils function is string yea?
Piotrek
Piotrek•3y 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 CassiereOP•3y ago
Thanks, testing now.
Sean Cassiere
Sean CassiereOP•3y ago
@Piotrek Ran into abit of a hurdle This is the error on the client I received (screenshot) Throw the Error using this throw new BadReqTRPCError("Message is invalid and a paragraph", "message")
export class BadReqTRPCError extends TRPCError {
constructor(message: string, path: string) {
const errorConfig = {
code: "BAD_REQUEST" as typeof TRPCError.prototype.code,
message: message,
path,
};

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

super({
...errorConfig,
cause: new TRPCError({
...errorConfig,
}),
});
}
}
<form
onSubmit={handleSubmit(async (data) => {
const { message } = data;
const response = setMessageForUser({ message });
console.log("response", response);
})}
className="mt-4 flex flex-col gap-2"
>
// this console log is not seen
<form
onSubmit={handleSubmit(async (data) => {
const { message } = data;
const response = setMessageForUser({ message });
console.log("response", response);
})}
className="mt-4 flex flex-col gap-2"
>
// this console log is not seen
const { mutate: setMessageForUser } = trpc.useMutation(["user.set-user-public-message"], {
onError: (error, variables, ctx) => {
console.log("error ->", { error });
// this console log is the one in the screenshot
},
onSuccess: () => {
trpcUtils.invalidateQueries(["user.get-user"]);
toggleUserMessageDialog(false);
},
});
const { mutate: setMessageForUser } = trpc.useMutation(["user.set-user-public-message"], {
onError: (error, variables, ctx) => {
console.log("error ->", { error });
// this console log is the one in the screenshot
},
onSuccess: () => {
trpcUtils.invalidateQueries(["user.get-user"]);
toggleUserMessageDialog(false);
},
});
Am I missing anything here? any client-side stuff I'm messing up
Sean Cassiere
Sean CassiereOP•3y ago
Basically, the path of the invalid field isn't returned.
Unknown User
Unknown User•3y ago
Message Not Public
Sign In & Join Server To View
Piotrek
Piotrek•3y ago
could you send the code that's failing? User.setUserPublicMessage is the place according to your error
Sean Cassiere
Sean CassiereOP•3y ago
Sure thing, this basically me intentionally throwing the error to see the response in the client.
async setUserPublicMessage(props: UserService_SetUserPublicMessage) {
if (props.throwError) { // boolean; which I'm setting to true to throw the Error
throw new BadReqTRPCError("Message is invalid and a paragraph", "message");
}

return await prisma.user.update({
where: { id: props.userId },
data: { publicMessage: props.publicMessage },
});
}
async setUserPublicMessage(props: UserService_SetUserPublicMessage) {
if (props.throwError) { // boolean; which I'm setting to true to throw the Error
throw new BadReqTRPCError("Message is invalid and a paragraph", "message");
}

return await prisma.user.update({
where: { id: props.userId },
data: { publicMessage: props.publicMessage },
});
}
Piotrek
Piotrek•3y ago
Oh, so that's what you want, no? 😄 Now you can just use the error and return it in the onSubmit function
Sean Cassiere
Sean CassiereOP•3y ago
Yea, basically a hacky debug so I can understand how I'd map the errors to react-hook-form after being piped from trpc
Piotrek
Piotrek•3y ago
Then handle it in the form component
Sean Cassiere
Sean CassiereOP•3y ago
My issue there is that the response/error from trpc doesn't contain the path/field onto which the errorMessage needs to be applied onto.
Piotrek
Piotrek•3y ago
Oh, i forgot about my errorFormattter on the tRPC side You'll have to handle the path before returning the error
Sean Cassiere
Sean CassiereOP•3y ago
Oh, that probably makes sense
Piotrek
Piotrek•3y ago
You have to pass errorFormatter to your .create({ errorFormatter }) function called on theinitTrpc
Sean Cassiere
Sean CassiereOP•3y ago
This the appRouter?
Piotrek
Piotrek•3y ago
My formatter method is that:
export const formatZodIssues = (issues: ZodIssue[]) =>
issues.map((issue) => ({
message: issue.message,
path: issue.path.join("."),
}));

export const formatCustomError = (
error: string,
path: string
) => JSON.stringify([{ message: error, path }]);

export const errorFormatter: ErrorFormatter<Context, any> = ({
shape,
error,
ctx,
}) => {
let errors: ReturnedError | null = null;

if (error.code === "BAD_REQUEST" && error.cause instanceof ZodError) {
errors = formatZodIssues(error.cause.issues);
}

if (error.code === "BAD_REQUEST" && error.cause instanceof TRPCError) {
errors = (JSON.parse(error.message) as ReturnedError).map((pError) => ({
...pError,
// You can do something in here eg. Translate messages or Sth
}));
}

return {
...shape,
errors,
};
};
export const formatZodIssues = (issues: ZodIssue[]) =>
issues.map((issue) => ({
message: issue.message,
path: issue.path.join("."),
}));

export const formatCustomError = (
error: string,
path: string
) => JSON.stringify([{ message: error, path }]);

export const errorFormatter: ErrorFormatter<Context, any> = ({
shape,
error,
ctx,
}) => {
let errors: ReturnedError | null = null;

if (error.code === "BAD_REQUEST" && error.cause instanceof ZodError) {
errors = formatZodIssues(error.cause.issues);
}

if (error.code === "BAD_REQUEST" && error.cause instanceof TRPCError) {
errors = (JSON.parse(error.message) as ReturnedError).map((pError) => ({
...pError,
// You can do something in here eg. Translate messages or Sth
}));
}

return {
...shape,
errors,
};
};
I just append new errors field onto the response with my shape No, the t variable that's being created with the initTRPC object I don't know where it is in t3-app, I'm using a Turborepo haha
Piotrek
Piotrek•3y ago
Piotrek
Piotrek•3y ago
I'm talking about this guy Btw I'm terrible at explaining stuff so sorry if it takes like 5 minutes to explain something easy haha
Sean Cassiere
Sean CassiereOP•3y ago
1. import for ReturnedError is from where? 2. I assume context is the tRPC context? No sweat man, this is some super helpful stuff For future ref, in create-t3-app its here
// src/server/router/index.ts
import { createRouter } from "./context";
import superjson from "superjson";

import { exampleRouter } from "./example";
import { protectedExampleRouter } from "./protected-example-router";
import { errorFormatter } from "../../utils/error";

export const appRouter = createRouter()
.formatError(errorFormatter) // this is it
.transformer(superjson)
.merge("example.", exampleRouter)
.merge("auth.", protectedExampleRouter)

// export type definition of API
export type AppRouter = typeof appRouter;
// src/server/router/index.ts
import { createRouter } from "./context";
import superjson from "superjson";

import { exampleRouter } from "./example";
import { protectedExampleRouter } from "./protected-example-router";
import { errorFormatter } from "../../utils/error";

export const appRouter = createRouter()
.formatError(errorFormatter) // this is it
.transformer(superjson)
.merge("example.", exampleRouter)
.merge("auth.", protectedExampleRouter)

// export type definition of API
export type AppRouter = typeof appRouter;
Piotrek
Piotrek•3y ago
Oh it's the v9 That's why I was confused 😅 Yeah, the context is the tRPC one I'm using the V10 version tho so not sure if the function will look the same You should have access to the context tho The returned error is just my type, probably something like {message:string;path:string}
Sean Cassiere
Sean CassiereOP•3y ago
I think this may be in play here. Since even though the type import for ReturnedError is missing, the formatter should return the new shape, but this is not the case.
Piotrek
Piotrek•3y ago
In tRPC the error.message is just a string and you can't parse it it looks like You'll just have to play around with your error formatter
Sean Cassiere
Sean CassiereOP•3y ago
EDIT: Got the ReturnedError type by taking type ReturnedError = ReturnType<typeof formatZodIssues>; Yup, will crack at it for a while.
Piotrek
Piotrek•3y ago
Good luck and... Night/whatever is right now where you live!
Sean Cassiere
Sean CassiereOP•3y ago
Night! Its 1:44 AM here in Sri Lanka
Piotrek
Piotrek•3y ago
It's 10:14 pm in Poland, so night too
Sean Cassiere
Sean CassiereOP•3y ago
Fixed 🎉
import { ZodError } from "zod";

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

super({
...errorConfig,
// this here fixed it
cause: new ZodError([{ code: "custom", path: [path], message }]),
});
}
}
import { ZodError } from "zod";

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

super({
...errorConfig,
// this here fixed it
cause: new ZodError([{ code: "custom", path: [path], message }]),
});
}
}
Thanks a bunch @Piotrek
Piotrek
Piotrek•3y ago
No problem! Good job! t3ggLove
Want results from more Discord servers?
Add your server