Mein Backend bedient drei Anwendungen: ein Admin-Interface, ein Web-Frontend und eine Mobile-App. Zwei davon sind mit Nuxt gebaut, eine mit React Native. Daraus folgt eine Frage, die banal klingt und trotzdem teuer ist: Woher wissen diese drei eigentlich, wie die API aussieht?
Also: Welche Felder gibt es? Welche dürfen null sein? Was kommt bei einer Liste zurück, was bei einem Einzelobjekt?
Die übliche Antwort lautet: Man schreibt es auf. Genau da fängt das Problem an.
Denn eine Beschreibung, die man von Hand pflegt, ist Dokumentation. Und Dokumentation veraltet. Nicht weil jemand schlampt, sondern weil sie eine zweite Quelle neben dem Code ist – und niemand hält zwei Quellen dauerhaft gleich. Eine Beschreibung dagegen, die aus dem Code selbst entsteht und deren Abweichung den Build rot macht, ist etwas ganz anderes: eine Garantie.
Vorher: jeder schreibt die Typen ab
Der Ausgangszustand war völlig normal. Für jedes Feature eine eigene API-Datei im Frontend, dazu von Hand gepflegte Typen, die es im Backend nochmal gab.
// von Hand gepflegt, parallel zum Backend
export interface Article {
id: string
title: string
publishedAt: string | null
author: Author
}
export async function fetchArticles(page: number): Promise<Paginated<Article>> {
const res = await fetch(`/api/articles?page=${page}`)
return res.json()
}
Das ist kein Strohmann. So sieht es in den meisten Projekten aus, und es funktioniert auch – bis es das nicht mehr tut.
Der Haken: Diese Typen sind ein Versprechen, das niemand prüft. Ich behaupte im Frontend, dass publishedAt auch null sein kann. Ob das Backend das genauso sieht, weiß ich nicht. Wird aus einem optionalen Feld im Backend ein Pflichtfeld, merkt das im Frontend niemand – dort steht ja weiterhin das Fragezeichen. TypeScript prüft alles, außer der einen Stelle, auf die es ankommt: dort, wo die Daten tatsächlich über das Netz hereinkommen.
Und weil jede der drei Anwendungen ihre eigene Abschrift pflegt, gibt es denselben Pagination-Typ am Ende dreimal, in leicht unterschiedlichen Varianten.
Warum ich die Typen nicht einfach importiere
Der naheliegende Ausweg: Wenn Backend und Frontend im selben Repository liegen, importiert man die Typen doch einfach direkt aus dem Backend. Für AdonisJS gibt es dafür sogar ein fertiges Paket. Das liegt bei mir im Projekt und wird auch benutzt – meine Tests bekommen darüber typisierte Routen-Namen.
Für den Weg zu den Clients trägt es trotzdem nicht. Aus zwei Gründen.
Erstens ist so ein Import eine feste Verbindung zwischen zwei Projekten. Der Client muss den Backend-Quellcode lesen und auflösen können. Solange beide Seiten dieselbe TypeScript-Welt teilen, geht das gut. Die Mobile-App tut das nicht: anderer Bundler, andere Build-Pipeline, andere Toolchain. Genau dort wird die Verbindung unbequem.
Zweitens verschwinden TypeScript-Typen beim Kompilieren. Sie existieren zur Laufzeit nicht mehr. Aus einem Typ kann ich also nichts erzeugen, was zur Laufzeit gebraucht wird – keine Validierung, keine fertigen Datenabruf-Funktionen für zwei verschiedene Frontend-Welten.
Ich brauchte etwas, das nicht an TypeScript hängt: eine schlichte Datei, die die API beschreibt, sprachunabhängig, und aus der man beliebig viel generieren kann. Das ist OpenAPI.
Die Beschreibung schreibt sich selbst
Diese Datei liegt bei mir im Repository. Aber niemand tippt sie. Ein eigener Befehl erzeugt sie aus dem, was ohnehin schon da ist.
Für die eingehende Seite ist das leicht. Es gibt bereits Validatoren, die jeden Request prüfen, bevor er verarbeitet wird. Der Code, der falsche Eingaben ablehnt, weiß damit auch, welche Eingaben richtig sind. Er wird ausgelesen und in Beschreibungen übersetzt.
Die ausgehende Seite ist der interessantere Teil. Bei mir baut je eine Klasse aus einem Datenbank-Eintrag die fertige API-Antwort – ein Transformer. Und weil dessen Methode einen ausgeschriebenen Rückgabetyp hat, steht dort bereits, wie die Antwort aussieht. Ein Werkzeug namens ts-morph liest den Code so, wie der TypeScript-Compiler ihn liest, und übersetzt diesen Rückgabetyp in ein Schema.
Aus dieser Methode:
export class ArticleTransformer {
toDetail(article: Article): ArticleDetailResponse {
return {
id: article.id,
title: article.title,
publishedAt: article.publishedAt?.toISO() ?? null,
author: this.authors.toSummary(article.author),
}
}
}
wird automatisch diese Beschreibung:
ArticleDetailResponse:
type: object
required: [id, title, publishedAt, author]
properties:
id: { type: string }
title: { type: string }
publishedAt:
type: [string, 'null']
author:
$ref: '#/components/schemas/AuthorSummary'
Der Code, der die Antwort baut, beschreibt sie also auch. Das ist der ganze Trick.
Vier Formen kennt der Generator: Einzelobjekt, Liste, und zwei Arten von Paginierung. Trifft er auf etwas, das er nicht kennt, bricht er mit einem Fehler ab. Er schreibt dann bewusst kein leeres Schema – denn ein leeres Schema wäre eine Lüge, die niemandem auffällt.
Eine Nebenwirkung gab es, die ich vorher nicht bedacht hatte. Damit die Typen ausgelesen werden können, muss jede Transformer-Methode ihren Rückgabetyp ausschreiben. Automatisch erkannte Typen reichen nicht. Heute erzwingt eine Lint-Regel genau das.
Der Generator diktiert also einen Code-Stil zurück ins Backend. Das ist verkraftbar. Aber es ist eine Nebenwirkung, und ich sage sie lieber vorher.
Das Wichtigste ist, dass es laut kaputtgeht
Fast alles, was man online zu diesem Thema findet, endet bei „ich generiere einen Client aus einer Swagger-URL". Das ist der langweilige Teil. Interessant wird es bei der Frage, wodurch das Ganze rot wird.
Und da gibt es zwei völlig verschiedene Fälle, die man nicht verwechseln sollte.
Fall 1: Jemand hat vergessen, neu zu generieren
Der einfache Fall. Die CI erzeugt die Beschreibung neu, erzeugt den Client neu, und vergleicht beides mit dem, was eingecheckt ist:
node ace openapi:generate
pnpm api-client:generate
git diff --exit-code openapi.yaml packages/api-sdk/src
Drei Zeilen, die aussehen wie Hygiene. Der Fall, für den sie da sind, sieht so aus: Zwei Feature-Branches ändern jeweils die API. Beide sind für sich genommen grün, beide haben brav neu generiert. Nach dem Zusammenführen ist der generierte Client trotzdem veraltet – er kennt ja nur die Änderung aus einem der beiden Branches.
Kein Typecheck der Welt findet das. Der generierte Code ist in sich völlig stimmig. Er ist nur nicht mehr die Wahrheit. Auffallen kann das nur, wenn man ihn gegen eine frisch erzeugte Version hält.
Genau dieser Fall ist mir passiert. Er ist der Grund, warum ich diesen Vergleich inzwischen für wichtiger halte als den Generator selbst.
Fall 2: Die Beschreibung lügt
Der unangenehme Fall. Eine Beschreibung, die aus Typen entsteht, beweist nur eines: dass der Code zu sich selbst passt. Sie beweist nicht, dass die tatsächlich ausgelieferte Antwort dazu passt.
Deshalb gibt es eine zweite Sorte Tests. Sie rufen echte Endpunkte auf und prüfen die echte Antwort gegen die Beschreibung.
Der Fund, der mir das bewiesen hat, war ein simpler Zähler. Der Wert kam aus einem SQL-COUNT. PostgreSQL gibt so etwas als Text zurück, nicht als Zahl. Im Code stand trotzdem number:
toStats(row: StatsRow): ArticleStatsResponse {
return { articleCount: row.count }
}
Und über die Leitung ging das hier:
{ "articleCount": "42" }
Der Typecheck war grün. Der Generator war zufrieden. Die Beschreibung sagte „Zahl", geliefert wurde ein Text in Anführungszeichen.
Hier log der Typ selbst. Und deshalb konnte kein Werkzeug, das Typen liest, den Fehler jemals finden – der Generator hat die falsche Angabe brav übernommen. Rot wurde erst der Test, der die echte Antwort gegen das Schema gehalten hat.
In derselben Runde fiel noch ein Paginierungs-Typ auf, der nicht zur echten Antwort passte. Später kamen Endpunkte dazu, die gar keine ausgeschriebene Antwort hatten und deren Schema entsprechend geraten war.
Ein dritter Fund hat mein Bild davon verändert, wo man Fehler eigentlich repariert. Im Admin standen an einigen Stellen Casts – also ein as im Code, mit dem man dem Compiler sagt: „Glaub mir, der Typ stimmt schon." Der Reflex ist, das dort zu belassen und weiterzumachen. Tatsächlich markierte jeder dieser Casts eine Lücke in der API-Beschreibung. Als die Lücken geschlossen wurden, verschwanden die Casts von selbst.
Die Casts waren das Symptom, die Lücke war die Ursache.
Was die drei Anwendungen davon haben
Aus dieser einen Datei erzeugt @hey-api/openapi-ts alles Weitere: fertige, typisierte Funktionen für jeden Endpunkt, einen Client zum Abschicken, passende Datenabruf-Funktionen für beide Frontend-Welten – Vue für Web und Admin, React für Mobile – und Validierungs-Schemas.
Das landet alles in einem gemeinsamen Paket, aus dem sich jede App nur das holt, was sie braucht. Im Frontend sieht das dann so aus:
import { useQuery } from '@tanstack/vue-query'
import { getArticlesOptions } from '@myapp/api-sdk/tanstack/vue-query'
const { data } = useQuery(getArticlesOptions({ query: { page: 1 } }))
Keine Zeile davon habe ich geschrieben, und keine wird veralten, ohne dass es jemand merkt.
Die Zahl, die das für mich greifbar gemacht hat: Als ich die Datenschicht des Admin umgestellt habe, fielen die Casts von 21 auf 2. Die 2 sind mir dabei wichtiger als die 21. Es geht eben nicht auf null, und das ist auch richtig so – an einer Systemgrenze bleibt manchmal ein bewusstes Zurechtbiegen übrig. Wer null verspricht, hat die Casts nur woanders versteckt.
Bei der Validierung muss ich ehrlich bleiben, weil die naheliegende Erzählung hier falsch wäre. Generiert werden 385 Schemas. Tatsächlich benutzt werden davon nur die für ausgehende Daten – für Formulare, teils erweitert um Regeln, die die API gar nicht kennen kann:
import { zCreateArticleBody } from '@myapp/api-sdk/zod'
// Die API kennt diese Regel nicht – die Oberfläche schon
const formSchema = zCreateArticleBody.extend({
title: zCreateArticleBody.shape.title.max(80, 'Passt sonst nicht in die Vorschau'),
})
Die Prüfung eingehender Antworten liegt fertig generiert im Repository, ist aber nicht eingeschaltet. Der Satz „Typen gelten nur beim Kompilieren, das Netzwerk nicht" stimmt weiterhin. Ich löse ihn im Moment nur auf einer Seite ein.
Ob sich das lohnt, hängt an einer Zahl
Der Aufwand für so ein Setup ist fast vollständig fix. Ob die API zehn oder hundert Endpunkte hat, spielt kaum eine Rolle – der Generator muss einmal gebaut und danach gepflegt werden. Der Nutzen dagegen wächst mit jeder Anwendung, die die API benutzt.
Die Zahl, an der es hängt, ist die der Anwendungen – nicht die der Entwickler. Bei einem einzigen Client lohnt sich das vermutlich nicht. Da genügt der direkte Import der Typen aus dem Backend, und wenn etwas nicht zusammenpasst, fällt es beim nächsten Build auf.
Ich bin in diesem Projekt übrigens der einzige Entwickler. Gerade deshalb trägt das Setup: Es gibt niemanden, dem eine Abweichung zufällig auffällt. Und drei Clients gleichzeitig im Kopf zu behalten funktioniert genau so lange, wie man ununterbrochen an allen dreien arbeitet.
Der Kipppunkt war für mich der dritte Client – und die Tatsache, dass einer davon die gemeinsame TypeScript-Welt verlässt. Beim Ausrollen auf alle Features wuchs die Beschreibung von 30 auf 62 Endpunkte. Das von Hand nachzuziehen wäre eine Vollzeitbeschäftigung gewesen.
Zwei Dinge sollte man trotzdem vorher wissen, weil sie in keinem Tutorial stehen.
Der Generator ist selbst Code. Er kennt genau die Antwortformen, die in diesem Projekt vorkommen. Jede neue Form braucht eine Erweiterung. Das ist Infrastruktur, die gewartet werden will und die kein Kunde je zu Gesicht bekommt.
Er wirkt auf den Code-Stil zurück. Die erzwungenen Rückgabetypen sind ein kleiner Preis, aber sie sind einer: eine Regel, die mit dem eigentlichen Feature nichts zu tun hat und die man jedem neuen Teammitglied erklären muss.
Beides ist verkraftbar. Es ist aber der Unterschied zwischen „ich generiere mir einen Client" und „ich betreibe eine Generierung".
Was ich mitgenommen habe, ist weniger eine Technologie-Empfehlung als eine andere Denkweise. Früher habe ich versucht, die API-Beschreibung zu pflegen: sorgfältig, diszipliniert, mit guten Vorsätzen. Das funktioniert eine Weile und scheitert dann an einem Dienstagnachmittag, an dem drei Dinge gleichzeitig dringend sind.
Heute pflege ich sie nicht mehr. Ich lasse sie ableiten und sorge dafür, dass jede Abweichung laut wird. Der Unterschied ist nicht Sorgfalt. Der Unterschied ist, dass Sorgfalt nicht mehr nötig ist.
Ein Contract ist kein Dokument, das man pflegt. Er ist eine Eigenschaft, die man erzwingt.
