Keamanan Tipe End-to-End dengan openapi-typescript

Foto oleh Dsimic via Wikimedia Commons (CC BY-SA 4.0)
openapi-typescript adalah alat build-time yang membaca spesifikasi OpenAPI dan menghasilkan file tipe TypeScript tanpa kode runtime. openapi-fetch adalah klien runtime, sekitar enam kilobyte, yang mengonsumsi tipe yang dihasilkan itu untuk memberi Anda pembungkus bertipe penuh di atas fetch native. Anda memakainya bersama: satu menghasilkan tipe, yang lain menegakkannya di setiap lokasi pemanggilan.
Tidak. openapi-fetch sekitar enam kilobyte dan merupakan pembungkus tipis di atas fetch native dengan overhead runtime yang praktis nol. Tipe yang dihasilkan openapi-typescript hanya ada di compile-time dan tidak mengirim apa pun ke browser, jadi total biaya runtime hanyalah klien kecil itu sendiri.
Tidak. Tipe yang dihasilkan mendeskripsikan kontrak dalam spesifikasi, bukan byte sebenarnya yang dikirim server. Jika server mengembalikan data yang rusak atau spesifikasinya salah, TypeScript tetap memercayai tipe itu. Untuk endpoint yang tidak tepercaya atau kritis, validasi response saat runtime dengan pustaka seperti Zod sebagai tambahan tipe yang dihasilkan.
Regenerasi tipe di CI dan gagalkan build jika file yang di-commit berbeda dari yang baru dihasilkan. Sebuah git diff dengan exit non-nol pada perubahan apa pun mengubah setiap perubahan kontrak backend yang diam-diam menjadi kegagalan pull-request yang memblokir, itulah yang menjaga frontend dan backend benar-benar sinkron.
Ya. openapi-typescript menerima URL, bukan hanya file lokal, jadi Anda bisa mengarahkannya ke dokumen JSON yang disajikan @nestjs/swagger, misalnya di /api-json. Ini menghapus langkah ekspor manual dan membuat CI meregenerasi langsung dari spesifikasi live yang sudah diterbitkan backend.

Foto oleh Dsimic via Wikimedia Commons (CC BY-SA 4.0)
Ringkasan Utama
Arahkan openapi-typescript ke spesifikasi OpenAPI Anda untuk menghasilkan file tipe, lalu berikan tipe itu ke createClient milik openapi-fetch untuk klien HTTP yang sepenuhnya bertipe. Setiap path, query param, request body, dan response diperiksa oleh compiler, jadi perubahan backend yang merusak kontrak menggagalkan build Anda alih-alih produksi.
Setiap tim TypeScript fullstack pada akhirnya menabrak tembok yang sama: backend mengganti nama sebuah field, merilisnya, dan frontend tetap mengirim bentuk lama karena tidak ada yang memperbarui interface yang ditulis tangan. Compiler hijau, tes lolos, dan bug baru muncul saat pengguna sungguhan memuat halaman. Saya sudah mengejar jenis kegagalan ini lebih sering daripada yang ingin saya akui, dan solusinya bukan disiplin yang lebih ketat. Solusinya adalah menghapus tipe yang ditulis tangan sepenuhnya dan menghasilkannya dari satu artefak yang sudah disepakati kedua sisi: spesifikasi OpenAPI.
Tulisan ini membahas pasangan yang selalu saya gunakan pada setiap frontend Next.js yang berbicara dengan backend NestJS: openapi-typescript untuk mengubah spesifikasi menjadi file tipe, dan openapi-fetch untuk membungkus tipe itu dalam klien kecil tanpa runtime. Hasilnya adalah keamanan tipe end-to-end tanpa generic yang harus ditulis dan tanpa framework codegen yang harus diurus.
Interface TypeScript yang dipelihara tangan untuk sebuah response API adalah salinan. Begitu Anda menulisnya, ia mulai menyimpang dari sumber kebenaran di server. Tidak ada mekanisme yang memaksa keduanya tetap sama, jadi penyimpangan bukan risiko, melainkan kondisi default seiring waktu. Compiler tidak bisa membantu di sini karena ia memvalidasi kode Anda terhadap salinan basi Anda sendiri, bukan terhadap kenyataan.
Tipe yang dihasilkan membalik hubungan ini. Alih-alih salinan yang Anda pelihara, Anda memiliki proyeksi dari spesifikasi yang Anda regenerasi. Ketika backend mengubah kontrak dan menerbitkan ulang spesifikasi, regenerasi menghasilkan file tipe yang berbeda, dan setiap lokasi pemanggilan yang tidak lagi cocok menjadi merah. Warna merah itulah intinya: ketidakcocokan berpindah dari runtime, tempat pengguna menemukannya, ke compile time, tempat Anda menemukannya.
| Aspek | Interface tulis tangan | Dihasilkan dari OpenAPI |
|---|---|---|
| Sumber kebenaran | Salinan di repo frontend | Spesifikasi yang diterbitkan backend |
| Penyimpangan seiring waktu | Pasti, secara diam-diam | Tertangkap saat regenerasi |
| Perubahan yang merusak muncul | Saat runtime, di produksi | Saat compile time, di CI |
| Biaya pemeliharaan | Edit manual per perubahan | Satu perintah regenerasi |
openapi-typescript adalah alat khusus build-time. Ia membaca dokumen OpenAPI 3.x, dari file lokal atau URL, dan menghasilkan satu file .d.ts. Ia tidak menghasilkan kode runtime apa pun, jadi tidak ada yang dihasilkannya masuk ke bundle Anda. Saat tulisan ini dibuat, versi terkini adalah 7.x, dan ia mengharapkan TypeScript 5.0 atau lebih baru untuk hasil terbaik.
# Install both packages
npm i -D openapi-typescript typescript
npm i openapi-fetch
# Generate types from a local spec file
npx openapi-typescript ./openapi.yaml -o ./src/lib/api/schema.d.ts
# Or straight from a running backend that serves its spec
npx openapi-typescript http://localhost:3000/api-json -o ./src/lib/api/schema.d.tsFile yang dihasilkan mengekspor interface paths yang mendeskripsikan setiap route, ditambah components untuk skema bersama. Anda tidak pernah menulisnya dengan tangan dan jarang membacanya langsung. Mereka ada untuk memberi makan klien di langkah berikutnya. Jika backend Anda NestJS, modul @nestjs/swagger dapat menyajikan persis dokumen JSON yang dikonsumsi perintah kedua di atas, yang menutup siklus tanpa langkah ekspor manual apa pun.
openapi-fetch adalah bagian runtime, dan sengaja dibuat sangat kecil, sekitar enam kilobyte dengan overhead yang praktis nol di atas fetch native yang dibungkusnya. Anda memanggil createClient dengan tipe paths yang dihasilkan sebagai argumen tipe, dan sejak saat itu klien mengetahui setiap route yang diekspos API Anda. Tidak ada generic per-panggilan yang harus ditulis dan tidak ada pengetikan manual. String path itu sendiri menggerakkan inferensi untuk params, body, dan response.
// src/lib/api/client.ts
import createClient from "openapi-fetch";
import type { paths } from "./schema";
export const api = createClient<paths>({
baseUrl: "https://api.example.com/v1/",
});
// A GET with a path param and a query param — all typed from the spec
const { data, error } = await api.GET("/blogposts/{post_id}", {
params: {
path: { post_id: "my-post" },
query: { version: 2 },
},
});
// A POST whose body is checked field-by-field
await api.POST("/blogposts", {
body: {
title: "My New Post",
// omitting a required field, or using the wrong type, fails tsc
},
});Autocomplete argumen pertama dan Anda mendapatkan daftar route sungguhan. Salah menulis nama path param, lupa field query yang wajib, atau mengirim number saat spesifikasi meminta string, dan compiler menolaknya sebelum kode berjalan. Inilah imbalan inferensi di atas generic: ergonomi tetap identik dengan fetch biasa, tetapi jaminannya menyeluruh.
Setiap panggilan mengembalikan objek dengan data, error, dan response. Properti data terisi hanya pada status 2xx; error menyimpan body error bertipe untuk response 4xx dan 5xx, berbentuk persis seperti yang dideklarasikan spesifikasi Anda; dan response adalah Response mentah untuk header dan status. Karena data dan error membentuk discriminated union, pemeriksaan truthiness mempersempit tipe, dan compiler memaksa Anda menangani jalur kegagalan sebelum bisa menyentuh payload sukses.
const { data, error } = await api.GET("/blogposts/{post_id}", {
params: { path: { post_id: id } },
});
if (error) {
// error is the typed error schema here — data is undefined
console.error(error);
return;
}
// past the guard, data is defined and fully typed
return data.title;Gunakan middleware alih-alih menulis ulang header di setiap lokasi pemanggilan. openapi-fetch mendukung hook middleware onRequest / onResponse, yang merupakan tempat bersih untuk melampirkan token auth, menyuntikkan request id, atau memusatkan logging. Ini menjaga lokasi pemanggilan tetap fokus pada data dan urusan auth berada tepat di satu file.
Generasi hanya melindungi Anda jika benar-benar dijalankan. Mode kegagalan yang saya jaga adalah schema.d.ts basi yang di-commit berbulan-bulan lalu sementara API sudah berubah. Aturan saya pada layanan ter-deploy di VPS yang saya jalankan sederhana: spesifikasi diregenerasi di CI, dan jika file hasil regenerasi berbeda dari yang di-commit, build gagal. Satu pemeriksaan itu mengubah setiap perubahan backend yang diam-diam menjadi sinyal keras yang memblokir pada pull request.
# package.json scripts
# "gen:api": "openapi-typescript http://localhost:3000/api-json -o ./src/lib/api/schema.d.ts"
# "check:types": "tsc --noEmit"
# CI step: regenerate, then fail if the committed file drifted
npm run gen:api
git diff --exit-code src/lib/api/schema.d.ts \
|| (echo "API types are stale — run npm run gen:api and commit" && exit 1)
# Then the usual gate
npm run check:typesTipe yang dihasilkan mendeskripsikan kontrak, bukan payload runtime. Jika server mengirim JSON yang rusak atau spesifikasinya bohong, TypeScript tetap memercayai tipe itu dan Anda mendapatkan objek yang mengklaim valid padahal tidak. Untuk batas yang tidak tepercaya atau kritis, validasi response saat runtime dengan pustaka skema seperti Zod. Tipe yang dihasilkan menghentikan penyimpangan di compile time; mereka tidak memverifikasi apa yang sebenarnya tiba melalui jaringan.
Jika Anda memiliki atau bisa memperoleh spesifikasi OpenAPI yang akurat, ini nyaris merupakan setup dengan usaha terendah dan imbalan tertinggi yang tersedia di ekosistem TypeScript. Ia unggul saat Anda menginginkan semantik fetch biasa tanpa keterikatan framework dan bundle yang bisa diukur dalam kilobyte. Jika sebaliknya Anda menginginkan hook TanStack Query yang dihasilkan atau SDK yang lebih lengkap dengan metode per operasi, generator yang lebih berat seperti Orval lebih cocok. Namun untuk kasus umum, frontend memanggil backend yang terdokumentasi, tipe yang dihasilkan ditambah klien bertipe tipis menghapus seluruh kategori bug dengan biaya satu langkah CI.