Startseite / Artikel

API-Vertragsabweichungen zur Kompilierzeit durch schrittweises tRPC-Einführen erkennen

tRPC verwandelt ein umbenanntes Backend-Feld in einen TypeScript-Kompilierfehler. Wie kann man es neben REST Schritt für Schritt an den Endpunkten einführen – und wo ist es das falsche Werkzeug.

Verlag: REACTAPP.TOP7 Min. Lesezeit1681 Wörter

Quellen: **I Switched From REST to tRPC. My Team Almost Revolted. Then They Loved It.**

Stellen Sie sich eine Kreditplattform vor, bei der ein Backend-Entwickler in der Antwort auf die Kreditantragstellung applicantName in applicantFullName umbennt. Die API, die Backend-Tests sowie der Pull Request werden alle aktualisiert und freigegeben, doch zwei Tage später zeigt das Kreditzusammenfassungsfenster stillschweigend leere Namen an: keine Ausnahme, kein Warnhinweis in der Konsole, einfach nur ein leeres String – weil die Frontend-Anwendung weiterhin einen Schlüssel liest, der nicht mehr existiert. Sowohl die Backend- als auch die Frontend-Teile waren in TypeScript geschrieben, und keine dieser Typinformationen überquerte die Netzwerkgrenze. Unten sehen Sie, wie tRPC diese Lücke schließt, wie man es schrittweise neben REST einsetzen kann und wann es nicht geeignet ist.

Was tRPC ist und was es nicht ist

tRPC ist kein Übertragungsprotokoll und ersetzt REST oder GraphQL nicht auf der Transportebene. Die Anfragen bleiben weiterhin gewöhnliche HTTP-Aufrufe. Was es hinzufügt, ist eine gemeinsame Typschicht: Die Rückgabetypen der Serverprozeduren werden von TypeScript erkannt und direkt an den Client übermittelt, ohne dass dabei ein Schema-Datei oder eine Codegenerierungsstufe erforderlich ist.

Ein nützliches Gedankenmodell ist ein Funktionsaufruf, der zufällig auf dem Server ausgeführt wird. Der Vertrag zwischen Aufrufer und Aufgerufener ist das, was der TypeScript-Kompilator vorgibt – nicht ein separates Spezifikationsdokument, das möglicherweise aktualisiert wurde oder auch nicht. Wenn der Server ändert, was eine Prozedur zurückgibt, versagen alle Client-Aufrufe, die von der alten Struktur abhängen, beim Kompilieren. Der Fehler tritt bereits im Editor in der genaueren Zeile auf, an der das fehlende Feld angegeben wird, und nicht erst Tage später in der Produktion.

Der Preis einer untypisierten API-Schicht

In dem oben beschriebenen Szenario sah die Frontend-Service-Schicht ungefähr so aus: Jede Methode lud JSON herunter und gab es als any zurück, wobei die Komponenten die Felder des Ergebnisses ohne Überprüfung verwendeten.

// services/api.service.ts
async getLoanApplication(id: string): Promise<any> {
const res = await this.http.get(`/api/loans/${id}`).toPromise();
return res;
}
// component
const loan = await this.apiService.getLoanApplication(loanId);
console.log(loan.applicantName); // works until it doesn't

Das Team zählte 47 Methoden mit einem Rückgabetyp von any. Jede dieser Methoden stellte einen Punkt dar, an dem das Umbenennen eines Feldes, die Umstrukturierung eines verschachtelten Objekts oder die Änderung eines Enum-Werts problemlos kompiliert werden würde, aber erst zur Laufzeit zu Fehlern führen würde – insbesondere dann, wenn ein Nutzer den Status einer Anwendung überprüfte. Bei einem Finanzprodukt ist das mehr als nur ein kosmetischer Fehler: Das Anzeigen falscher oder fehlender Daten an einen Kunden kann zu Compliance-Problemen führen.

Migration mit dem Strangler Fig-Muster

Das gleichzeitige Umschreiben aller Endpunkte ist riskant. Der sichere Ansatz ist der „Strangler Fig“-Ansatz: Die bestehenden REST-Endpunkte bleiben unberührt, tRPC wird daneben eingebunden, und anschließend werden die Endpunkte nacheinander umgestellt. Jeder migrierte Endpunkt erhält sofort Typsicherheit, und das, was noch nicht migriert wurde, muss sich nicht ändern.

Definieren des Routers

Auf der Serverseite beginnt alles mit initTRPC.create(), welches ein Konstruktorobjekt zurückgibt, das üblicherweise t genannt wird. Router können verschachtelt werden, wodurch ein loans-Subrouter die Kreditverfahren gruppiert. Beachten Sie drei Aspekte in diesem Auszug. Erstens deklariert jedes Verfahren seine Eingaben mithilfe eines Zod-Schemas, sodass fehlerhafte IDs oder unbekannte Statuswerte bereits zur Laufzeit, bevor der Handler ausgeführt wird, abgelehnt werden. Das gleiche Schema stellt dem Handler außerdem eine typisierte input-Variable zur Verfügung. Zweitens wird query für Lesevorgänge und mutation für Schreibvorgänge verwendet. Drittens muss niemand den Rückgabetyp manuell angeben: TypeScript schließt ihn aus dem Ergebnis der Datenbankanfrage ab. Die letzte Zeile exportiert lediglich den Typ des Routers, den der Client verwendet.

// server/router/index.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
loans: t.router({
getById: t.procedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input }) => {
const loan = await db.loans.findUnique({ where: { id: input.id } });
if (!loan) throw new TRPCError({ code: 'NOT_FOUND' });
return loan; // TypeScript infers the return type from the DB model
}),
updateStatus: t.procedure
.input(z.object({
id: z.string().uuid(),
status: z.enum(['PENDING', 'APPROVED', 'REJECTED', 'DISBURSED'])
}))
.mutation(async ({ input }) => {
return db.loans.update({
where: { id: input.id },
data: { status: input.status }
});
}),
}),
});
export type AppRouter = typeof appRouter;

Ein Detail, das korrigiert werden muss, wenn man dies kopiert: Der Handler wirft TRPCError aus, der ebenfalls aus @trpc/server importiert werden muss. Er fehlt in der Importzeile hier.

Erstellen des Clients

Auf der Frontend-Seite wird der Client aus dem Typ AppRouter erstellt. Da der Import import type verwendet, gelangt kein Server-Code in den Browser-Bundle; nur die Typinformationen werden geteilt. httpBatchLink kombiniert mehrere im selben Tick ausgeführte Aufrufe in eine einzige HTTP-Anfrage, und sein headers-Callback wird pro Anfrage ausgeführt – das ist ein geeigneter Ort, um einenBearer-Token hinzuzufügen.

// lib/trpc.ts
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../../server/router';
export const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: '/api/trpc',
headers() {
return {
Authorization: `Bearer ${getToken()}`,
};
},
}),
],
});

Der Name der Factory spiegelt die verwendete tRPC-Version wider: createTRPCProxyClient gehört zur v10-API, während neuere Versionen diesen Namen durch createTRPCClient ersetzen. Prüfen Sie die aktuellen Dokumentationen für die von Ihnen installierte Version. Die gemeinsame Verwendung des Typs setzt außerdem voraus, dass der Client diesen importieren kann – in der Regel über ein Monorepo oder ein gemeinsames Workspace-Paket.

Aufruf einer Prozedur

Ein Aufruf sieht nun aus wie ein Methodenaufruf auf einem lokalen Objekt. Das Ergebnis ist vollständig typisiert, wodurch der Zugriff auf ein Feld, das der Server nicht mehr zurückgibt, zu einem Kompilierfehler führt anstelle eines stillschweigenden undefined-Werts.

// In a component or service
const loan = await trpc.loans.getById.query({ id: loanId });
// loan is fully typed - TypeScript knows every field
console.log(loan.applicantFullName); // if this field doesn't exist, compile error

Antworten auf die Einwände des Teams

Rechnen Sie mit Widerstand. In der Regel tauchen drei Einwände auf, und jeder verdient eine klare Antwort.

  • Warum nicht OpenAPI mit Code-Generierung? Das ist der stärkste Einwand, denn die generierten Clients liefern tatsächlich Typen. Der Preis dafür ist ein Schema, das gepflegt werden muss, ein Generierungsschritt, der ausgeführt werden muss, generierte Dateien, die gespeichert werden müssen, sowie Disziplin, um alles synchron zu halten. Bei tRPC ist der Servercode selbst die einzige Quelle der Wahrheit. Wenn Sie den OpenAPI-Weg bevorzugen, erfahren Sie, wie man einen typisierten Next.js-Client aus NestJS Swagger generiert.
  • Was ist mit Verbrauchern, die nicht in TypeScript geschrieben sind? In diesem Fall rufen einige interne Python-Tools die API auf. Ihre Endpunkte bleiben einfach bei REST. Eine schrittweise Migration ist dadurch unkompliziert, da beide Ansätze nebeneinander existieren können.
  • Lohnt sich die Lernkurve? Router, Verfahren sowie die Eingabeschemata von Zod stellen ein neues Vokabular dar. Eine 30-minütige Einführung zusammen mit einem kurzen internen Leitfaden reichten aus, um das Team produktiv zu machen; es gibt nur wenige Konzepte, doch diese erste Sitzung ist entscheidend.
  • Wenn der Nutzen offensichtlich wird

    Etwa nach sechs Wochen erforderte die Zustandsmaschine für die Kreditanträge eine strukturelle Änderung. Ein einfaches Statusfeld wurde zu einem verschachtelten Objekt, das sowohl die Überprüfungsstufe als auch das Ergebnis der Genehmigung erfassen kann:

    // Before
    type LoanApplication = {
    status: 'PENDING' | 'APPROVED' | 'REJECTED';
    }
    // After
    type LoanApplication = {
    applicationStatus: {
    stage: 'DOCUMENT_COLLECTION' | 'CREDIT_REVIEW' | 'FINAL_APPROVAL';
    approvalStatus: 'PENDING' | 'APPROVED' | 'REJECTED' | null;
    };
    }
    

    Unter der alten Konfiguration hätte jedes Komponente, das application.status las, weiterhin kompiliert, wäre aber zur Laufzeit fehlerhaft gewesen – die einzige Abwehrmaßnahme wäre eine gesamte Codebasis umfassende Suche gewesen, gefolgt von Hoffnung. Mit tRPC wies die Änderung des Rückgabetyps sofort 8 Aufrufstellen im Frontend aus. Alle wurden bereits am Nachmittag behoben, und eine Änderung, die mehrere Systeme betraf, wurde ohne Laufzeitfehler bereitgestellt. Danach verschob sich das Gespräch von der Kritik an der Migration hin zur Frage, welcher Endpunkt als Nächstes übertragen werden sollte.

    Ergebnisse nach sechs Monaten

    • Die Anzahl der any-Rückgabetypen in der API-Schicht sank von 47 auf 3.
    • Die verbleibenden 3 sind Payloads von Drittanbieter-Webhooks ohne definierte Typen; sie werden durch validierte Zod-Schemata analysiert, anstatt blind vertraut zu werden.
  • Sie seit dem Beginn der Migration keine Fehler aufgrund von Typunstimmungen bei laufendem Betrieb gezeigt.
  • 11 Typfehler konnten bereits in der Kompilierphase erkannt werden, die sonst in die Produktion gelangt wären.
  • Die 44 entfernten any-Typen stehen für versteckte Fehler, die nie die Gelegenheit hatten, aufzutreten. Um auch die letzten verbleibenden Probleme zu beseitigen, siehe sechs typsichere Ersatzlösungen für any.

    Wo tRPC nicht geeignet ist

    tRPC lohnt sich nur, wenn TypeScript an beiden Enden der Verbindung verwendet wird. Ein mehrsprachiger Backend oder eine öffentliche API, die von Dritten genutzt wird, benötigt einen expliziten, sprachunabhängigen Vertrag wie OpenAPI oder GraphQL.

    Bedenken Sie auch, dass die Garantie für die Ausgaben eine Compile-Zeit-Garantie ist. Zod validiert die Eingaben zur Laufzeit, doch der abgeleitete Rückgabetyp überprüft zur Laufzeit nichts. Wenn wie im obigen Router ein rohes Datenbankrecord zurückgegeben wird, werden alle Spalten an den Client gesendet. Für sensible Domänen sollten Sie nur die Felder auswählen oder zuweisen, die tatsächlich offengelegt werden sollen.

    Haupterkenntnisse

    • Die Verwendung von TypeScript auf beiden Seiten bedeutet nicht automatisch das Vorhandensein eines typisierten Vertrags zwischen ihnen; tRPC schafft diese fehlende Verbindung ohne Schema oder Codegenerierungs-Schritt.
    • Migrieren Sie Schritt für Schritt neben REST weiter, damit Nutzer ohne TypeScript weiterarbeiten können und jeder Schritt klein genug ist, um ihn zu überprüfen.
    • Der größte Vorteil zeigt sich bei Refaktorierungen, wenn der Compiler Ihnen alle betroffenen Aufrufstellen auflistet.
  • Wenn Daten Geld und Kundendaten darstellen, ist dieses Niveau an Typsicherheit eine Grundvoraussetzung und kein Luxus, und tRPC macht es ohne umfangreichen Boilerplate möglich.