TypeScript 7 untuk SDK API relevan ketika tim ingin memodernisasi SDK internal agar integrasi antar service lebih aman, lebih mudah dirawat, dan lebih sulit salah pakai. Nilai utamanya bukan pada daftar fitur rilis, melainkan pada bagaimana type yang lebih presisi membantu menangkap mismatch kontrak, error auth, dan alur retry sebelum kode dijalankan.

Untuk SDK API internal, target praktisnya biasanya jelas: request/response contract harus ketat, error harus bisa dimodelkan dengan baik, operasi tulis harus aman terhadap retry, dan webhook tidak boleh dipercaya tanpa validasi runtime. Type system membantu mencegah banyak bug integrasi di compile time, tetapi ia tidak menggantikan validasi input, signature check, atau proteksi idempotensi di sisi server.

Artikel ini menggunakan tren TypeScript 7 sebagai pengait modernisasi SDK, bukan sebagai rangkuman rilis. Untuk konteks umum, Anda bisa merujuk pengumuman resmi TypeScript 7 dari Microsoft.

Mengapa SDK API internal perlu kontrak yang lebih ketat

Banyak bug integrasi bukan berasal dari algoritma yang rumit, tetapi dari asumsi yang berbeda antar tim:

  • field request opsional dianggap wajib oleh backend,
  • status response berubah tanpa sinyal yang jelas di client,
  • error auth diperlakukan sama dengan error jaringan,
  • retry mengulang operasi yang sebenarnya tidak aman,
  • payload webhook dianggap valid hanya karena tipenya cocok di editor.

SDK internal yang baik harus memaksa pola pemakaian yang benar. Jika client harus selalu menyertakan idempotency key untuk operasi tertentu, jadikan itu bagian eksplisit dari type dan API helper. Jika token bisa kedaluwarsa, modelkan jalur error dan refresh-nya dengan jelas. Jika kontrak webhook bisa berubah, pisahkan versi payload dan validasi runtime-nya.

Desain kontrak request/response yang ketat

Pisahkan model domain dari model transport

Kesalahan umum adalah memakai satu interface untuk semua kebutuhan: input UI, payload HTTP, entity database, dan event webhook. Ini membuat perubahan kecil di satu lapisan merambat ke mana-mana. Untuk SDK API, lebih aman memisahkan model transport yang benar-benar mencerminkan kontrak API.

type Currency = "IDR" | "USD";

type CreatePaymentRequest = {
  amount: number;
  currency: Currency;
  customerId: string;
  description?: string;
};

type PaymentStatus = "pending" | "authorized" | "captured" | "failed";

type PaymentResponse = {
  id: string;
  status: PaymentStatus;
  amount: number;
  currency: Currency;
  createdAt: string;
};

Poin pentingnya bukan sekadar menulis type, tetapi memastikan type tersebut cukup sempit untuk mencegah input yang salah. Misalnya, daripada memakai string untuk semua nilai status, gunakan union literal agar perubahan status bisa segera terlihat sebagai breaking change pada sisi client.

Gunakan discriminated union untuk response dan error

Jika API sering mengembalikan beberapa bentuk hasil, jangan sembunyikan semuanya di balik any atau error string umum. Gunakan discriminated union supaya alur penanganan lebih aman dan eksplisit.

type ApiSuccess<T> = {
  ok: true;
  data: T;
  requestId: string;
};

type AuthError = {
  ok: false;
  kind: "auth";
  code: "TOKEN_EXPIRED" | "TOKEN_INVALID" | "INSUFFICIENT_SCOPE";
  message: string;
};

type ValidationError = {
  ok: false;
  kind: "validation";
  fieldErrors: Record<string, string[]>;
  message: string;
};

type NetworkError = {
  ok: false;
  kind: "network";
  retryable: boolean;
  message: string;
};

type ConflictError = {
  ok: false;
  kind: "conflict";
  code: "IDEMPOTENCY_CONFLICT" | "VERSION_CONFLICT";
  message: string;
};

type ApiError = AuthError | ValidationError | NetworkError | ConflictError;
type ApiResult<T> = ApiSuccess<T> | ApiError;

Model seperti ini memaksa developer melakukan narrowing yang benar. Keuntungannya terasa saat refactor: kalau Anda menambah varian error baru, compiler akan menunjukkan lokasi penanganan yang belum lengkap.

Jangan terlalu cepat menggeneralisasi endpoint

SDK internal sering berakhir dengan abstraksi seperti request(path, method, body) yang terlalu generik. Fungsinya memang fleksibel, tetapi developer kehilangan petunjuk penting tentang kewajiban field, kebutuhan auth, dan apakah endpoint aman untuk di-retry.

Lebih baik sediakan wrapper yang spesifik untuk operasi penting:

type CreatePaymentOptions = {
  idempotencyKey: string;
  accessToken: string;
};

interface PaymentsClient {
  createPayment(
    req: CreatePaymentRequest,
    opts: CreatePaymentOptions
  ): Promise<ApiResult<PaymentResponse>>;
}

Dari tanda tangan fungsi saja, sudah terlihat bahwa operasi ini membutuhkan token dan idempotency key. Ini jauh lebih aman daripada mengandalkan dokumentasi terpisah yang mudah diabaikan.

Typed client untuk auth dan retry yang aman

Modelkan auth sebagai bagian dari kontrak, bukan detail tersembunyi

Banyak SDK menyembunyikan seluruh urusan auth di interceptor global. Pendekatan ini nyaman, tetapi sering membuat error menjadi kabur. Developer hanya melihat request gagal tanpa tahu apakah masalahnya karena token habis, scope kurang, atau refresh sedang balapan.

Model yang lebih sehat adalah memisahkan:

  • cara memperoleh token,
  • cara menempelkan token ke request,
  • cara memetakan response auth error menjadi type yang eksplisit.
type AccessToken = {
  value: string;
  expiresAtEpochMs: number;
};

type TokenProvider = {
  getToken(): Promise<AccessToken>;
  refreshToken(current?: AccessToken): Promise<AccessToken>;
};

class SdkClient {
  constructor(
    private baseUrl: string,
    private tokenProvider: TokenProvider
  ) {}

  async createPayment(
    req: CreatePaymentRequest,
    opts: { idempotencyKey: string }
  ): Promise<ApiResult<PaymentResponse>> {
    const token = await this.tokenProvider.getToken();

    const res = await fetch(`${this.baseUrl}/payments`, {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "authorization": `Bearer ${token.value}`,
        "idempotency-key": opts.idempotencyKey
      },
      body: JSON.stringify(req)
    });

    if (res.status === 401) {
      return {
        ok: false,
        kind: "auth",
        code: "TOKEN_EXPIRED",
        message: "Access token tidak valid atau sudah kedaluwarsa"
      };
    }

    if (!res.ok) {
      return {
        ok: false,
        kind: "network",
        retryable: res.status >= 500,
        message: `HTTP ${res.status}`
      };
    }

    const data = (await res.json()) as PaymentResponse;
    return {
      ok: true,
      data,
      requestId: res.headers.get("x-request-id") ?? ""
    };
  }
}

Contoh di atas sengaja sederhana. Dalam implementasi nyata, Anda biasanya juga memetakan 400 ke validation, 409 ke conflict, dan memisahkan kegagalan parsing response dari kegagalan transport.

Token refresh race: edge case yang sering terlambat disadari

Salah satu bug klasik adalah beberapa request paralel mendeteksi token kedaluwarsa dan semuanya mencoba refresh bersamaan. Akibatnya:

  • refresh endpoint dibanjiri request yang tidak perlu,
  • token baru saling menimpa di cache,
  • sebagian request tetap memakai token lama,
  • bug muncul sporadis dan sulit direproduksi.

Solusi praktisnya adalah single-flight refresh: saat refresh sedang berjalan, request lain menunggu promise yang sama alih-alih memulai refresh baru.

class MemoryTokenProvider implements TokenProvider {
  private current?: AccessToken;
  private inflightRefresh?: Promise<AccessToken>;

  async getToken(): Promise<AccessToken> {
    if (this.current && this.current.expiresAtEpochMs > Date.now() + 5000) {
      return this.current;
    }
    return this.refreshToken(this.current);
  }

  async refreshToken(current?: AccessToken): Promise<AccessToken> {
    if (this.inflightRefresh) return this.inflightRefresh;

    this.inflightRefresh = (async () => {
      try {
        const next = await fetchNewTokenFromAuthServer(current);
        this.current = next;
        return next;
      } finally {
        this.inflightRefresh = undefined;
      }
    })();

    return this.inflightRefresh;
  }
}

declare function fetchNewTokenFromAuthServer(
  current?: AccessToken
): Promise<AccessToken>;

Trade-off-nya: Anda perlu hati-hati pada timeout dan state yang menggantung. Jika promise refresh tidak pernah selesai, semua request yang menunggu akan ikut tertahan. Karena itu, refresh sebaiknya dibatasi timeout dan memiliki logging yang jelas.

Idempotency key dan retry aman

Kapan idempotensi dibutuhkan

Idempotensi sangat penting pada operasi tulis yang bisa terduplikasi karena retry, timeout, atau user menekan tombol dua kali. Contohnya:

  • membuat pembayaran,
  • membuat invoice,
  • mengirim perintah provisioning,
  • mencatat mutasi yang tidak boleh dobel.

Tanpa idempotensi, retry yang tampak aman di sisi client bisa menyebabkan duplikasi data atau efek samping ganda di backend.

Bedakan retry aman dan retry berbahaya

Tidak semua kegagalan boleh langsung di-retry. Aturan sederhananya:

  • Aman di-retry: kegagalan jaringan sementara, timeout sebelum response diterima, atau 5xx tertentu pada operasi yang didukung idempotency key.
  • Perlu kehati-hatian: 401 karena mungkin butuh refresh token dulu, 409 karena bisa berarti konflik idempotensi atau versi.
  • Jangan di-retry buta: 400 validation error, 403 insufficient scope, atau kegagalan bisnis yang deterministik.

SDK yang baik sebaiknya tidak hanya punya helper retry, tetapi juga type atau metadata yang menjelaskan apakah suatu operasi memang aman diulang.

type RetryDecision = {
  shouldRetry: boolean;
  reason: string;
};

function decideRetry(error: ApiError): RetryDecision {
  switch (error.kind) {
    case "network":
      return {
        shouldRetry: error.retryable,
        reason: error.retryable ? "transient network/server error" : "non-retryable network error"
      };
    case "auth":
      return {
        shouldRetry: error.code === "TOKEN_EXPIRED",
        reason: error.code === "TOKEN_EXPIRED" ? "refresh token lalu ulangi" : "auth error non-retryable"
      };
    case "conflict":
      return {
        shouldRetry: false,
        reason: "perlu investigasi konflik idempotensi atau versi"
      };
    case "validation":
      return {
        shouldRetry: false,
        reason: "payload salah"
      };
  }
}

Idempotency key harus stabil untuk satu operasi logis

Kesalahan yang sering terjadi adalah menghasilkan key baru setiap kali request di-retry. Itu menggagalkan tujuan idempotensi karena server melihatnya sebagai operasi baru. Untuk satu operasi logis yang sama, key harus dipertahankan stabil sampai client memutuskan operasi tersebut benar-benar dibatalkan.

Praktik yang umum:

  • buat key sekali di lapisan pemanggil, bukan di dalam loop retry,
  • kirim key yang sama pada semua percobaan ulang,
  • simpan korelasi antara key dan intent bisnis jika perlu audit,
  • pastikan server juga memvalidasi apakah payload untuk key yang sama identik atau tidak.

Kasus penting: jika request pertama berhasil diproses server tetapi response hilang karena timeout jaringan, retry dengan key yang sama memungkinkan server mengembalikan hasil yang konsisten tanpa mengeksekusi operasi dua kali.

Partial failure: idempotensi bukan solusi untuk semua hal

Misalnya sebuah endpoint membuat pembayaran lalu mengirim notifikasi ke sistem lain. Jika pembayaran sudah tersimpan tetapi notifikasi gagal, hasilnya adalah partial failure. Idempotency key membantu mencegah duplikasi pembayaran saat client retry, tetapi ia tidak otomatis memperbaiki notifikasi yang gagal.

Karena itu, backend tetap perlu desain yang tahan gangguan, misalnya:

  • menyimpan status intermediate secara eksplisit,
  • memisahkan side effect ke job asynchronous,
  • menggunakan outbox/event queue untuk pengiriman yang bisa diulang,
  • menyediakan endpoint query status agar client tidak menebak hasil akhir.

Validasi payload webhook: type membantu, runtime validation tetap wajib

Mengapa type saja tidak cukup

TypeScript hanya memeriksa kode saat build. Payload webhook datang dari jaringan sebagai data tak tepercaya. Walaupun Anda menulis:

const event = payload as PaymentWebhookEvent;

baris itu tidak memvalidasi apa pun. Ia hanya memaksa compiler diam. Jika provider mengubah field, mengirim event versi lama, atau payload rusak, bug tetap akan lolos ke runtime.

Karena itu, ada dua lapis yang harus dipertahankan:

  1. type statis untuk pengalaman developer dan kontrak internal,
  2. validasi runtime untuk data eksternal yang tidak bisa dipercaya.

Contoh helper webhook event yang typed

type PaymentCapturedEvent = {
  type: "payment.captured";
  id: string;
  createdAt: string;
  data: {
    paymentId: string;
    amount: number;
    currency: "IDR" | "USD";
  };
};

type PaymentFailedEvent = {
  type: "payment.failed";
  id: string;
  createdAt: string;
  data: {
    paymentId: string;
    reason: string;
  };
};

type WebhookEvent = PaymentCapturedEvent | PaymentFailedEvent;

function isWebhookEvent(input: unknown): input is WebhookEvent {
  if (!input || typeof input !== "object") return false;

  const value = input as Record<string, unknown>;
  if (typeof value.type !== "string") return false;
  if (typeof value.id !== "string") return false;
  if (typeof value.createdAt !== "string") return false;
  if (!value.data || typeof value.data !== "object") return false;

  switch (value.type) {
    case "payment.captured": {
      const data = value.data as Record<string, unknown>;
      return (
        typeof data.paymentId === "string" &&
        typeof data.amount === "number" &&
        (data.currency === "IDR" || data.currency === "USD")
      );
    }
    case "payment.failed": {
      const data = value.data as Record<string, unknown>;
      return (
        typeof data.paymentId === "string" &&
        typeof data.reason === "string"
      );
    }
    default:
      return false;
  }
}

Dengan helper seperti ini, handler webhook menjadi lebih aman:

async function handleWebhook(rawBody: string, signature: string) {
  verifySignatureOrThrow(rawBody, signature);

  const parsed: unknown = JSON.parse(rawBody);
  if (!isWebhookEvent(parsed)) {
    throw new Error("Invalid webhook payload");
  }

  switch (parsed.type) {
    case "payment.captured":
      await processCaptured(parsed);
      return;
    case "payment.failed":
      await processFailed(parsed);
      return;
  }
}

declare function verifySignatureOrThrow(rawBody: string, signature: string): void;
declare function processCaptured(event: PaymentCapturedEvent): Promise<void>;
declare function processFailed(event: PaymentFailedEvent): Promise<void>;

Dalam sistem produksi, banyak tim memilih library validasi schema agar definisi runtime dan inferensi type bisa lebih konsisten. Namun bahkan tanpa library tambahan, prinsipnya tetap sama: jangan percaya payload jaringan hanya karena editor tidak memberi garis merah.

Duplicate delivery pada webhook harus dianggap normal

Provider webhook sering mengirim ulang event jika endpoint Anda lambat, timeout, atau tidak merespons dengan status yang dianggap sukses. Artinya, duplicate delivery bukan edge case langka, melainkan perilaku yang harus diasumsikan terjadi.

Praktik yang aman:

  • simpan event.id atau identitas dedup lain,
  • jadikan handler idempoten,
  • bedakan antara already processed dan processing failed,
  • hindari side effect ganda seperti pengiriman email atau perubahan saldo dua kali.

Jika pemrosesan event melibatkan beberapa langkah, lebih aman memakai transaksi database atau state machine sederhana agar event yang sama tidak menghasilkan perubahan inkonsisten.

Backward compatibility kontrak API

Perubahan yang tampak kecil bisa menjadi breaking change

Di SDK internal, perubahan seperti menambah nilai baru pada enum status, mengubah field dari wajib menjadi nullable, atau memindahkan lokasi field nested sering dianggap sepele. Padahal pada client yang memakai union sempit dan exhaustive switch, perubahan itu bisa memicu error compile-time atau, lebih buruk, bug runtime jika tidak tervalidasi.

Ini sebenarnya hal baik: type yang ketat membuat breaking change terlihat lebih awal. Namun tim tetap perlu disiplin versioning kontrak.

Strategi praktis menjaga kompatibilitas

  • Tambah field baru secara non-breaking bila memungkinkan, jangan langsung mengganti atau menghapus field lama.
  • Gunakan union yang toleran terhadap penambahan versi pada sisi penerima webhook jika provider dapat memperluas event.
  • Pisahkan versi payload bila bentuk data benar-benar berubah, misalnya v1 dan v2.
  • Uji SDK terhadap contoh payload nyata, bukan hanya type buatan tangan.
  • Dokumentasikan invariant bisnis yang tidak tercermin penuh di type, misalnya hubungan antar field atau kondisi lifecycle.

TypeScript membantu menemukan perubahan struktural, tetapi tidak bisa memahami semua aturan bisnis. Misalnya, type mungkin mengizinkan status: "captured" dan capturedAt: undefined, padahal secara domain kombinasi itu tidak valid. Untuk aturan seperti ini, Anda tetap memerlukan validasi runtime atau constructor/domain guard yang lebih ketat.

Pola implementasi yang disarankan untuk modernisasi SDK

1. Mulai dari endpoint yang paling mahal jika salah

Jangan modernisasi seluruh SDK sekaligus. Pilih operasi dengan risiko tinggi: pembayaran, provisioning, perubahan status, atau webhook inti. Di sana manfaat kontrak ketat dan idempotensi paling terasa.

2. Tetapkan boundary yang jelas

Pisahkan modul berikut:

  • transport: HTTP client, header, timeout, retry dasar,
  • contract: request/response type dan error union,
  • auth: token provider dan refresh policy,
  • webhook: signature verification dan payload validation,
  • business helpers: operasi spesifik domain.

Pemisahan ini mencegah helper domain bocor menjadi utilitas global yang sulit diuji.

3. Gunakan compile-time strictness untuk memaksa pola benar

Contohnya:

  • fungsi mutasi wajib menerima idempotencyKey,
  • handler response wajib menangani semua varian ApiError,
  • event webhook hanya bisa diproses setelah lolos guard validasi,
  • status domain dimodelkan dengan union literal, bukan string bebas.

Tujuannya bukan membuat API SDK rumit, tetapi menghilangkan ruang untuk pemakaian yang ambigu.

4. Tambahkan observability sejak awal

Masalah auth race, duplicate delivery, dan partial failure sulit di-debug tanpa jejak yang cukup. Minimal, catat:

  • request ID dari server,
  • idempotency key,
  • event ID webhook,
  • status retry dan alasan retry,
  • waktu refresh token dan hasilnya.

Tanpa ini, error sering tampak acak padahal sebenarnya pola kegagalannya konsisten.

Kesalahan umum yang perlu dihindari

  • Menggunakan as untuk memaksa payload eksternal cocok dengan type. Ini menenangkan compiler, bukan memvalidasi data.
  • Menyamakan semua error menjadi exception generik. Akibatnya retry, refresh token, dan UX error jadi tidak terarah.
  • Membuat idempotency key baru saat retry. Ini menghilangkan jaminan idempoten.
  • Mengandalkan interceptor auth tanpa kontrol race. Request paralel bisa refresh token berkali-kali.
  • Menganggap webhook delivered exactly once. Dalam banyak sistem, asumsi itu salah.
  • Mengabaikan kompatibilitas kontrak. Penambahan nilai enum baru pun bisa berdampak ke client lama.

Penutup

TypeScript 7 untuk SDK API paling berguna jika dipakai sebagai momentum memperketat kontrak dan memperjelas perilaku runtime, bukan sekadar memperbarui sintaks atau tooling. Dengan request/response type yang presisi, union error yang eksplisit, idempotency key untuk operasi tulis, serta validasi webhook di runtime, tim bisa memindahkan banyak bug integrasi dari produksi ke compile time.

Namun batasnya juga penting dipahami: type system tidak memverifikasi payload jaringan, tidak mencegah duplicate delivery, dan tidak menyelesaikan partial failure sendirian. Karena itu, modernisasi SDK yang sehat selalu menggabungkan dua hal: strict typing untuk developer experience dan runtime safeguards untuk realitas sistem terdistribusi.