tRPC, typowane API bez schematów i generowania
tRPC pozwala wywołać funkcję serwerową z klienta tak, jakby leżała w tym samym pliku, z pełnym podpowiadaniem typów i błędem kompilacji, gdy zmienisz sygnaturę. Nie ma tu schematu, generowania kodu ani osobnego kroku budowania.
Sztuczka polega na tym, że typy nie są przesyłane ani generowane, tylko wywnioskowane. Klient importuje wyłącznie typ routera serwerowego, a kompilator wyprowadza z niego kształt wszystkich wywołań. W czasie działania programu nie zostaje po tym ślad, bo cała ta warstwa znika przy kompilacji.
Na czym polega problem, który to rozwiązuje
Warto nazwać go dokładnie, bo od tego zależy, czy to narzędzie jest Ci potrzebne.
W zwykłym API opartym o adresy URL kształt odpowiedzi jest po stronie klienta nieznany. Zapisujesz interfejs ręcznie i on jest obietnicą, nie faktem. Gdy serwer zmieni nazwę pola, kod klienta kompiluje się dalej, a błąd pojawia się dopiero u użytkownika.
Można temu zaradzić schematem i generowaniem klienta z opisu, co działa, natomiast dokłada krok w procesie budowania i moment, w którym opis rozjeżdża się z implementacją. Każdy, kto pracował z generowanym klientem, zna sytuację, w której ktoś zapomniał przegenerować pliki.
tRPC likwiduje ten rozjazd, bo nie ma czego rozjeżdżać. Źródłem prawdy jest kod serwera, a typy klienta są z niego wyprowadzone przez kompilator.
Cena jest jedna i konkretna: obie strony muszą być w TypeScripcie i widzieć swój kod nawzajem. Jeśli aplikacja mobilna jest w Swifcie, a panel administracyjny w Pythonie, ta technika im nic nie daje.
Kiedy to jest właściwy wybór
Trzy warunki muszą być spełnione jednocześnie, bo brak któregokolwiek zamienia zaletę w kłopot.
Pierwszy: jeden zespół albo jedno repozytorium. Klient musi importować typ z serwera, więc obie części muszą leżeć w miejscu, z którego kompilator je widzi.
Drugi: API jest prywatne. To jest interfejs dla Twojego własnego frontendu, nie kontrakt dla obcych integracji. Nie ma tu opisu w standardowym formacie, nie ma przeglądarki dokumentacji, nie ma wersjonowania adresów.
Trzeci: obie strony w TypeScripcie, i to zbliżonych wersjach, bo wnioskowanie typów potrafi się różnić między wydaniami kompilatora.
Gdy któryś warunek nie jest spełniony, zwykłe API oparte o adresy albo GraphQL jest lepszym wyborem, i to nie jest kompromis, tylko właściwe narzędzie do innego problemu.
Router, procedura, kontekst
Cała biblioteka opiera się na trzech pojęciach i po ich zrozumieniu reszta jest szczegółem konfiguracji.
import { initTRPC } from '@trpc/server'
import { z } from 'zod'
const t = initTRPC.context<Kontekst>().create()
export const router = t.router
export const procedura = t.procedure
export const appRouter = router({
uzytkownik: router({
pobierz: procedura
.input(z.object({ id: z.string() }))
.query(({ input, ctx }) => ctx.db.uzytkownik.znajdz(input.id)),
zmienNazwe: procedura
.input(z.object({ id: z.string(), nazwa: z.string().min(2) }))
.mutation(({ input, ctx }) => ctx.db.uzytkownik.aktualizuj(input)),
}),
})
export type AppRouter = typeof appRouterOstatnia linia jest najważniejsza w całym pliku. Eksportujesz sam typ, nie wartość, więc kod serwera nie trafia do pakietu klienta. To rozróżnienie bywa mylone i prowadzi do wciągnięcia całego zaplecza razem ze sterownikiem bazy do przeglądarki.
Walidacja wejścia przez Zoda pełni tu podwójną rolę. Odrzuca niepoprawne dane w czasie działania i jednocześnie wyprowadza typ argumentu, więc nie deklarujesz go osobno. Bez walidatora tracisz obie rzeczy naraz.
Kontekst to obiekt budowany przy każdym zapytaniu, i tam trafia połączenie z bazą oraz dane zalogowanej osoby. Rozszerzenie kontekstu o sprawdzenie uprawnień daje procedurę chronioną, którą składasz raz i używasz wszędzie.
export const proceduraZalogowana = procedura.use(({ ctx, next }) => {
if (!ctx.uzytkownik) throw new TRPCError({ code: 'UNAUTHORIZED' })
return next({ ctx: { ...ctx, uzytkownik: ctx.uzytkownik } })
})Zwrócenie zawężonego kontekstu z next sprawia, że w procedurach opartych na tej warstwie pole z użytkownikiem przestaje być opcjonalne. To drobiazg, który usuwa dziesiątki sprawdzeń w kodzie.
Integracja po stronie klienta
Tu zaszła zmiana, o której trzeba wiedzieć, bo większość poradników w sieci opisuje starsze podejście.
Klasyczna integracja z TanStack Query dawała własne warianty punktów zaczepienia, opakowujące bibliotekę zapytań. Nowa integracja idzie inaczej: zwraca zwykłe obiekty opcji, które podajesz do standardowych funkcji z biblioteki zapytań.
import { useQuery } from '@tanstack/react-query'
import { useTRPC } from './trpc'
function ProfilUzytkownika({ id }: { id: string }) {
const trpc = useTRPC()
const { data, isPending } = useQuery(trpc.uzytkownik.pobierz.queryOptions({ id }))
if (isPending) return <p>Wczytywanie</p>
return <h1>{data.nazwa}</h1>
}Zysk jest praktyczny. Wszystko, co umiesz z biblioteki zapytań, działa tu bez tłumaczenia: buforowanie, unieważnianie, wstępne pobieranie, zapytania zależne. Nie musisz uczyć się osobnego zestawu funkcji, a dokumentacja biblioteki zapytań stosuje się wprost.
Do nowych projektów autorzy zalecają właśnie to podejście, a istniejącym radzą przechodzić stopniowo. Obie integracje da się trzymać obok siebie w jednym projekcie, co pozwala migrować ekran po ekranie zamiast jednym ruchem.
tRPC kontra alternatywy
| Rozwiązanie | Źródło typów | Klient obcy niż TypeScript | Kiedy wybrać |
|---|---|---|---|
| tRPC | Wnioskowanie z kodu serwera | Nie | Prywatne API, jedno repozytorium |
| REST z opisem OpenAPI | Schemat plus generowanie | Tak | API publiczne, wielu odbiorców |
| GraphQL | Schemat plus generowanie | Tak | Wielu klientów, różne zestawy pól |
| Server Actions | Wnioskowanie z kodu funkcji | Nie | Aplikacja w Next.js, głównie formularze |
Ostatni wiersz jest dziś najważniejszym pytaniem przy nowym projekcie w Next.js i warto na nie odpowiedzieć uczciwie.
Akcje serwerowe dają to samo bezpieczeństwo typów przy zerowej konfiguracji, bo są wbudowane. Wywołujesz funkcję z komponentu, ona wykonuje się na serwerze, typy zgadzają się same. Do formularzy i prostych operacji zapisu to zwykle wystarczy i dokładanie osobnej warstwy nie ma uzasadnienia.
Przewaga tRPC pojawia się w trzech sytuacjach. Gdy klient nie jest tą samą aplikacją, na przykład aplikacja mobilna w React Native sięgająca do tego samego zaplecza. Gdy potrzebujesz pełnej kontroli nad buforowaniem po stronie klienta, a nie tylko odświeżenia widoku po zapisie. Oraz gdy zaplecze ma żyć niezależnie od frameworka frontendowego, bo akcje serwerowe wiążą Cię z Next.js na poziomie, z którego wyjście oznacza przepisanie.
Łączenie zapytań i koszt sieciowy
Jedno z pierwszych pytań przy tej bibliotece brzmi: skoro każde wywołanie procedury to zapytanie HTTP, czy ekran z dziesięcioma wywołaniami wysyła dziesięć zapytań.
Domyślnie nie, bo wywołania wykonane w tym samym cyklu są sklejane w jedno zapytanie i rozdzielane po stronie serwera. Zysk widać zwłaszcza przy połączeniu mobilnym, gdzie koszt nawiązania połączenia bywa większy niż samo przetworzenie danych.
Ten mechanizm ma jednak konsekwencję, którą trzeba znać. Sklejone zapytanie kończy się wtedy, gdy skończy się najwolniejsza procedura w paczce, więc jedna wolna operacja opóźnia wszystkie pozostałe. Jeśli na ekranie masz szybkie dane profilu i wolne wyliczenie statystyk, wrzucenie ich do jednej paczki sprawia, że nazwa użytkownika pojawia się dopiero po zakończeniu liczenia.
Rozwiązanie jest proste: rozdziel je świadomie, wyłączając sklejanie dla ciężkich procedur albo umieszczając je w osobnym komponencie ładowanym niezależnie. Warto to sprawdzić w zakładce sieci przeglądarki, bo problem widać dopiero na realnych czasach odpowiedzi.
Druga rzecz dotyczy metody HTTP. Zapytania odczytujące idą domyślnie przez GET, więc dają się buforować na poziomie pośredników i sieci dostarczania treści. Operacje zapisujące idą przez POST i buforowaniu nie podlegają. To rozróżnienie ma znaczenie przy danych publicznych, gdzie odpowiedź może leżeć na brzegu sieci zamiast obciążać serwer.
Trzecia rzecz to rozmiar odpowiedzi. Wnioskowanie typów działa niezależnie od tego, ile pól zwracasz, więc łatwo przyzwyczaić się do oddawania całego obiektu z bazy. Typ się zgadza, a przez sieć lecą pola, których interfejs nigdy nie pokaże. Wybieranie kolumn w zapytaniu do bazy jest tu wart uwagi bardziej niż w API pisanym ręcznie, właśnie dlatego, że nic nie przypomina o problemie.
Subskrypcje i dane przychodzące na bieżąco
Poza odczytem i zapisem istnieje trzeci rodzaj procedury: subskrypcja, czyli strumień zdarzeń płynący od serwera do klienta.
Nadaje się do powiadomień, wskaźników obecności, postępu długiego zadania i wszystkiego, co ma pojawić się bez odświeżania. Kształt danych jest typowany tak samo jak przy zwykłych procedurach, więc obsługa zdarzenia po stronie klienta dostaje podpowiadanie.
Warto natomiast wiedzieć, że to jest najbardziej kosztowna operacyjnie część tej biblioteki. Utrzymane połączenie wymaga infrastruktury, która na to pozwala, a spora część środowisk bezserwerowych rozłącza je po kilkudziesięciu sekundach. Zanim to zaplanujesz, sprawdź, czy Twój sposób wdrożenia w ogóle utrzymuje długie połączenia.
Przy mniejszych potrzebach odpytywanie co kilka sekund bywa rozwiązaniem rozsądniejszym, niż się wydaje. Jest brzydsze, natomiast działa wszędzie, nie wymaga stanu po stronie serwera i nie psuje się przy wdrożeniu nowej wersji, gdy wszystkie połączenia zrywają się naraz.
Obsługa błędów i to, co widzi klient
Błędy mają w tej bibliotece własne kody i to jest jedna z rzeczy, które ułatwiają życie bardziej, niż wygląda.
throw new TRPCError({ code: 'NOT_FOUND', message: 'Nie ma takiego użytkownika' })Kod przekłada się na status HTTP, a klient dostaje go w ustalonym kształcie, więc rozróżnienie braku uprawnień od braku zasobu nie wymaga parsowania tekstu.
Jedna rzecz wymaga uwagi przy wdrożeniu. Komunikat błędu trafia do klienta, a ślad stosu w trybie produkcyjnym nie. To dobre zachowanie domyślne, natomiast łatwo je zepsuć, wrzucając do komunikatu treść wyjątku z bazy danych, razem z nazwami tabel i fragmentem zapytania. Komunikaty pisz z myślą o tym, że zobaczy je ktoś obcy.
Osobno warto ustawić formatowanie błędów walidacji, żeby klient dostawał informację o polu, a nie ogólne odrzucenie. Bez tego formularz umie pokazać wyłącznie, że coś jest nie tak, a nie co dokładnie.
Typowe błędy
Pierwszy to import wartości zamiast typu w kodzie klienta. Wciąga to całe zaplecze do pakietu przeglądarki, razem z sterownikiem bazy i kluczami ze zmiennych środowiskowych. Używaj import type i sprawdź rozmiar zbudowanego pakietu.
Drugi to procedura bez walidatora wejścia. Argument jest wtedy nieznanego typu, a dane z zewnątrz trafiają prosto do zapytania. Walidator daje typ i ochronę jednocześnie, więc jego pominięcie kosztuje podwójnie.
Trzeci to jeden ogromny router w jednym pliku. Wnioskowanie typów przy kilkuset procedurach potrafi zauważalnie spowolnić edytor, a przy bardzo dużych projektach dojść do granicy złożoności kompilatora. Dziel na podrouterów według obszaru.
Czwarty to wystawianie tego API na zewnątrz jako publicznego. Nie ma opisu w standardowym formacie ani wersjonowania adresów, więc obcy integrator dostaje interfejs, którego nie da się sensownie udokumentować.
Piąty to trzymanie danych z serwera w magazynie stanu klienckiego równolegle do biblioteki zapytań. Powstaje wtedy druga kopia prawdy i pytanie, która jest aktualna.
Szósty to pominięcie transformatora przy typach, które nie przechodzą przez zapis tekstowy. Data zamienia się w łańcuch znaków, a typ mówi, że to nadal data, więc błąd wychodzi dopiero przy wywołaniu metody.
Siódmy to mylenie bezpieczeństwa typów z bezpieczeństwem aplikacji. Poprawny typ mówi tylko tyle, że dane mają właściwy kształt, i nie mówi nic o tym, czy osoba wywołująca procedurę ma prawo zobaczyć ten rekord. Sprawdzenie uprawnień musi być w warstwie pośredniej albo w samej procedurze, niezależnie od tego, jak dobrze wszystko się kompiluje.
Ósmy to zapominanie o rozbieżności wersji przy osobnym wdrażaniu klienta i serwera. Typy zgadzają się w momencie budowania, natomiast w przeglądarce użytkownika może przez jakiś czas działać starsza wersja klienta rozmawiająca z nowszym serwerem. Zmiany usuwające pola planuj dwuetapowo: najpierw przestań ich używać, potem je usuń.
FAQ
Czy tRPC nadaje się do publicznego API?
Nie. Typy przechodzą przez kompilator TypeScriptu, więc obcy klient w innym języku nie dostaje nic. Do interfejsu dla zewnętrznych odbiorców użyj REST z opisem OpenAPI albo GraphQL, a tRPC zostaw dla własnego frontendu.
Czy tRPC ma sens przy Server Actions?
Zależy od projektu. Przy aplikacji w Next.js, gdzie klientem jest wyłącznie ta sama aplikacja, akcje serwerowe zwykle wystarczą. tRPC wygrywa, gdy masz drugiego klienta, potrzebujesz pełnej kontroli nad buforowaniem albo chcesz zaplecze niezwiązane z frameworkiem.
Czy trzeba używać Zoda?
Nie, obsługiwane są też inne biblioteki walidacyjne. Warto natomiast używać jakiejkolwiek, bo walidator pełni podwójną rolę: sprawdza dane w czasie działania i wyprowadza typ wejścia procedury.
Czy to spowalnia aplikację?
W czasie działania nie, bo typy znikają przy kompilacji, a wywołanie jest zwykłym zapytaniem HTTP. Odczuwalny koszt dotyczy edytora i kompilacji przy bardzo dużej liczbie procedur w jednym routerze.
Jak przejść na nową integrację z TanStack Query?
Stopniowo, bo obie mogą działać obok siebie. Nowa zwraca obiekty opcji podawane do standardowych funkcji biblioteki zapytań, więc migracja polega głównie na zamianie wywołań punktów zaczepienia, ekran po ekranie.
Dokumentacja stoi na stronie projektu, a opis nowej integracji w ogłoszeniu zespołu.