web-data-fetching-trpc-944b55 — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited web-data-fetching-trpc-944b55 (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Quick Guide: tRPC carries types from server to client through one exported type rather than through a generated schema, soexport type AppRouter = typeof appRouteris the whole bridge and everything downstream fails without it. Procedures take a validated input and return a value;TRPCErrorcodes are what become HTTP statuses; middleware narrows the context type so an authenticated procedure'sctx.useris non-nullable. In v11 the transformer moved inside the link, subscriptions are async generators, and@trpc/tanstack-react-queryis the current React integration.
Detailed Resources:
queryOptionsFile in an input schema (v11+)createTRPCContext yields a useTRPChook, and each procedure exposes queryOptions(), mutationOptions(), infiniteQueryOptions() and queryKey() that go straight into the standard query hooks. Pattern 5.
own trpc.x.useQuery() hooks instead, and a cache key comes from getQueryKey(trpc.x) rather than from a queryKey() on the procedure. Migrate when convenient; the two can coexist.
<critical_requirements>
Export the router's type: `export type AppRouter = typeof appRouter`. That single line is the whole client-side contract — without it the client falls back to unknown and every guarantee tRPC offers is gone, with no error at the point the export was forgotten.
Give every procedure that accepts input a validator on `.input()`. It is both the runtime check and the source of the handler's parameter type, so a procedure without one receives unknown and tempts a cast.
Throw `TRPCError` with a code rather than a bare `Error`. The code is what maps to an HTTP status and what the client switches on; a bare Error arrives as an opaque 500.
Place the transformer inside `httpBatchLink()`, not on `createTRPCClient()`. v11 moved it, and the old position raises an error at client construction.
</critical_requirements>
Auto-detection: initTRPC, createTRPCClient, createTRPCContext, createTRPCOptionsProxy, @trpc/server, @trpc/client, @trpc/react-query, @trpc/tanstack-react-query, TRPCError, publicProcedure, protectedProcedure, httpBatchLink, httpSubscriptionLink, loggerLink, inferRouterInputs, inferRouterOutputs, useTRPC, tracked, AppRouter
Applies to:
Handled elsewhere:
document rather than a shared type
the options a query client consumes, and the client decides what to do with them
.input(); any validator the version supports works, and the examplesshow one
<philosophy>
There is no API description anywhere — no schema file, no generated client, no build step between the two halves. The router's inferred type _is_ the contract, and it is shared by importing a type across the codebase.
That buys immediate accuracy: a procedure's return type changes and every call site reddens in the same typecheck, with no regeneration step to forget. It costs polyglot support, a published contract, and HTTP caching — calls go out as POST by default, so a CDN in front of the endpoint has nothing to cache. Which is why tRPC suits an internal API in one TypeScript codebase and suits a public one badly.
</philosophy>
<patterns>
Initialize once per application and export the factories the routers compose from.
const t = initTRPC.context<Context>().create({
transformer: superjson, // Date, Map and Set survive the wire
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
export const router = t.router;
export const publicProcedure = t.procedure;
export const middleware = t.middleware;The error formatter is what turns a validation failure into something a form can render per field, instead of one message.
Full code: examples/core.md — including the per-request context factory
const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
});
export const userRouter = router({
create: protectedProcedure
.input(createUserSchema)
.mutation(async ({ input, ctx }) => {
// input: { email: string; name: string } — validated, and typed from the same schema
return ctx.db.user.create({ data: input });
}),
});Full code: examples/core.md
const isAuthenticated = middleware(async ({ ctx, next }) => {
if (!ctx.session || !ctx.user) throw new TRPCError({ code: "UNAUTHORIZED" });
return next({ ctx: { ...ctx, session: ctx.session, user: ctx.user } });
});
export const protectedProcedure = publicProcedure.use(isAuthenticated);The next({ ctx }) return type is what makes ctx.user non-nullable in every procedure built on protectedProcedure — so forgetting the check becomes a compile error rather than a review comment.
Full code: examples/middleware.md
export const appRouter = router({ user: userRouter, post: postRouter });
export type AppRouter = typeof appRouter;
type RouterInputs = inferRouterInputs<AppRouter>;
type RouterOutputs = inferRouterOutputs<AppRouter>;
type User = RouterOutputs["user"]["getById"];Component props take RouterOutputs[...] rather than a hand-written interface, so a field removed from a procedure reddens every component that read it.
Full code: examples/core.md
export const { TRPCProvider, useTRPC } = createTRPCContext<AppRouter>();
const trpc = useTRPC();
const { data } = useQuery(trpc.user.getById.queryOptions({ id: userId }));Each procedure carries queryOptions(), mutationOptions(), infiniteQueryOptions() and queryKey(). The options object goes into the ordinary hook, so anything the query client can do to an options object works here too.
Full code: examples/core.md — provider, links and a component
// Server
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to delete",
cause: error,
});// Client
onError: (error) => {
switch (error.data?.code) {
case "NOT_FOUND":
return toast.error("Not found");
case "FORBIDDEN":
return toast.error("Not allowed");
}
};cause keeps the original stack for the server logs while the client still receives only the code and message. The code-to-status table is in reference.md.
Cancel, snapshot, write, roll back on failure, invalidate when settled — all five, since the rollback is what makes the optimistic write safe.
const toggleTodo = useMutation({
...trpc.todo.toggle.mutationOptions(),
onMutate: async ({ id }) => {
await queryClient.cancelQueries({ queryKey: trpc.todo.list.queryKey() });
const previousTodos = queryClient.getQueryData(trpc.todo.list.queryKey());
queryClient.setQueryData(trpc.todo.list.queryKey(), (old) =>
toggle(old, id),
);
return { previousTodos };
},
onError: (err, vars, context) =>
queryClient.setQueryData(trpc.todo.list.queryKey(), context?.previousTodos),
onSettled: () =>
queryClient.invalidateQueries({ queryKey: trpc.todo.list.queryKey() }),
});The cancelQueries is not optional: an in-flight refetch that resolves after the optimistic write overwrites it with the pre-mutation server state.
Full code: examples/optimistic-updates.md
</patterns>
<red_flags>
Breaks at runtime:
export type AppRouter — the client infers unknown and every procedure call is untyped,with nothing failing at the file that omitted it.
createTRPCClient() in v11 — client construction throws, naming the link itshould have gone in.
Date, Map and Set arrive as something else.
observable() in a subscription — that is the v10 shape; v11 subscriptions are async generators.rawInput in middleware — v11 replaced it with await getRawInput().onError — a failed mutation leaves the UI showing achange that never happened.
Surprising behaviour:
httpBatchLink merges concurrent calls into one request, so every procedure in a batch shares oneHTTP status — a 401 from one is the status the others see too.
limiting means the limit is keyed on a known user.
retry: false for mutations.lastEventId in the input schema for tracked() to have somewhereto resume from.
ctx.user nullable to the type system, so thenext procedure that forgets it compiles.
</red_flags>
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.