Setiap klien API memiliki state machine yang tersembunyi di dalamnya. Handshake dulu. Autentikasi kedua. Kirim data ketiga. Tutup terakhir. Langgar urutan itu dan Anda mendapat error waktu runtime, server yang bingung, atau yang lebih buruk, korupsi data diam-diam.

Kebanyakan tim menyandikan aturan-aturan ini dengan pengecekan waktu runtime. if (!connected) throw new Error(...). Itu berfungsi sampai seseorang lupa melakukan pengecekan, atau sebuah refactor memperkenalkan path kode baru yang melewatkannya. Pada saat Anda menyadarinya, bug sudah ada di produksi.

Type system TypeScript dapat menghilangkan seluruh kelas bug ini. Bukan dengan aturan lint yang cerdas, tetapi dengan membuat state ilegal secara harfiah tidak dapat direpresentasikan.

Klien API Anda memiliki state machine tersembunyi

Session types adalah teknik type-system untuk menyandikan urutan operasi yang valid dalam sebuah protocol. Alih-alih melacak state dengan field string pada waktu runtime, Anda melacaknya dalam parameter tipe. Sebuah Channel<'idle'> hanya bisa memanggil connect(). Sebuah Channel<'connected'> hanya bisa memanggil send() dan close(). Compiler menolak sisanya.

Ini bukan hal akademis. Jika Anda pernah menggunakan database transaction, koneksi WebSocket, atau alur OAuth, Anda telah secara manual menegakkan session protocol. Session types hanya memindahkan enforcement itu dari waktu runtime ke waktu kompilasi.

Phantom types membuat transisi ilegal tidak dapat direpresentasikan

Berikut state machine khas dalam TypeScript:

class Channel {
  private state: 'idle' | 'connected' | 'closed' = 'idle';

  connect() {
    if (this.state !== 'idle') throw new Error('Already connected');
    this.state = 'connected';
  }

  send(msg: string) {
    if (this.state !== 'connected') throw new Error('Not connected');
    // ...
  }

  close() {
    this.state = 'closed';
  }
}

Ini baik-baik saja sampai tidak lagi. Method send() melempar pada waktu runtime jika Anda memanggilnya pada waktu yang salah. Pengujian mungkin menangkapnya. Mungkin tidak. Refactoring mungkin memperkenalkan panggilan send() baru setelah close() yang tidak ada yang sadari. Compiler tidak punya pendapat.

TypeScript menggunakan structural typing. Dua class dengan bentuk yang sama dapat dipertukarkan, bahkan jika parameter tipe mereka berbeda. Agar Channel<'idle'> dan Channel<'connected'> tidak kompatibel, parameter tipe harus muncul dalam struktur itu sendiri.

Pola standar adalah private phantom field:

class Channel<State extends string> {
  private __state!: State;
  private constructor() {}

  static create(): Channel<'idle'> {
    return new Channel();
  }

  connect(this: Channel<'idle'>): Channel<'connected'> {
    return new Channel();
  }

  send(this: Channel<'connected'>, msg: string): Channel<'connected'> {
    console.log(msg);
    return new Channel();
  }

  close(this: Channel<'connected'>): Channel<'closed'> {
    return new Channel();
  }
}

Field __state tidak pernah mendapat nilai. Field tersebut hanya ada untuk mengikat parameter tipe ke struktur class. Karena field tersebut private, kode eksternal tidak bisa memalsukan Channel<'connected'>. Karena field tersebut bertipe State, Channel<'idle'> dan Channel<'connected'> adalah tipe yang secara struktural berbeda.

Parameter this pada setiap method membatasi state mana yang bisa memanggilnya. TypeScript memeriksa kompatibilitas this di call site, bukan hanya di dalam method body.

Sekarang panggilan ilegal adalah error waktu kompilasi:

const ch = Channel.create();
ch.send('hello'); // Error: 'Channel<"idle">' is not assignable to 'Channel<"connected">'

Ini adalah bagian yang membingungkan orang. Parameter this terlihat seperti anotasi waktu runtime. Bukan. TypeScript menggunakannya hanya untuk type-checking receiver. Jika tipe receiver tidak cocok, compiler menolak panggilan sepenuhnya.

Membangun protocol unggah file yang type-safe

Channel mainan menunjukkan pola. Berikut sesuatu yang lebih dekat dengan protocol nyata: klien unggah file dengan autentikasi dan verifikasi checksum.

type UploadState = 'idle' | 'ready' | 'uploading' | 'verifying' | 'done';

class Uploader<State extends UploadState> {
  private __state!: State;
  private file: string;

  private constructor(file: string) {
    this.file = file;
  }

  static forFile(file: string): Uploader<'idle'> {
    return new Uploader(file);
  }

  authenticate(
    this: Uploader<'idle'>,
    token: string
  ): Uploader<'ready'> {
    // verify token...
    return new Uploader(this.file);
  }

  uploadChunk(
    this: Uploader<'ready' | 'uploading'>,
    data: Uint8Array
  ): Uploader<'uploading'> {
    // stream bytes...
    return new Uploader(this.file);
  }

  finalize(
    this: Uploader<'uploading'>,
    checksum: string
  ): Uploader<'verifying'> {
    // start verification...
    return new Uploader(this.file);
  }

  verify(
    this: Uploader<'verifying'>,
    serverChecksum: string
  ): Uploader<'done'> {
    // compare checksums...
    return new Uploader(this.file);
  }
}

Perhatikan uploadChunk menerima Uploader<'ready' | 'uploading'> sebagai tipe this-nya. Tipe union memungkinkan Anda mengekspresikan method yang valid dari banyak state. Method mengembalikan Uploader<'uploading'>, jadi setelah panggilan pertama Anda terkunci dalam state uploading sampai Anda melakukan finalisasi.

const chunk1 = new Uint8Array([0x01, 0x02]);
const chunk2 = new Uint8Array([0x03, 0x04]);

const upload = Uploader.forFile('report.pdf')
  .authenticate('token-123')
  .uploadChunk(chunk1)
  .uploadChunk(chunk2)
  .finalize('abc123')
  .verify('abc123');

// upload is now Uploader<'done'>

Coba panggil authenticate() setelah uploadChunk(), atau uploadChunk() setelah verify(), dan TypeScript langsung error.

Di mana pola ini menjadi canggung

Pola ini tidak gratis. Titik sakit terbesar adalah setiap transisi state membangun objek baru. Dalam contoh kita, kita membuat Uploader baru bahkan ketika state yang mendasarinya tidak berubah, seperti uploadChunk mengembalikan Uploader<'uploading'> dari Uploader<'uploading'>. Untuk objek kompleks dengan banyak state, itu boros.

Anda bisa mengoptimalkan dengan melewatkan state alih-alih merekonstruksi, tetapi tipe-tipe menjadi lebih berisik. Masalah kedua: pesan error TypeScript untuk ketidakcocokan parameter this bersifat kriptik. “The ‘this’ context of type ‘X’ is not assignable to method’s ‘this’ of type ‘Y’” akurat, tetapi tidak langsung jelas bagi seseorang yang membaca kode untuk pertama kali. Penamaan yang baik dan komentar membantu, tetapi developer experience tidak sempurna.

Ketiga, pola ini tidak terkomposisi dengan baik dengan kode async. Sebuah await di tengah rantai memecah fluent interface, dan Anda perlu menyimpan hasil bertipe perantara dalam variabel. Itu bukan dealbreaker, tetapi memang berarti pola ini paling baik bekerja untuk protocol sinkron atau protocol async yang terstruktur dengan hati-hati.

Tagged unions adalah jalan tengah yang pragmatis

Jika pola phantom type penuh terasa berat, tagged unions dengan pernyataan switch yang ekshaustif adalah alternatif pragmatis. Anda masih mendapat keamanan waktu kompilasi, tetapi di level nilai alih-alih level tipe:

type UploadState =
  | { tag: 'idle'; file: string }
  | { tag: 'ready'; file: string; token: string }
  | { tag: 'uploading'; file: string; sent: number }
  | { tag: 'done'; file: string };

function authenticate(
  state: Extract<UploadState, { tag: 'idle' }>,
  token: string
): UploadState {
  return { tag: 'ready', file: state.file, token };
}

Ini lebih idiomatis TypeScript dan lebih mudah untuk sebagian besar tim dikelola. Trade-off-nya adalah Anda tidak bisa mencegah seseorang mengoper varian state yang salah ke sebuah function di level tipe tanpa trik parameter this yang sama. Compiler menangkapnya di dalam function, tetapi call site tidak dibatasi.

Jangkau phantom types ketika pelanggaran menyebabkan kerugian nyata

Gunakan phantom types ketika protocol cukup kompleks sehingga melanggarnya menyebabkan kerugian nyata, dan ketika permukaan API cukup kecil sehingga Anda mengontrol setiap transisi. Driver database, implementasi protocol jaringan, dan SDK stateful adalah kandidat yang baik.

Jangan gunakan untuk API CRUD sederhana atau apa pun di mana kompleksitas tipe ekstra lebih mahal daripada pengecekan runtime yang sesekali.

Saat berikutnya Anda menulis if (state !== 'connected') throw new Error(...), tanyakan apakah pengecekan itu termasuk dalam kode Anda atau dalam tipe Anda. Parameter this dan phantom field TypeScript memberi Anda cara untuk mendorong enforcement protocol ke atas. Bug yang Anda tangkap tidak akan menjadi bug yang dramatis. Mereka akan menjadi kesalahan sunyi yang lolos dari code review dan baru muncul ketika pelanggan menemukan edge case yang tidak Anda uji.

Bug-bug itulah yang layak dieliminasi.