tRPC v11 + Zod — Deep Dive senior
TL;DR — tRPC v11 (stable en 2026) est l'outil qui fait du type-safety end-to-end entre un serveur TypeScript et un client TypeScript sans codegen, sans schéma intermédiaire, sans OpenAPI. Tu définis une procédure côté serveur, le client la "voit" instantanément avec les types inférés à la frappe. Comparé à GraphQL : pas de schéma textuel à maintenir, pas de N+1, pas de DataLoader, mais aussi pas de fédération ni d'introspection au runtime. Comparé à REST : type-safety automatique mais perte de l'aspect "URL = ressource" qu'on peut tester avec cURL. tRPC s'intègre nativement à Next.js + React Query, supporte les subscriptions (SSE + WebSockets), et tourne en edge via Cloudflare Workers (avec Hono comme adapter). Zod en est l'allié naturel pour la validation : schemas TypeScript-first, refinements, transforms, branded types, avec une perf compatible avec Fastify/Hono via compilation. Cette page couvre l'architecture tRPC (procedures, routers, context, middleware, links), les patterns seniors (auth, rate limit, batching), les subscriptions, le déploiement edge, et un deep dive Zod (design, perf, branded types).
🧠 Mental model — ASCII + analogie
tRPC inverse le mental model habituel d'une API : au lieu de penser "endpoints HTTP", tu penses fonctions distantes. Côté serveur, tu écris des procédures (queries, mutations, subscriptions). Côté client, tu les appelles comme des fonctions, avec les bons types d'entrée et de retour. L'URL et le verbe HTTP sont des détails cachés sous le capot par les links.
┌──────────────── SERVER ─────────────────┐ ┌─────────── CLIENT ────────────┐
│ │ │ │
│ const appRouter = router({ │ │ import { trpc } from './trpc' │
│ users: router({ │ │ │
│ list: protectedProcedure │ │ // typed call: │
│ .input(z.object({ limit:z.number()│ │ const users = await │
│ .max(100).default(20) })) │ ──RPC──▶│ trpc.users.list │
│ .query(({ input, ctx }) => │ │ .query({ limit: 20 }) │
│ ctx.db.users.findMany(input)), │ │ │
│ create: protectedProcedure │ │ // ↑ types of input AND │
│ .input(UserSchema) │ │ // output inferred from │
│ .mutation(({ input, ctx }) => │ │ // server code, no codegen │
│ ctx.db.users.insert(input)), │ │ │
│ }), │ │ │
│ }) │ │ │
│ │ │ │
│ export type AppRouter = typeof appRouter│ ──type──│ import type { AppRouter } │
│ │ ─only──▶│ → inférence de tous les types │
└──────────────────────────────────────────┘ └────────────────────────────────┘Analogie : tRPC est l'équivalent de gRPC pour le monde TypeScript fullstack, mais sans Protobuf. Plus précisément, c'est comme partager un fichier .ts entre back et front avec des decorators implicites. Le client n'utilise pas la classe directement (il fait des appels HTTP réels), mais le compilateur TS vérifie que tu appelles avec les bons types comme s'il s'agissait d'appels in-process.
L'autre métaphore : tRPC est GraphQL sans GraphQL. Tu as les avantages (type-safety end-to-end, sélection client-side de ce que tu veux) sans les coûts (schéma SDL à maintenir, serveur GraphQL à configurer, parser GraphQL côté client). Mais tu perds aussi : pas d'introspection au runtime, pas de fédération de services hétérogènes, pas de tooling agnostique au langage.
┌────────────────────────────────────────┐
│ React Query / Tanstack Query │
│ (cache, retries, optimistic, suspense)│
└────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ tRPC client (proxy typé) │
│ trpc.users.list.useQuery({ limit: 20 }) │
└─────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ Links (chain de middlewares client) │
│ splitLink ──┬─→ httpBatchLink (queries) │
│ ├─→ wsLink (subscriptions) │
│ └─→ httpLink (mutations) │
└─────────────────────────────────────────────┘
│
▼
SERVER🛠️ Code minimal (ts)
// server/trpc.ts — bootstrap tRPC
import { initTRPC, TRPCError } from '@trpc/server'
import { z } from 'zod'
import superjson from 'superjson'
type Context = { user: { id: string; email: string } | null; db: DbClient }
const t = initTRPC.context<Context>().create({ transformer: superjson })
export const router = t.router
export const publicProcedure = t.procedure
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED' })
return next({ ctx: { ...ctx, user: ctx.user } }) // narrow user from nullable
})
// server/routers/users.ts
import { router, protectedProcedure, publicProcedure } from '../trpc'
const UserCreate = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
})
export const usersRouter = router({
list: protectedProcedure
.input(z.object({ limit: z.number().int().min(1).max(100).default(20) }))
.query(({ input, ctx }) => ctx.db.users.findMany({ take: input.limit })),
byId: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input, ctx }) => {
const u = await ctx.db.users.findUnique({ where: { id: input.id } })
if (!u) throw new TRPCError({ code: 'NOT_FOUND' })
return u
}),
create: protectedProcedure
.input(UserCreate)
.mutation(({ input, ctx }) => ctx.db.users.create({ data: input })),
})
// server/index.ts — root router
import { router } from './trpc'
import { usersRouter } from './routers/users'
export const appRouter = router({
users: usersRouter,
// orders: ordersRouter, etc.
})
export type AppRouter = typeof appRouter
// server/server.ts — Fastify adapter
import Fastify from 'fastify'
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'
const app = Fastify()
app.register(fastifyTRPCPlugin, {
prefix: '/trpc',
trpcOptions: {
router: appRouter,
createContext: async ({ req }) => ({
user: await authenticateRequest(req),
db: dbClient,
}),
},
})
app.listen({ port: 3000 })
// client/trpc.ts
import { createTRPCClient, httpBatchLink, splitLink, wsLink, createWSClient } from '@trpc/client'
import superjson from 'superjson'
import type { AppRouter } from '../server'
const wsClient = createWSClient({ url: 'wss://api.example.com/trpc' })
// v11 : `createTRPCClient` est le client proxy par défaut
// (`createTRPCProxyClient` n'est qu'un alias déprécié conservé pour la migration v10).
export const trpc = createTRPCClient<AppRouter>({
transformer: superjson,
links: [
splitLink({
condition: (op) => op.type === 'subscription',
true: wsLink({ client: wsClient }),
false: httpBatchLink({ url: 'https://api.example.com/trpc' }),
}),
],
})
// Usage client
const users = await trpc.users.list.query({ limit: 10 }) // typé !
const newUser = await trpc.users.create.mutate({ email: '[email protected]', name: 'Alice' })Points seniors :
protectedProcedureest unt.procedureaugmenté par un middleware qui throw si pas d'utilisateur. Lenext({ ctx: ... })narrow le type dectx.user(passe deUser | nullàUser). C'est l'élégance de tRPC.superjsonpermet de transporterDate,Map,Set,BigInt, qui ne survivent pas àJSON.stringify. Sans lui,Datedevientstringcôté client.- Le
splitLinkroute les opérations selon leur type : subscriptions → WebSocket, queries/mutations → HTTP batch.
🎯 Patterns courants
Pattern 1 — Context typé et injectable
Le Context est l'équivalent de la DI de Nest, version simple. On y met tout ce qui change par requête : utilisateur, DB transaction, request ID, locale, feature flags.
type Context = {
user: User | null
db: DbClient
requestId: string
locale: 'fr' | 'en'
}
createContext: async ({ req }) => ({
user: await authenticateRequest(req),
db: createTransactionalClient(),
requestId: req.headers['x-request-id'] ?? crypto.randomUUID(),
locale: parseAcceptLanguage(req.headers['accept-language']),
}),Toutes les procédures voient ce contexte typé. Ajouter un champ propage l'inférence automatiquement.
Pattern 2 — Middlewares en pipeline
Les middlewares s'enchaînent avec .use(...) sur les procédures.
const logged = t.procedure.use(async ({ next, path, type }) => {
const start = Date.now()
const result = await next()
console.log(`${type} ${path} ${Date.now() - start}ms ok=${result.ok}`)
return result
})
const authed = logged.use(({ ctx, next }) => {
if (!ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED' })
return next({ ctx: { ...ctx, user: ctx.user } })
})
const admin = authed.use(({ ctx, next }) => {
if (!ctx.user.roles.includes('admin')) throw new TRPCError({ code: 'FORBIDDEN' })
return next()
})Tu te crées une bibliothèque de procédures de base (publicProcedure, loggedProcedure, authedProcedure, adminProcedure) que tu utilises selon les besoins.
Pattern 3 — Validation par Zod (input ET output)
const createUser = protectedProcedure
.input(z.object({ email: z.string().email(), name: z.string() }))
.output(z.object({ id: z.string().uuid(), email: z.string(), name: z.string() }))
.mutation(async ({ input, ctx }) => {
const u = await ctx.db.users.create({ data: input })
return u // validé par le output schema avant envoi
})Le .output() est optionnel mais recommandé en prod : il empêche les fuites accidentelles (password hash, secrets internes) en filtrant la réponse.
Pattern 4 — Subscriptions via SSE ou WebSocket
// server
import { observable } from '@trpc/server/observable'
import { EventEmitter } from 'node:events'
const ee = new EventEmitter()
export const chatRouter = router({
send: protectedProcedure
.input(z.object({ room: z.string(), message: z.string() }))
.mutation(({ input, ctx }) => {
const msg = { id: crypto.randomUUID(), userId: ctx.user.id, ...input, at: new Date() }
ee.emit(`msg:${input.room}`, msg)
return msg
}),
onMessage: publicProcedure
.input(z.object({ room: z.string() }))
.subscription(({ input }) =>
observable<{ id: string; message: string }>((emit) => {
const handler = (msg: any) => emit.next(msg)
ee.on(`msg:${input.room}`, handler)
return () => ee.off(`msg:${input.room}`, handler)
})
),
})
// client
trpc.chat.onMessage.subscribe({ room: 'general' }, {
onData: (msg) => console.log(msg),
onError: (err) => console.error(err),
})En v11, tRPC supporte SSE en plus de WebSocket pour les subscriptions. SSE a l'avantage de fonctionner à travers les proxies HTTP/2 classiques sans configuration, et de bien tourner sur Cloudflare Workers.
Idiome v11 moderne — async generators. Depuis la v11, observable n'est plus le seul moyen : une subscription peut renvoyer un async generator. C'est l'idiome recommandé car il gère le backpressure naturellement (le yield est suspendu tant que le client ne consomme pas), supporte AbortSignal pour le cleanup, et permet de combiner SSE + reconnexion automatique avec un curseur de reprise (lastEventId). Le pattern observable reste valide et plus simple pour wrapper un EventEmitter synchrone.
import { on } from 'node:events'
import { tracked } from '@trpc/server' // helper v11 pour les events "trackés" (resumable)
onMessage: publicProcedure
.input(z.object({ room: z.string(), lastEventId: z.string().nullish() }))
.subscription(async function* ({ input, ctx, signal }) {
// 1) reprise : rejouer ce qui a été manqué depuis lastEventId (SSE reconnect)
if (input.lastEventId) {
const missed = await ctx.db.messages.since(input.room, input.lastEventId)
for (const m of missed) yield tracked(m.id, m)
}
// 2) live : itère l'EventEmitter en respectant l'AbortSignal du client
for await (const [msg] of on(ee, `msg:${input.room}`, { signal })) {
yield tracked(msg.id, msg) // tracked() => le client renvoie l'id en lastEventId au reconnect
}
})tracked(id, data) attache un id d'event ; sur reconnexion SSE, le client renvoie automatiquement le dernier id reçu comme lastEventId, et le serveur rejoue le gap. C'est le pattern qui rend une subscription resumable (pas de trou de messages après une coupure réseau) — quelque chose d'impossible à faire proprement avec observable seul. Le signal (un AbortSignal) est aborté quand le client se déconnecte : on(ee, ..., { signal }) se termine alors, et le for await sort — cleanup gratuit, pas de ee.off() manuel.
Pattern 5 — React Query integration
// client/react.ts
import { createTRPCReact } from '@trpc/react-query'
import type { AppRouter } from '../server'
export const trpc = createTRPCReact<AppRouter>()
// app.tsx
function UserList() {
const { data, isLoading } = trpc.users.list.useQuery({ limit: 20 })
const create = trpc.users.create.useMutation({
onSuccess: () => utils.users.list.invalidate(), // refetch
})
if (isLoading) return <Spinner />
return <ul>{data.map(u => <li key={u.id}>{u.name}</li>)}</ul>
}L'intégration React Query donne : cache automatique, retries, refetch on focus, optimistic updates, suspense, infinite queries. C'est la combo killer pour Next.js / Remix.
Pattern 6 — Batching et déduplication
httpBatchLink rassemble plusieurs queries en une seule requête HTTP. Si tu fais 5 useQuery dans une page, tRPC envoie 1 requête au lieu de 5. Coût réduit, latence aussi.
httpBatchLink({
url: '/trpc',
maxURLLength: 2083, // sinon split en plusieurs requêtes
})Pattern 7 — Edge deployment via Hono adapter
import { Hono } from 'hono'
import { trpcServer } from '@hono/trpc-server'
import { appRouter } from './server'
const app = new Hono()
app.use('/trpc/*', trpcServer({
router: appRouter,
createContext: ({ req }) => ({ user: null, db: edgeDb }),
}))
export default appLe router tRPC tourne tel quel sur Cloudflare Workers via Hono. Latence sub-100ms partout dans le monde, type-safety end-to-end.
🏭 Production — erreurs, rate limit, observabilité, perf
Un staff engineer ne juge pas tRPC sur le DX en greenfield mais sur ce qui se passe à 3h du matin quand une procédure renvoie des 500. Voici les axes de production.
Error handling — errorFormatter et codes
tRPC a un jeu de codes fini (UNAUTHORIZED, FORBIDDEN, NOT_FOUND, BAD_REQUEST, CONFLICT, TOO_MANY_REQUESTS, INTERNAL_SERVER_ERROR, ...) mappés sur des status HTTP. Le errorFormatter est le point unique pour (a) attacher le détail Zod (zodError) que le client peut exploiter pour afficher des erreurs par champ, (b) masquer les détails internes en prod.
import { ZodError } from 'zod'
const t = initTRPC.context<Context>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
// expose les erreurs Zod *structurées* au client (form errors typées)
zodError: error.code === 'BAD_REQUEST' && error.cause instanceof ZodError
? error.cause.flatten()
: null,
// ne JAMAIS leak le stack ou le message d'une 500 en prod
...(process.env.NODE_ENV === 'production' && error.code === 'INTERNAL_SERVER_ERROR'
? { message: 'Internal server error' }
: {}),
},
}
},
})Règle senior : une erreur attendue (validation, droit refusé, ressource absente) → un TRPCError avec le bon code, qui devient un 4xx propre. Une erreur inattendue (DB down, bug) → laisse-la remonter, elle devient un INTERNAL_SERVER_ERROR 500 dont le message est masqué côté client mais loggé/tracé côté serveur. Ne try/catch pas pour transformer une vraie 500 en faux 200.
Rate limiting en middleware
Le rate limit vit dans un middleware, branché sur le ctx (IP, userId) et un store (Redis/Upstash). On le compose comme n'importe quelle procédure de base.
const rateLimited = (limit: number, windowSec: number) =>
t.middleware(async ({ ctx, path, next }) => {
const key = `rl:${ctx.user?.id ?? ctx.ip}:${path}`
const count = await ctx.redis.incr(key)
if (count === 1) await ctx.redis.expire(key, windowSec)
if (count > limit) {
throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: `Limit ${limit}/${windowSec}s` })
}
return next()
})
// procédure coûteuse (génération de rapport) : 5 req / 60s
// v11 : `.concat()` (stabilisé) — en v10 c'était `.unstable_concat()`.
// Équivalent et plus direct : authedProcedure.use(rateLimited(5, 60))
const reportProcedure = authedProcedure.concat(rateLimited(5, 60))Note batching : avec httpBatchLink, N appels arrivent dans une requête HTTP mais chaque procédure passe son middleware indépendamment — le rate limit par path reste correct. En revanche un rate limit basé sur "1 requête HTTP" serait trompeur ; raisonne toujours par procédure.
Observability — OpenTelemetry par middleware
Un middleware « racine » qui ouvre un span par procédure te donne le tracing distribué gratuitement (path, type, durée, ok/erreur, code), corrélé avec les spans DB en aval.
import { trace, SpanStatusCode } from '@opentelemetry/api'
const tracer = trace.getTracer('trpc')
const traced = t.middleware(async ({ path, type, next }) => {
return tracer.startActiveSpan(`trpc.${type} ${path}`, async (span) => {
const res = await next()
if (!res.ok) {
span.setStatus({ code: SpanStatusCode.ERROR })
span.recordException(res.error)
span.setAttribute('trpc.error_code', res.error.code) // UNAUTHORIZED, etc.
}
span.end()
return res
})
})Métriques à exposer (Prometheus) : trpc_request_duration_seconds{path,type,ok}, un compteur par error_code, et — point souvent oublié — le taille de batch (httpBatchLink) car un batch de 50 queries qui timeout au global masque quelle procédure est lente. Sépare via httpBatchLink({ maxItems: ... }) ou route les procédures lentes sur un httpLink non batché avec un splitLink conditionné sur op.path.
Perf — le coût des types et du runtime
Deux perfs distinctes, à ne pas confondre :
| Dimension | Symptôme | Levier |
|---|---|---|
| Compile-time (tsc) | tsc lent, IDE qui rame, Type instantiation is excessively deep | Découper les routers, Zod 4, éviter les z.infer profonds en boucle, --isolatedDeclarations |
| Runtime serveur | p99 latence, CPU | Validation Zod (compilée en v4), éviter superjson sur gros payloads, N+1 DB dans les resolvers |
| Bundle client | poids JS livré | import type only, tree-shaking, httpBatchLink réduit le nb de requêtes pas le bundle |
Le piège runtime n°1 reste le N+1 dans les resolvers : tRPC ne te donne aucun DataLoader. Si users.list renvoie 50 users et que le front appelle posts.byUser 50 fois, tu as 50 requêtes (batchées en 1 HTTP, mais 50 hits DB). Solution : batche côté serveur avec un dataloader ou un IN (...), exactement comme tu le ferais en REST. tRPC ne résout PAS le N+1 — c'est le tradeoff vs GraphQL+DataLoader.
Sécurité — checklist
.output()sur toute procédure qui touche des données sensibles (anti-leak).- Auth dans le middleware, jamais dans le resolver (oubli facile = procédure ouverte).
- CSRF :
httpBatchLinkenvoie des POST ; protège via un header custom (x-trpc-source) vérifié serveur, ou SameSite cookies. tRPC ne le fait pas pour toi. - Pas de logique d'autorisation par ressource côté Zod (Zod valide la forme, pas les droits) — vérifie l'ownership dans le resolver (
where: { id, ownerId: ctx.user.id }). - Limite la profondeur/taille des inputs (
z.array(...).max(1000),z.string().max(...)) — un input non borné est un vecteur DoS.
🔄 Versions — tRPC v9, v10, v11 + Zod 3 → 4
Historique tRPC
- v9 (2021) — preuve de concept, API encore en mouvement.
- v10 (2022) — refactor majeur, API
proxy-based(trpc.users.list.query()), inférence stable. - v11 (2024) — stable en 2026, SSE natif pour subscriptions, types meilleurs, perf TS améliorée (les types ne font plus exploser tsc), adapters pour Next.js App Router, edge first-class.
Compat Node
tRPC v11 supporte Node 18+, mais en 2026 le mainstream est Node 22 LTS. Sur Cloudflare Workers, tRPC tourne via Hono adapter, runtime V8 isolate (pas Node).
Zod 3 → Zod 4
- Zod 3 (2020-2024) — la version qui a régné, devenue le standard de facto.
- Zod 4 (2024-2025, stable en 2026) — perf x6 sur la validation, types TS allégés (compile times divisés par 2-3 sur les schémas profonds), nouvelles features (
z.discriminatedUnionamélioré,z.codec, meilleurs message d'erreur,z.isonamespace pour les datetime).
Migration Zod 3 → 4 : la plupart du code passe sans changement. Les renames notables :
- Les validateurs de format chaînés deviennent des fonctions top-level :
z.string().email()→z.email(),z.string().uuid()→z.uuid(),z.string().url()→z.url(),z.string().datetime()→z.iso.datetime(). Les formes chaînées restent disponibles comme alias dépréciés (le code v3 compile encore sous Zod 4, d'où leur présence dans certains exemples de cette page), mais le top-level est l'idiome v4 recommandé. errorMap→error(paramètre unifié de personnalisation des messages).z.discriminatedUnionaccepte désormais des unions imbriquées et des branchespipe.
À faire en bloc avec zod-v4-codemod ou à la main pour un projet moyen.
Compat tRPC + Zod
tRPC v11 fonctionne avec Zod 3 et 4. La version 4 améliore l'expérience IDE et réduit les temps de build TypeScript sur les gros routers. Recommandation senior : Zod 4 en greenfield, Zod 3 si dépendances bloquantes (certains plugins React Hook Form attendent Zod 3 mais ils migrent).
⚠️ Pitfalls — 10 erreurs classiques
1. Importer le serveur depuis le client
// client/foo.ts
import { appRouter } from '../server/router' // ← KO, ramène prisma, fastify, etc. dans le bundle clientToujours import type :
import type { AppRouter } from '../server/router'C'est une règle fondamentale. tRPC ne fonctionne que parce qu'on échange uniquement les types entre back et front, pas le code.
2. Oublier superjson quand on transporte des Date
Sans superjson (ou devalue), new Date() côté serveur arrive en string côté client. Le type dit Date, la valeur est string — bug runtime sournois.
Solution : configurer le transformer: superjson côté serveur ET côté client. Les deux doivent matcher.
3. Schémas Zod recursifs sans z.lazy
// KO — récursion infinie au runtime
const Node = z.object({ id: z.string(), children: z.array(Node) })
// OK
const Node: z.ZodType<NodeT> = z.lazy(() => z.object({
id: z.string(),
children: z.array(Node),
}))Pour les arborescences (catégories, commentaires nested), z.lazy est obligatoire.
4. Schémas Zod gros qui explosent le compilateur TS
Un schéma Zod géant (50+ champs imbriqués 4 niveaux) peut faire ramer tsc ou même crasher. Solutions :
- Découper en sous-schémas réutilisables.
- Utiliser
z.object({...}).strict()au lieu depassthrough()(moins de types à dériver). - Passer à Zod 4 (perf TS x2-3).
- Pour les schémas vraiment énormes : TypeBox ou
valibot(encore plus rapide TS-wise).
5. Mutation qui ne renvoie pas la donnée mise à jour
update: protectedProcedure.input(...).mutation(async ({ input, ctx }) => {
await ctx.db.users.update({ where: { id: input.id }, data: input })
// ← rien retourné, le client doit refetch
})Convention senior : toujours retourner la ressource après mutation pour permettre setQueryData côté client (optimistic update).
6. Subscriptions sans cleanup
.subscription(({ input }) => observable((emit) => {
const handler = (msg) => emit.next(msg)
ee.on('event', handler)
// ← oubli du return cleanup → memory leak
}))Toujours retourner une fonction de cleanup qui fait ee.off(...).
7. protectedProcedure qui ne narrow pas
const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED' })
return next() // ← ne re-passe pas le ctx avec user narrowed
})
// ↓ dans la procédure
ctx.user.id // erreur TS : possibly nullIl faut explicitement narrow :
return next({ ctx: { ...ctx, user: ctx.user } })8. Batching en mutation
httpBatchLink batche les queries. Pour les mutations, le comportement est plus subtil (elles ne sont pas idempotentes). Si tu envoies 10 mutations en parallèle, elles partent en 10 requêtes. Pour les regrouper volontairement, créer une mutation "bulk" côté serveur.
9. Refinements Zod avec async
const Email = z.string().email().refine(async (v) => await isNotBlacklisted(v))
// ← KO en .parse(), il faut .parseAsync()Si tu as un refinement async, tu dois appeler .parseAsync(...) ou .safeParseAsync(...). tRPC l'appelle automatiquement, mais tes propres usages doivent en tenir compte.
10. Zod .transform() qui change le type d'output
const Schema = z.string().transform(s => parseInt(s, 10)) // input: string, output: numberC'est puissant mais piégeux : le type d'output diffère du type d'input. En tRPC .input(Schema), l'input côté client est string, le input dans le handler est number. C'est utile pour parser des query params (string → number), confusion garantie sinon.
🧪 Testing
Test d'un router en isolation
import { createCallerFactory } from './trpc'
import { appRouter } from './server'
const createCaller = createCallerFactory(appRouter)
test('users.list returns users for authed user', async () => {
const caller = createCaller({ user: { id: '1', email: '[email protected]' }, db: mockDb })
const result = await caller.users.list({ limit: 10 })
expect(result).toHaveLength(2)
})
test('users.list throws for unauth', async () => {
const caller = createCaller({ user: null, db: mockDb })
await expect(caller.users.list({ limit: 10 })).rejects.toThrow('UNAUTHORIZED')
})createCallerFactory permet d'appeler le router directement, en in-process, sans HTTP. Idéal pour tester la logique métier sans monter de serveur.
Test E2E avec fetch réel
import { appRouter } from './server'
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'
const app = Fastify()
app.register(fastifyTRPCPlugin, { router: appRouter, ... })
await app.listen({ port: 0 })
const client = createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: `http://localhost:${app.server.address().port}/trpc` })],
})
test('e2e create user', async () => {
const u = await client.users.create.mutate({ email: '[email protected]', name: 'A' })
expect(u.id).toBeDefined()
})Tester un schéma Zod isolément
test('UserCreate rejects empty name', () => {
const r = UserCreate.safeParse({ email: '[email protected]', name: '' })
expect(r.success).toBe(false)
})Les schémas Zod sont juste des objets : trivialement testables.
🎬 Cas d'usage concrets
Scénario 1 — SaaS RH "PaySimple Next" type-safe end-to-end
PaySimple lance une nouvelle gamme "PaySimple Next" en greenfield, ciblée aux startups françaises. Stack : Next.js 15 (App Router) + tRPC v11 + Zod 4 + Prisma + Postgres. L'équipe (4 fullstack) refuse explicitement GraphQL parce qu'ils n'ont pas de partenaires externes à exposer et ne veulent pas maintenir un schéma SDL. Ils refusent aussi REST classique parce qu'ils ont galéré pendant deux ans avec openapi-typescript sur PaySimple legacy (types pas toujours à jour, codegen cassant).
Le résultat tRPC : 80 procédures réparties en 12 routers (users, employees, payslips, leaves, expenses, etc.), un seul appRouter exporté. Le frontend Next.js consomme via createTRPCReact<AppRouter>() et profite de React Query pour le cache. Quand un dev backend renomme Employee.contractType en Employee.contract, le TypeScript compile-time fail sur 23 endroits côté front — refactor ciblé en 15 minutes au lieu de découvrir le bug en prod trois semaines plus tard. L'équipe utilise protectedProcedure chainé pour les permissions (employeeProcedure = protectedProcedure.use(checkEmployeeAccess)), des branded types Zod pour distinguer EmployeeId, UserId, CompanyId (impossible de mélanger accidentellement). Velocity équipe doublée par rapport à PaySimple legacy selon les KPI internes (PR mergées/semaine, bugs prod/release).
Scénario 2 — Cabinet juridique "LexFidens" app interne avec subscriptions
LexFidens a une troisième app interne : un outil collaboratif où les avocats commentent les dossiers en temps réel pendant les réunions de stratégie client. Stack : Remix + tRPC v11 + Zod 4 + Postgres + Redis pub/sub. Les subscriptions tRPC sont utilisées pour pousser les commentaires en live à tous les avocats connectés sur le même dossier.
Architecture : un commentsRouter avec une procédure subscription qui s'abonne à un canal Redis Pub/Sub comments:dossier:{id}. Quand un avocat poste un commentaire (mutation), le serveur écrit en Postgres et publish sur Redis. Tous les clients abonnés au dossier reçoivent l'event via WebSocket (wsLink). Le subscription retourne une fonction de cleanup qui unsubscribe du canal Redis (évite les memory leaks). Côté UI Remix, trpc.comments.onNew.useSubscription({ dossierId }, { onData }) met à jour le store local. Type-safety end-to-end : le payload { id, author, content, at } est typé depuis Zod, sans aucun cast côté React. L'équipe (5 devs) a livré la feature en 3 semaines, là où une approche Socket.io + types manuels en aurait pris 6.
Scénario 3 — Dashboard interne banque "NeoCrédit Ops"
NeoCrédit a un dashboard interne pour 60 ops & risk officers : ils consultent les transactions suspectes, contactent les clients, déclenchent des actions (blocage de carte, gel de compte, signalement TRACFIN). Stack interne : monorepo TS avec Vite + React + tRPC v11 + Zod + Fastify + Postgres. Pas d'app mobile, pas de partenaire externe — la stack peut être 100 % TS, donc tRPC est idéal.
Le appRouter a 150 procédures couvrant des écrans très différents (queue d'investigation, profil client, historique de transactions, audit log RGPD). L'équipe ops & risk teste l'app en pré-prod et donne des feedbacks rapides : ils veulent renommer un champ, ajouter un filtre, modifier l'ordre des colonnes. Avec tRPC + React Query, ces changements prennent 30 minutes par feedback (modifier le router serveur, le compilo TS pointe les 4 composants front à update, push). Aucun OpenAPI à régénérer, aucun mock front-end à maintenir. L'équipe utilise aussi createCallerFactory pour tester les procédures en pure logique métier (sans HTTP), ce qui a permis de viser 85 % de couverture sur les modules critiques (calcul de score de risque, génération de rapport TRACFIN) sans monter de serveur de test.
🛠️ Exemple end-to-end
Cas d'usage : "router expenseReports dans PaySimple Next — un employé soumet une note de frais, son manager l'approuve ou la refuse, et tout le monde voit les notifications live ; tRPC + Zod 4 + Prisma + Redis pub/sub pour subscriptions".
// server/trpc/context.ts
import type { CreateNextContextOptions } from '@trpc/server/adapters/next'
import { verifyJwt } from '../auth'
import { prisma } from '../db'
import { redis } from '../redis'
export const createContext = async ({ req }: CreateNextContextOptions) => {
const token = req.headers.authorization?.replace(/^Bearer /, '')
const user = token ? await verifyJwt(token).catch(() => null) : null
return { user, prisma, redis }
}
export type Context = Awaited<ReturnType<typeof createContext>>
// server/trpc/init.ts
import { initTRPC, TRPCError } from '@trpc/server'
import superjson from 'superjson'
import type { Context } from './context'
const t = initTRPC.context<Context>().create({ transformer: superjson })
export const router = t.router
export const publicProcedure = t.procedure
export const authedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED' })
return next({ ctx: { ...ctx, user: ctx.user } })
})
export const managerProcedure = authedProcedure.use(({ ctx, next }) => {
if (!ctx.user.roles.includes('manager')) throw new TRPCError({ code: 'FORBIDDEN' })
return next()
})
// server/trpc/routers/expenseReports.ts
import { z } from 'zod'
import { router, authedProcedure, managerProcedure } from '../init'
import { observable } from '@trpc/server/observable'
const ExpenseReportId = z.string().uuid().brand<'ExpenseReportId'>()
const EmployeeId = z.string().uuid().brand<'EmployeeId'>()
const SubmitInput = z.object({
amount: z.number().min(0.01).max(50000),
currency: z.literal('EUR'),
category: z.enum(['meal', 'transport', 'lodging', 'supplies', 'other']),
description: z.string().min(3).max(500),
receiptUrl: z.string().url(),
incurredAt: z.iso.datetime(),
})
const ApproveInput = z.object({ reportId: ExpenseReportId, comment: z.string().max(500).optional() })
const RejectInput = z.object({ reportId: ExpenseReportId, reason: z.string().min(3).max(500) })
const ReportEvent = z.object({
type: z.enum(['submitted', 'approved', 'rejected']),
reportId: ExpenseReportId,
employeeId: EmployeeId,
amount: z.number(),
at: z.iso.datetime(),
})
export const expenseReportsRouter = router({
list: authedProcedure
.input(z.object({ status: z.enum(['draft', 'pending', 'approved', 'rejected']).optional(), limit: z.number().int().min(1).max(100).default(20) }))
.query(({ input, ctx }) =>
ctx.prisma.expenseReport.findMany({
where: { employeeId: ctx.user.employeeId, ...(input.status && { status: input.status }) },
take: input.limit,
orderBy: { createdAt: 'desc' },
})
),
submit: authedProcedure
.input(SubmitInput)
.output(z.object({ id: ExpenseReportId, status: z.literal('pending') }))
.mutation(async ({ input, ctx }) => {
const report = await ctx.prisma.expenseReport.create({
data: { ...input, employeeId: ctx.user.employeeId, status: 'pending' },
})
await ctx.redis.publish('expense:events', JSON.stringify({
type: 'submitted', reportId: report.id, employeeId: ctx.user.employeeId,
amount: input.amount, at: new Date().toISOString(),
}))
// l'output schema re-valide et "brande" report.id en ExpenseReportId
return { id: report.id as z.infer<typeof ExpenseReportId>, status: 'pending' as const }
}),
approve: managerProcedure
.input(ApproveInput)
.mutation(async ({ input, ctx }) => {
const report = await ctx.prisma.expenseReport.update({
where: { id: input.reportId },
data: { status: 'approved', approvedById: ctx.user.id, approvedAt: new Date(), managerComment: input.comment },
})
await ctx.redis.publish('expense:events', JSON.stringify({
type: 'approved', reportId: report.id, employeeId: report.employeeId,
amount: report.amount, at: new Date().toISOString(),
}))
return report
}),
reject: managerProcedure
.input(RejectInput)
.mutation(async ({ input, ctx }) => {
const report = await ctx.prisma.expenseReport.update({
where: { id: input.reportId },
data: { status: 'rejected', rejectedById: ctx.user.id, rejectedAt: new Date(), rejectionReason: input.reason },
})
await ctx.redis.publish('expense:events', JSON.stringify({
type: 'rejected', reportId: report.id, employeeId: report.employeeId,
amount: report.amount, at: new Date().toISOString(),
}))
return report
}),
onEvent: authedProcedure
.subscription(({ ctx }) =>
observable<z.infer<typeof ReportEvent>>((emit) => {
const subscriber = ctx.redis.duplicate()
subscriber.subscribe('expense:events')
subscriber.on('message', (_channel, payload) => {
const parsed = ReportEvent.safeParse(JSON.parse(payload))
if (parsed.success) emit.next(parsed.data)
})
return () => {
subscriber.unsubscribe('expense:events')
subscriber.quit()
}
})
),
})Ce router combine huit patterns tRPC + Zod seniors : (1) procedures hiérarchisées (publicProcedure → authedProcedure → managerProcedure) avec narrowing du contexte à chaque étage, (2) branded types Zod pour ExpenseReportId et EmployeeId (impossible de mélanger un UUID employé avec un UUID rapport), (3) output schema sur submit qui filtre la réponse, (4) superjson qui sérialise Date correctement (Prisma renvoie des Date, le client les reçoit en Date), (5) Redis Pub/Sub pour propager les events à toutes les instances Next.js (scale horizontal), (6) subscription tRPC qui s'abonne au canal Redis et émet via observable, (7) cleanup obligatoire (unsubscribe + quit) pour éviter les memory leaks, (8) validation Zod côté serveur sur le payload Pub/Sub (defense in depth : un message malformé venant d'un autre service ne crash pas le subscriber). Côté client React, trpc.expenseReports.onEvent.useSubscription() push automatiquement les updates dans React Query via utils.expenseReports.list.invalidate() — UX réactive, code minimal, type-safety totale.
🔁 Quand utiliser / éviter
Utiliser tRPC
- Monorepo TypeScript fullstack : Next.js + backend Node, ou Remix + Fastify, tout en TS. C'est le sweet spot.
- Équipe full TS : pas de mobile natif Android/iOS qui consomme l'API, pas de partenaire externe en Python/Java.
- Itération rapide : ajouter un champ, refactoriser, renommer — tout est typé end-to-end, le compilo te dit où ça casse.
- Pas envie de gérer un schéma GraphQL : tu veux du DX similaire sans la complexité GraphQL.
Éviter tRPC
- API publique : tes consommateurs ne sont pas tous TS. Reste sur REST/OpenAPI ou GraphQL.
- Stack polyglotte : backend Go, mobile Swift, web React. tRPC ne sert qu'au web TS, le reste retombe sur HTTP plain.
- Microservices hétérogènes : tRPC n'a pas de fédération comme GraphQL Federation. Pour des dizaines de services, regarde gRPC ou GraphQL.
- Besoin d'introspection runtime : tRPC ne s'introspecte pas comme GraphQL. Pas de Postman intelligent, pas d'auto-doc UI prête (il existe trpc-panel mais pas du tout au niveau de GraphQL Playground).
tRPC vs REST vs GraphQL
| Critère | tRPC | REST | GraphQL |
|---|---|---|---|
| Type-safety end-to-end TS | total | manuel ou via openapi-typescript | total (codegen) |
| Codegen requis | non | optionnel | oui |
| Schéma textuel | non | OpenAPI (optionnel) | obligatoire (SDL) |
| Cross-language | non | oui | oui |
| Sélection client-side | non (whole-object) | non | oui |
| N+1 | côté serveur (à toi) | côté serveur (à toi) | côté serveur (DataLoader) |
| Subscriptions | oui (SSE/WS) | non native | oui (WS) |
| Introspection runtime | non | non | oui |
| Bundle client | léger | léger | lourd (apollo/relay) |
| Caching | React Query | divers | apollo/relay |
Quand mélanger
Pattern réel en 2026 : tRPC pour l'app web TS + REST/OpenAPI pour les intégrations externes. Tu écris ta logique métier dans des fonctions pures, ton routeur tRPC l'appelle pour le web, et tu exposes un sous-ensemble REST pour les webhooks, partenaires, mobile natif.
Zod : design, performance, branded types
Design d'API.
z.object()pour les structures,.strict()pour rejeter les champs en trop,.partial()pour rendre tout optionnel,.pick()/.omit()pour les variantes.z.union([A, B])ouz.discriminatedUnion('type', [A, B]): le discriminé est x10 plus rapide et meilleur en TS..transform()pour transformer après validation,.refine()pour des règles custom,.preprocess()pour normaliser avant validation.
Performance.
Zod 4 a divisé par 6 le temps de validation et par 2-3 le temps de compilation TS sur les gros schémas. Pour aller plus loin, alternatives : valibot (3-10× plus rapide que Zod 3 sur certains benchmarks, modulaire), arktype (basé sur des chaînes), typia (codegen, le plus rapide). En 2026, Zod 4 reste le standard, les autres sont des niches.
Branded types — pour distinguer des string qui ont le même type mais des sémantiques différentes :
const UserId = z.string().uuid().brand<'UserId'>()
type UserId = z.infer<typeof UserId>
function getUser(id: UserId) { ... }
const id = UserId.parse('abc-...') // narrow
getUser(id) // OK
getUser('abc-...') // ← KO TS : string n'est pas UserIdC'est l'équivalent des "newtypes" en Haskell / Rust. Indispensable pour éviter de mélanger OrderId et UserId accidentellement.
Refinements.
const Password = z.string()
.min(8)
.refine(p => /[A-Z]/.test(p), 'must contain uppercase')
.refine(p => /[0-9]/.test(p), 'must contain digit')Ou en un seul refinement avec superRefine pour ajouter plusieurs issues :
const Form = z.object({ password: z.string(), confirm: z.string() })
.superRefine((data, ctx) => {
if (data.password !== data.confirm) {
ctx.addIssue({ code: 'custom', path: ['confirm'], message: 'must match' })
}
})🏋️ Exercices
Progression : implémenter → production-grade → casser puis réparer. Fais-les dans l'ordre, chaque exercice réutilise le précédent.
Exercice 1 — Procédure de base + branded types (implémenter)
Objectif : créer un notesRouter avec create, byId, list, où l'id de note est un branded type Zod (NoteId), de sorte qu'on ne puisse pas passer un UserId là où un NoteId est attendu.
Indice/Solution : const NoteId = z.string().uuid().brand<'NoteId'>(). Mets .input(z.object({ id: NoteId })) sur byId. Vérifie que getNote(someUserId) est rejeté par tsc (et pas seulement au runtime). Ajoute un .output() qui exclut un champ internalScore pour prouver le filtrage.
Exercice 2 — Pile de procédures + narrowing (implémenter)
Objectif : construire publicProcedure → authedProcedure → ownerProcedure, où ownerProcedure garantit que ctx.user.id === note.ownerId, et où le type de ctx.user est non-nullable dès authedProcedure.
Indice/Solution : chaque middleware fait return next({ ctx: { ...ctx, user: ctx.user } }) pour narrow. ownerProcedure ne peut pas connaître la note (pas d'input encore) — fais plutôt un middleware-usine requireOwnership((input) => input.noteId) appliqué par procédure, ou vérifie l'ownership dans le resolver avec where: { id, ownerId: ctx.user.id }. Discute le tradeoff : middleware réutilisable vs requête DB dupliquée.
Exercice 3 — Subscription resumable via async generator (production-grade)
Objectif : exposer notes.onChange en async generator avec tracked() et lastEventId, de sorte qu'un client qui perd la connexion 5s ne rate aucun event au reconnect.
Indice/Solution : async function* ({ input, ctx, signal }). Si input.lastEventId, rejoue ctx.db.events.since(lastEventId) en yield tracked(id, e). Puis for await (const [e] of on(ee, 'note:change', { signal })) yield tracked(e.id, e). Teste en coupant le WS/SSE et en vérifiant que les events manqués arrivent. Le piège : si tu oublies signal, le générateur ne se termine jamais → leak.
Exercice 4 — Rate limit + observabilité (production-grade)
Objectif : ajouter un middleware rateLimited(10, 60) sur create (Redis), et un middleware traced qui ouvre un span OTel par procédure avec le code d'erreur en attribut. Prouve que sous httpBatchLink, 15 create dans un batch déclenchent bien un TOO_MANY_REQUESTS au 11e.
Indice/Solution : redis.incr(key) + expire si count===1. Clé = rl:${userId}:${path}. Pour OTel, tracer.startActiveSpan enveloppant await next(), set SpanStatusCode.ERROR + recordException si !res.ok. Le batch passe chaque procédure dans le middleware séparément, donc le compteur s'incrémente 15 fois pour un seul POST HTTP.
Exercice 5 — Casser le N+1 puis le réparer (break-then-fix)
Objectif : écrire notes.list qui renvoie 50 notes, puis un front qui appelle notes.tagsFor 50 fois. Mesure : 50 hits DB (batchés en 1 HTTP). Répare avec un DataLoader ou un WHERE noteId IN (...).
Indice/Solution : instancie le DataLoader dans le createContext (un par requête, sinon cache cross-tenant = fuite de données). ctx.loaders.tagsByNote.load(id) coalesce les 50 load() en un findMany({ where: { noteId: { in: ids } } }). Compte les requêtes via un middleware Prisma. Conclusion à formuler : tRPC ne résout pas le N+1, c'est à toi — contrairement à GraphQL où DataLoader est canonique.
Exercice 6 — Casser superjson puis diagnostiquer (break-then-fix)
Objectif : configurer transformer: superjson côté serveur mais l'oublier côté client. Observe : Date arrive en string, le type ment. Puis ajoute un test qui attrape ce mismatch.
Indice/Solution : expect(typeof result.createdAt).not.toBe('string') échoue tant que le client n'a pas le même transformer. Le bug est sournois car tsc est vert (le type dit Date). Leçon : transformer serveur ET client doivent matcher, et un test runtime sur le type réel attrape ce que les types statiques ne peuvent pas (la frontière réseau est non typée).
🎤 En entretien
Q : Pourquoi tRPC obtient-il le type-safety end-to-end sans codegen, et quelle est la limite fondamentale de cette approche ? Parce que le client importe type AppRouter = typeof appRouter — un type TypeScript, effacé au build — et infère input/output par le compilateur, pas par un artefact généré. La limite : ça ne marche que si client et serveur partagent le même tsconfig/monorepo et sont tous deux en TS ; la frontière réseau elle-même reste non typée (d'où superjson et .output()), et aucun consommateur non-TS ne peut profiter de ces types.
Q : Comment protectedProcedure rend-il ctx.user non-nullable dans les procédures qui l'utilisent ? Le middleware throw si ctx.user est null, puis fait return next({ ctx: { ...ctx, user: ctx.user } }). Le next({ ctx }) renvoie un nouveau type de contexte où user est narrowed à non-null (TS sait qu'après le throw, ctx.user n'est plus User | null). Si tu fais juste next() sans repasser le ctx, le narrowing est perdu et ctx.user.id est une erreur TS « possibly null ».
Q : tRPC résout-il le problème N+1 comme GraphQL ? Non. httpBatchLink batche les requêtes HTTP (50 queries → 1 POST), ce qui réduit la latence réseau, mais chaque resolver s'exécute indépendamment côté serveur — 50 hits DB restent 50 hits DB. GraphQL non plus ne le résout pas seul, mais y associe canoniquement DataLoader. En tRPC c'est à toi d'ajouter un DataLoader (instancié par requête dans createContext) ou un IN (...). C'est un tradeoff assumé, pas un bug.
Q : Subscriptions — quand choisir SSE vs WebSocket en tRPC v11, et comment garantir qu'un client ne rate aucun event après une coupure ? SSE par défaut : passe les proxies HTTP/2, marche sur edge/Workers, reconnexion native du navigateur. WS si tu as besoin de bidirectionnel ou de très haute fréquence. Pour le « zéro event manqué », utilise un async generator qui yield tracked(id, data) : à la reconnexion SSE, le client renvoie le dernier id en lastEventId, et le serveur rejoue le gap depuis un store persistant avant de reprendre le live. observable seul ne permet pas ce replay.
🔗 Liens
- tRPC : https://trpc.io
- Repo : https://github.com/trpc/trpc
- Adapters (Next, Express, Fastify, Lambda, Hono) : https://trpc.io/docs/server/adapters
- React Query integration : https://trpc.io/docs/client/react
- Zod : https://zod.dev
- Zod 4 migration : https://zod.dev/v4
- valibot (alternative) : https://valibot.dev
- arktype (alternative) : https://arktype.io
- "Why tRPC vs GraphQL" Alex / KATT : https://trpc.io/docs/concepts
- Branded types pattern : https://github.com/colinhacks/zod#brand
- Talk tRPC v11 : https://www.youtube.com/results?search_query=trpc+v11
Récap final. tRPC v11 + Zod est, en 2026, la combo de référence pour les apps fullstack TypeScript où back et front partagent un monorepo. Tu écris une procédure côté serveur, tu l'appelles côté client comme une fonction typée, sans codegen, sans schéma à maintenir, avec l'inférence Zod qui propage les types d'input et d'output. Les patterns clés : protectedProcedure avec narrowing du contexte, middlewares chaînables, subscriptions via SSE (ou WS) avec cleanup, intégration React Query pour le cache et l'optimistic update, déploiement edge via l'adapter Hono. Zod (v4 en 2026) est l'allié naturel : schemas TypeScript-first, refinements, transforms, branded types, perf x6 par rapport à Zod 3. Limites : pas pour les API publiques cross-language, pas de fédération GraphQL, pas d'introspection runtime. À mélanger judicieusement avec REST pour les intégrations externes. Pour un SaaS B2B en monorepo Next.js/Remix + Node backend, tRPC v11 + Zod 4 est la stack qui donne le meilleur DX du marché — type-safety end-to-end, refactor sans peur, et perf compatible avec edge.