Bangun API Type-Safe dengan TypeScript dari Awal

Bangun API Type-Safe dengan TypeScript dari Awal

1/10/2026 Coding By Tech Writers
TypeScriptAPI DevelopmentType Safety

Daftar Isi

Kenapa API Type-Safe Jadi Kebutuhan, Bukan Fitur Tambahan

Di aplikasi modern yang punya banyak client (web, mobile, service lain), bug paling menyebalkan sering bukan yang “heboh”, tapi yang sepele:

  • Frontend mengirim field yang salah nama atau salah bentuk.
  • Backend mengubah bentuk response tanpa sengaja dan beberapa halaman tiba-tiba error.
  • Tipe data berubah (misalnya dari number ke string) dan baru ketahuan di production.

Kalau kontrak API hanya hidup di:

  • Dokumentasi yang jarang di-update, atau
  • Ingatan 1–2 orang engineer,

maka potensi drift antara backend dan frontend akan selalu ada.

API type-safe membantu kamu:

  • Menangkap perbedaan tipe sebelum request benar-benar dikirim (di compile-time).
  • Mengurangi kebutuhan testing manual untuk hal-hal “remeh” seperti typo field.
  • Mempermudah refactor karena IDE bisa langsung menunjukkan semua lokasi yang terdampak.

Dengan TypeScript, kamu tidak perlu menulis definisi tipe berkali-kali. Kuncinya adalah punya satu sumber kebenaran (schema/kontrak) lalu menurunkan type dari sana, bukan didefinisikan ulang di setiap layer.

Arsitektur Dasar: Schema sebagai Single Source of Truth

Ada banyak cara membangun API type-safe di ekosistem TypeScript (OpenAPI-first, tRPC, dll.). Di artikel ini, kita fokus ke pola umum:

  1. Definisikan schema (misalnya dengan Zod atau library sejenis) untuk request dan response.
  2. Turunkan type TypeScript dari schema tersebut (z.infer dkk).
  3. Gunakan type yang sama di backend handler dan di client.

Secara konsep, arsitekturnya kira-kira seperti ini:

  • Lapisan schema/kontrak:
    • Mendefinisikan bentuk request, response, dan error.
    • Bisa dikonversi ke dokumen OpenAPI bila perlu.
  • Lapisan handler:
    • Menerima request yang sudah divalidasi (parsed dari schema).
    • Mengembalikan response yang mengikuti schema.
  • Lapisan client:
    • Menggunakan type yang sama sehingga auto-complete dan compile-time check bekerja.

Keuntungannya:

  • Tidak ada lagi “tipe versi backend” dan “tipe versi frontend” yang saling mengejar.
  • Perubahan kontrak API diawali dari perubahan schema, lalu otomatis menyebar ke semua konsumen yang meng-import type-nya.

Langkah 1: Definisikan Kontrak Request dan Response

Sebagai contoh, anggap kita punya endpoint: POST /api/users untuk membuat user baru.

Dengan Zod, definisi schema-nya bisa seperti ini:

import { z } from "zod";

export const createUserRequestSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1),
  role: z.enum(["ADMIN", "MEMBER"]).default("MEMBER"),
});

export const createUserResponseSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  name: z.string(),
  role: z.enum(["ADMIN", "MEMBER"]),
  createdAt: z.string().datetime(),
});

Dari sini, kita bisa turunkan type TypeScript secara otomatis:

export type CreateUserRequest = z.infer<typeof createUserRequestSchema>;
export type CreateUserResponse = z.infer<typeof createUserResponseSchema>;

Beberapa hal penting:

  • Schema ini berfungsi ganda:
    • Sebagai validasi runtime (input yang tidak valid akan tertolak).
    • Sebagai definisi type di compile-time.
  • Kita sudah menghindari:
    • Tipe “any” di boundary API.
    • Field yang tiba-tiba hilang atau berganti nama tanpa disadari.

Langkah 2: Turunkan Type Otomatis ke Handler

Setelah punya schema dan type, jangan sampai handler masih menerima any. Idealnya, handler punya signature yang selaras dengan kontrak.

Contoh untuk Express / HTTP framework sejenis:

import type { Request, Response } from "express";
import {
  createUserRequestSchema,
  createUserResponseSchema,
  type CreateUserRequest,
} from "./schema";

export async function createUserHandler(
  req: Request,
  res: Response
) {
  const parseResult = createUserRequestSchema.safeParse(req.body);
  if (!parseResult.success) {
    return res.status(400).json({
      error: "VALIDATION_ERROR",
      details: parseResult.error.format(),
    });
  }

  const input: CreateUserRequest = parseResult.data;

  const user = await createUserInDb(input);

  const response = createUserResponseSchema.parse({
    id: user.id,
    email: user.email,
    name: user.name,
    role: user.role,
    createdAt: user.createdAt.toISOString(),
  });

  return res.status(201).json(response);
}

Di sini:

  • input dijamin sudah sesuai schema → handler bebas dari undefined/tipe liar di body.
  • Sebelum mengirim response, kita juga memvalidasi agar tidak ada field aneh yang lolos (berguna saat refactor layer bawah).

Jika kamu memakai framework seperti tRPC atau Hono yang sudah terintegrasi dengan schema/type, banyak boilerplate di atas bisa berkurang, tapi prinsipnya sama: schema → type → handler, bukan sebaliknya.

Langkah 3: Bagikan Type ke Frontend Tanpa Copy-Paste

Keunggulan utama pendekatan ini adalah type bisa dibagikan ke frontend sehingga:

  • Saat backend mengubah kontrak, frontend akan langsung mendapat error compile kalau belum menyesuaikan.
  • Auto-complete di frontend (misalnya React, Next.js) jadi jauh lebih akurat.

Beberapa pola yang bisa dipakai:

  • Monorepo dengan shared package:
    • Simpan schema dan type API di package seperti @acme/api-contracts.
    • Backend dan frontend sama-sama meng-import dari package ini.
  • Generate client dari schema/OpenAPI:
    • Jika kamu berangkat dari OpenAPI, gunakan generator (misalnya openapi-typescript) untuk menghasilkan type dan client.
    • Pastikan proses generate ini bagian dari pipeline build.

Contoh simple di frontend (React) dengan shared type:

import type { CreateUserRequest, CreateUserResponse } from "@acme/api-contracts";

async function createUser(input: CreateUserRequest): Promise<CreateUserResponse> {
  const res = await fetch("/api/users", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(input),
  });

  if (!res.ok) {
    throw new Error("Failed to create user");
  }

  return res.json();
}

Di sini, kalau backend mengubah field (misalnya mengganti role jadi roles), frontend akan langsung komplain saat compile — sebelum bug-nya sampai ke user.

Error Contract yang Konsisten untuk Debugging Lebih Cepat

Type-safety tidak hanya soal data “berhasil”, tapi juga cara kamu mengirim error. Tanpa kontrak error yang konsisten, debugging di client akan selalu penuh if (res.status === ...) dengan bentuk payload yang berubah-ubah.

Kamu bisa mendefinisikan schema error global:

import { z } from "zod";

export const errorResponseSchema = z.object({
  error: z.string(), // e.g. "VALIDATION_ERROR", "NOT_FOUND"
  message: z.string().optional(),
  details: z.unknown().optional(),
});

export type ErrorResponse = z.infer<typeof errorResponseSchema>;

Lalu gunakan di semua handler:

  • Untuk validation error → error: "VALIDATION_ERROR", details berisi error schema.
  • Untuk not found → error: "NOT_FOUND", message menjelaskan resource apa.
  • Untuk error lain → error: "INTERNAL_ERROR", dengan payload yang tetap aman.

Di frontend, kamu cukup punya satu util:

async function parseApiResponse<T>(res: Response): Promise<T> {
  const data = await res.json();

  if (!res.ok) {
    const err = errorResponseSchema.parse(data);
    throw err;
  }

  return data as T;
}

Hasilnya:

  • Handling error di UI jadi lebih mudah dan konsisten.
  • Ketika kamu mengubah bentuk error, type di frontend juga akan ikut “teriak” kalau belum disesuaikan.

Checklist Migrasi Bertahap dari API Lama

Kalau kamu sudah punya API yang berjalan lama, migrasi ke type-safe tidak harus big bang. Berikut pendekatan bertahap yang lebih realistis:

  1. Pilih satu endpoint kritis sebagai pilot
    Misalnya endpoint yang paling sering dipakai frontend atau paling sering menyebabkan bug.
  2. Tambahkan schema dan type untuk endpoint itu saja
    • Definisikan request/response schema.
    • Integrasikan ke handler.
    • Kalau memungkinkan, bagi type ke frontend yang memakainya.
  3. Bangun kebiasaan baru di code review
    • Setiap ada endpoint baru, minta selalu ada schema + type.
    • Secara bertahap, endpoint lama yang disentuh untuk perubahan juga dimigrasikan.
  4. Tarik kontrak ke package terpisah
    • Setelah jumlah endpoint yang sudah type-safe cukup banyak, pindahkan schema/type ke package shared.
  5. Dokumentasikan pola
    • Tulis guideline singkat: di mana menaruh schema, bagaimana cara import ke frontend, bagaimana struktur error.

Dengan cara ini, dalam beberapa sprint kamu sudah bisa:

  • Menjadikan endpoint paling kritis jauh lebih aman.
  • Mengurangi bug regresi karena perubahan kontrak.
  • Tanpa harus mematikan atau menulis ulang seluruh API di satu waktu.

Referensi

Artikel Terkait


Tim kamu sudah menerapkan API type-safe end-to-end, atau baru di backend saja? Share pengalaman dan kendala terbesarnya di komentar — khususnya soal sinkronisasi type dengan frontend.