Arsitektur Tipe

Memisahkan tipe API publik dari tipe internal dan memahami maksud versioning.

Daftar isi

Kontrak sebuah pustaka terletak pada tipenya, dan kontrak yang tercampur dengan detail internal membuat perubahan kecil terasa berisiko. Topik ini memisahkan tipe API publik dari tipe internal pada pustaka-waktu, menetapkan aturan permukaan publik yang seminimal mungkin, dan memaknai versioning semantik sebagai janji atas tipe. Pemisahan itu kemudian diuji dua arah, yaitu pemanggil yang sah tetap berjalan dan pemanggil yang melanggar kontrak ditolak compiler. Setelah topik ini selesai, batas antara yang boleh berubah dan yang wajib dijaga sudah tertulis pada folder pustaka.

Tipe publik dan tipe internal#

Tipe publik adalah tipe yang diekspor dan dipakai orang lain, sedangkan tipe internal adalah tipe yang hanya dipakai di dalam pustaka. Perbedaan posisinya melahirkan aturan yang berbeda. Tipe publik berupa janji, yaitu bentuk yang diandalkan pemanggil luar, sehingga perubahannya harus diumumkan melalui versi. Tipe internal berupa detail, yaitu bagian yang bebas disunting selama perilaku luar tidak berubah.

Aturannya, permukaan publik dibuat seminimal mungkin. Hanya fungsi dan tipe yang benar-benar dipakai pihak luar yang diekspor, sedangkan fungsi pembantu dan tipe pendukung tetap tidak diekspor. Permukaan yang kecil membuat janji yang dijaga juga sedikit, sehingga kebebasan menyunting bagian dalam tetap lebar. Kebiasaan sebaliknya, yaitu mengekspor segalanya agar mudah dipakai, membuat hampir setiap suntingan kecil terasa seperti perubahan besar.

Pengujian dua arah itu juga berlaku pada berkas uji. Berkas uji adalah pemanggil internal, tetapi ia menegakkan kontrak yang sama seperti pemanggil luar. Ketika berkas uji mengirim nilai yang melanggar kontrak, compiler menolaknya dengan pesan yang sama persis seperti yang akan diterima konsumen pustaka.

Berkas kontrak src/tipe-publik.ts#

Folder src kini memuat berkas khusus untuk kontrak, yaitu src/tipe-publik.ts. Isinya berupa satu union literal untuk satuan durasi dan satu interface untuk opsi pemformatan. Union literal dipilih karena himpunan satuannya tertutup, sedangkan interface dipilih karena bentuk opsi berpeluang bertambah pada versi berikutnya. Keduanya diekspor karena memang dikonsumsi luar, dan berkas ini menjadi satu-satunya tempat bentuk kontrak dinyatakan.

ts pustaka-waktu/src/tipe-publik.ts
export type SatuanDurasi = "menit" | "jam";

export interface OpsiFormatDurasi {
  satuan: SatuanDurasi;
  angkaDesimal: number;
}

Kedua modul pustaka mengimpor kontrak dari berkas ini, sehingga tidak ada bentuk kontrak yang ditulis dua kali. src/format.ts menambahkan parameter opsional bertipe OpsiFormatDurasi pada formatDurasi, sehingga pemanggil lama tanpa opsi tetap berjalan dan pemanggil baru dapat memilih satuan beserta jumlah angka desimalnya. Akses terhadap field satuan dan angkaDesimal diperiksa pada cabang yang menjamin opsinya ada, sehingga tidak ada akses yang berada di luar jaminan tipe. Fungsi formatJamDesimal dilepas karena tugasnya kini dijalankan cabang opsi, yaitu formatDurasi dengan satuan jam dan jumlah desimal yang dapat diatur.

ts pustaka-waktu/src/format.ts
// tidak-dijalankan
import { type OpsiFormatDurasi } from "./tipe-publik.ts";

export function formatDurasi(menit: number, opsi?: OpsiFormatDurasi): string {
  const jam = Math.floor(menit / 60);
  const sisa = menit % 60;
  if (opsi === undefined) {
    if (jam === 0) {
      return `${sisa} menit`;
    }
    if (sisa === 0) {
      return `${jam} jam`;
    }
    return `${jam} jam ${sisa} menit`;
  }
  if (opsi.satuan === "jam") {
    return `${(menit / 60).toFixed(opsi.angkaDesimal)} jam`;
  }
  return `${menit} menit`;
}

src/konversi.ts menambahkan fungsi keMenit yang menerima nilai durasi beserta satuannya lalu menormalkannya menjadi menit. Parameter dari bertipe SatuanDurasi, sehingga pemanggil hanya dapat mengirim “menit” atau “jam”. Nilai lain seperti “detik” ditolak compiler pada titik pemanggilannya, sebelum fungsi itu sempat dijalankan.

ts pustaka-waktu/src/konversi.ts
// tidak-dijalankan
import { type SatuanDurasi } from "./tipe-publik.ts";

export function menitKeJam(menit: number): number {
  return menit / 60;
}

export function jamKeMenit(jam: number): number {
  return jam * 60;
}

export function keMenit(nilai: number, dari: SatuanDurasi): number {
  if (dari === "jam") {
    return nilai * 60;
  }
  return nilai;
}

Versioning sebagai kontrak#

Versioning semantik menomori rilis sebagai mayor.minor.patch, dan setiap segmen menjawab satu pertanyaan tentang tipe publik. Perubahan yang mematahkan pemanggil menaikkan mayor, perubahan yang menambah tanpa mematahkan menaikkan minor, dan perbaikan yang tidak menyentuh kontrak menaikkan patch. Pada pustaka bertipe, mematahkan berarti mematahkan kompilasi, karena pemanggil yang mengirim nilai salah ditolak compiler sebelum program berjalan. Kaitan tersebut menjadikan daftar tipe publik sebagai daftar janji yang paling mudah diaudit.

Tabel berikut menerjemahkan aturan itu ke perubahan yang mungkin terjadi pada pustaka-waktu.

Perubahan pada tipe publikAkibat pada pemanggilSegmen versi
Menghapus fungsi atau tipe yang dieksporKode pemanggil berhenti dikompilasimayor
Mengubah field opsional menjadi wajibPemanggilan lama ditolak compilermayor
Menambah field opsional pada tipe opsiPemanggil lama tetap sahminor
Menyunting tipe internal yang tidak dieksporTidak terlihat oleh pemanggilpatch

Penambahan parameter opsional pada formatDurasi mengikuti baris minor pada tabel karena pemanggil lama tanpa opsi tetap sah. Seandainya rilis berikutnya menjadikan opsi wajib atau menghapus field angkaDesimal, perubahan itu menuntut nomor mayor baru. Penilaian seperti ini dilakukan pada setiap suntingan tipe publik, sebelum rilis diterbitkan, bukan sesudah keluhan pemanggil masuk.

Menjaga kontrak dengan compiler#

Compiler menegakkan kontrak pada setiap pemanggilan, termasuk pemanggilan dari berkas uji milik pustaka sendiri. Berkas berikut sengaja mengirim satuan “detik” yang tidak ada pada SatuanDurasi, seolah-olah seorang pengembang lupa membaca kontraknya. Karena berkas ini mengimpor modul lokal, eksekusinya ditunjukkan melalui perintah compiler di bawahnya.

ts pustaka-waktu/uji-kontrak-salah.ts
// tidak-dijalankan
import { formatDurasi } from "./src/format.ts";

console.log(formatDurasi(90, { satuan: "detik", angkaDesimal: 2 }));
bash
# memeriksa berkas pemanggil yang melanggar kontrak publik
tsc --noEmit --pretty false --allowImportingTsExtensions pustaka-waktu/uji-kontrak-salah.ts
text Keluaran
pustaka-waktu/uji-kontrak-salah.ts(4,32): error TS2322: Type '"detik"' is not assignable to type 'SatuanDurasi'.

Pesan error tersebut menunjuk posisi persis nilai yang melanggar, yaitu kata “detik” pada berkas uji, dan menutup celah agar nilai itu tidak pernah sampai ke program yang berjalan. Kontrak yang tertulis pada tipe dengan demikian aktif, bukan sekadar dokumentasi. Bentuk pemeriksaan yang sama akan dijalankan pada gerbang CI sebagai gerbang typecheck.

Pemanggil yang menghormati kontrak lolos tanpa pesan apa pun. Pemeriksaan berikut berakhir senyap, yang berarti seluruh berkas yang tersentuh, termasuk kontraknya, cocok satu sama lain.

bash
# memeriksa berkas pemanggil yang sah
tsc --noEmit --pretty false --allowImportingTsExtensions pustaka-waktu/uji.ts
# menjalankan program uji yang lolos pemeriksaan
node --no-warnings pustaka-waktu/uji.ts
text Keluaran
1 jam 30 menit
1.50 jam
45 menit
90
45

Keluaran tersebut menunjukkan kedua bentuk pemanggilan formatDurasi, yaitu tanpa opsi yang menghasilkan “1 jam 30 menit” dan dengan opsi jam dua desimal yang menghasilkan “1.50 jam”. Fungsi keMenit menormalkan 1.5 jam menjadi 90 menit dan membiarkan 45 menit apa adanya. Kontrak publik dengan demikian sudah teruji dari dua sisi, yaitu yang ditolak dan yang lolos.

Kesalahan umum#

  • Mengekspor seluruh tipe dengan alasan memudahkan pemanggil. Permukaan API melebar, dan perubahan kecil pada tipe yang sebenarnya jarang dipakai ikut terhitung sebagai perubahan yang mematahkan. Perbaikannya berupa mengekspor hanya tipe yang benar-benar dikonsumsi luar dan menampung sisanya sebagai tipe internal.
  • Menuliskan kontrak tersebar di berkas implementasi. Bentuk yang sama dideklarasikan ulang di beberapa tempat, lalu satu salinan berubah dan salinan lainnya tertinggal. Pemanggil yang mengikuti salinan lama memakai kontrak yang sudah tidak benar. Perbaikannya berupa menampung tipe publik pada satu berkas kontrak yang diimpor seluruh modul.
  • Menganggap versioning sebagai urusan angka semata dan melupakan sisi tipenya. Nomor versi yang tidak mengikuti perubahan tipe membuat pemanggil sulit menilai risiko pembaruan, padahal penilaian itu justru bersandar pada nomor versi tersebut. Perbaikannya berupa menelaah perubahan tipe publik lebih dahulu, lalu menetapkan segmen versi mengikutinya.

Ringkasan#

  • Tipe publik berupa janji yang diekspor dan berubah melalui versi, sedangkan tipe internal berupa detail yang bebas disunting.
  • Permukaan publik dibuat seminimal mungkin agar janji yang dijaga sedikit dan kebebasan menyunting bagian dalam tetap lebar.
  • Kontrak pustaka-waktu ditampung pada src/tipe-publik.ts berupa union SatuanDurasi dan interface OpsiFormatDurasi.
  • Semver menerjemahkan perubahan tipe publik menjadi segmen versi, dan compiler menegakkan kontrak tersebut pada setiap pemanggilan.

Referensi#