Skip to content
Blog Panji

Best Practice TypeScript: Panduan Praktis Menulis Kode Type-Safe

Panduan lengkap best practice TypeScript: strict mode, pengetikan presisi, generics, dan pola umum — dengan contoh kode valid yang bisa langsung dipakai.

Panji Ihsanudin Fajri

15 min read0 reads

Ilustrasi best practice TypeScript dengan badge tipe string number boolean dan perisai validasi
Ilustrasi best practice TypeScript dengan badge tipe string number boolean dan perisai validasi
Contents
  1. Best Practice TypeScript: Apa yang Dimaksud?
  2. Kenapa Best Practice TypeScript Penting
  3. Bug tertangkap sebelum mencapai pengguna
  4. Refactor tanpa rasa takut
  5. Dokumentasi yang tidak pernah basi
  6. Cara Kerja Type System TypeScript
  7. Nyalakan Strict Mode Sejak Hari Pertama
  8. Hindari any: Pakai unknown dan Narrowing
  9. Modelkan State dengan Discriminated Union
  10. Kunci Nilai Literal dengan as const
  11. Generics: Tulis Sekali, Pakai untuk Banyak Tipe
  12. Interface vs Type: Pilih dengan Tenang
  13. Biasakan Immutability: readonly dan Utilitas Bawaan
  14. Kesalahan Umum dan Cara Menghindarinya
  15. Studi Kasus: Refactor API Client yang Longgar
  16. FAQ
  17. Apakah TypeScript memperlambat runtime aplikasiku?
  18. Kapan boleh memakai any?
  19. Apakah strict mode wajib untuk proyek kecil atau skrip sekali pakai?
  20. Interface atau type — mana yang benar?
  21. Bagaimana cara memvalidasi data dari API kalau tipe hilang saat runtime?
  22. Kesimpulan

Banyak kode TypeScript di dunia nyata sebenarnya JavaScript yang kebetulan memakai ekstensi .ts. Anotasi any berserakan di mana-mana, strict dimatikan supaya "tidak ribet", dan type checker yang mahal itu berubah jadi pajangan. Hasilnya ironis: bug yang sama seperti di JavaScript, ditambah rasa aman yang palsu.

Artikel ini merangkum best practice TypeScript yang benar-benar dipakai di codebase produksi: mengaktifkan strict mode sejak hari pertama, mengetik dengan presisi lewat unknown dan discriminated union, memakai generics tanpa over-engineering, sampai pola refactor yang aman. Semua disertai contoh kode valid yang bisa langsung kamu salin ke proyekmu.

Ringkasan: Best practice TypeScript intinya tiga hal: (1) nyalakan strict dan jangan pernah dimatikan; (2) hindari any, pilih unknown plus narrowing atau tipe yang presisi; (3) modelkan state dengan discriminated union dan pakai generics untuk kode yang dipakai ulang. Praktik ini menangkap bug saat kompilasi, bukan saat pengguna yang menemukannya.

Ilustrasi best practice TypeScript dengan badge tipe string number boolean dan perisai validasi
Kode TypeScript yang diketik dengan benar menangkap bug sebelum mencapai runtime.

Best Practice TypeScript: Apa yang Dimaksud?

Best practice TypeScript adalah sekumpulan kebiasaan menulis kode yang memaksimalkan nilai type checker: bug tertangkap saat kompilasi, refactor berani dilakukan, dan pembaca kode paham maksudmu tanpa menebak-nebak. Ini bukan soal gaya kurung kurawal, melainkan soal keputusan tipe — kapan memakai unknown alih-alih any, kapan union lebih tepat daripada enum, dan kapan generics menyelamatkanmu dari duplikasi.

Praktik di artikel ini berlaku untuk TypeScript 5.x dan fokus pada hal yang berdampak besar dengan usaha kecil. Kalau kamu baru pindah dari JavaScript, anggap ini peta jalan: ikuti urutannya, dan setiap langkah membuat codebase-mu sedikit lebih aman dari sebelumnya.

Kenapa Best Practice TypeScript Penting

Bug tertangkap sebelum mencapai pengguna

TypeScript dirancang dengan satu tujuan yang eksplisit: JavaScript yang bisa diskalakan ke codebase besar dan tim besar. Type checker bekerja seperti rekan yang memeriksa setiap baris sebelum kode dijalankan — salah ketik nama properti, argumen fungsi yang tertukar, atau nilai null yang lolos tanpa sadar langsung ditandai merah di editormu. Bug semacam ini di JavaScript baru ketahuan saat pengguna mengklik tombol yang salah, dan biaya memperbaikinya jauh lebih mahal.

Refactor tanpa rasa takut

Kode yang diketik dengan baik membuat rename, pemindahan fungsi, dan perubahan bentuk data jadi operasi yang aman. Compiler memberitahu setiap tempat yang terdampak, jadi kamu tidak perlu menghafal seluruh codebase. Tanpa tipe yang presisi, setiap refactor besar terasa seperti operasi tanpa anestesi.

Dokumentasi yang tidak pernah basi

Tipe adalah dokumentasi yang diverifikasi compiler setiap kali proyek dibangun. Komentar bisa kedaluwarsa, tapi signature function transfer(from: AccountId, to: AccountId, amount: PositiveNumber) tidak bisa berbohong tentang apa yang diterimanya. Pembaca baru di timmu memahami kontrak sebuah fungsi hanya dengan membaca tipenya.

Cara Kerja Type System TypeScript

Sebelum masuk ke praktiknya, pahami dulu dua sifat fondasional TypeScript supaya keputusan tipemu tepat sasaran.

Pertama, TypeScript memakai structural typing: dua tipe dianggap kompatibel kalau bentuknya cocok, bukan karena namanya sama. Objek { name: string } bisa dipakai di mana pun tipe { name: string } diminta, walau didefinisikan di file berbeda. Ini membuat tipe terasa fleksibel, tapi juga berarti nama tipe saja tidak menjamin keunikan — topik yang akan berguna saat kita membahas validasi.

Kedua, tipe dihapus saat kompilasi (type erasure). Setelah tsc selesai, yang berjalan di runtime adalah JavaScript biasa tanpa sisa informasi tipe. Konsekuensinya penting: TypeScript tidak bisa melindungimu dari data yang datang dari luar — respons API, input form, isi file. Untuk batas sistem seperti itu kamu tetap butuh validasi runtime, yang akan kita bahas di bagian kesalahan umum.

Diagram alur tiga tahap sistem tipe TypeScript dari kode ke kompilasi
Alur kerja TypeScript: kode beranotasi diperiksa type checker, lalu tipe dihapus menjadi JavaScript.

Prinsipnya sederhana: type checker membaca tipemu seperti membaca instruksi — semakin presisi instruksinya, semakin tepat bantuannya. Prinsip yang sama berlaku saat memberi instruksi yang presisi ke model bahasa: kejelasan input menentukan kualitas output yang kamu dapat.

Nyalakan Strict Mode Sejak Hari Pertama

Satu baris konfigurasi ini adalah best practice TypeScript dengan return on investment tertinggi:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler"
  }
}

Flag strict sebenarnya paket berisi beberapa pemeriksaan sekaligus. Ini yang paling berdampak:

Flag Yang dicek compiler
noImplicitAny Ekspresi yang tipenya implisit any langsung ditolak
strictNullChecks null dan undefined tidak bisa menyelinap ke tipe lain diam-diam
strictFunctionTypes Parameter fungsi dicek dengan benar saat fungsi dioper sebagai nilai
strictBindCallApply bind, call, dan apply ikut mengecek tipe argumen
noUncheckedIndexedAccess arr[i] menghasilkan T | undefined, bukan T
exactOptionalPropertyTypes Properti opsional tidak menerima undefined secara eksplisit

Dua flag tambahan di luar paket strict — noUncheckedIndexedAccess dan exactOptionalPropertyTypes — memang terasa cerewet di awal. Akses array jadi harus ditangani kasus undefined-nya, dan itu justru intinya: mayoritas crash "cannot read property of undefined" berasal dari asumsi yang tidak pernah ditulis.

Alasan "sejak hari pertama" bersifat praktis: menyalakan strict di tengah proyek yang sudah besar berarti ratusan error sekaligus dan migrasi yang menyakitkan. Menyalakannya di proyek kosong berarti nol error dan kebiasaan yang terbentuk secara alami.

Hindari any: Pakai unknown dan Narrowing

any adalah lubang darurat di sistem tipe. Setiap nilai bertipe any menonaktifkan pemeriksaan untuk seluruh rantai pemakaiannya, dan lubang itu menular — satu any di hulu bisa membatalkan puluhan anotasi presisi di hilir.

// ❌ any menonaktifkan type checker sepenuhnya
function parseJsonAny(raw: string): any {
  return JSON.parse(raw);
}

const data = parseJsonAny("{}");
data.user.name.toUpperCase(); // lolos kompilasi, crash saat runtime

Penggantinya adalah unknown: tipe yang jujur berkata "aku belum tahu ini apa", sehingga compiler memaksamu memeriksa sebelum memakai.

// ✅ unknown memaksa validasi sebelum dipakai
function parseJson(raw: string): unknown {
  return JSON.parse(raw);
}

function isUser(value: unknown): value is { name: string } {
  return (
    typeof value === "object" &&
    value !== null &&
    "name" in value &&
    typeof (value as { name: unknown }).name === "string"
  );
}

const maybeUser = parseJson("{}");
if (isUser(maybeUser)) {
  console.log(maybeUser.name.toUpperCase()); // aman: sudah tervalidasi
}

Pola di atas disebut type guard — fungsi yang mengembalikan value is T dan mempersempit tipe di dalam blok if. Untuk kasus sederhana, narrowing bawaan seperti typeof, instanceof, dan in biasanya sudah cukup tanpa menulis guard manual:

function formatId(id: string | number): string {
  if (typeof id === "string") {
    return id.trim().toUpperCase(); // di sini id: string
  }
  return id.toFixed(0); // di sini id: number
}

Aturan praktisnya: any hanya boleh muncul di kode yang benar-benar darurat, misalnya adaptor library lama yang belum punya tipe, dan harus dibungkus type guard selekasnya. Kalau kamu tergoda menulis any supaya "cepat selesai", itu sinyal tipenya belum dipikirkan — bukan sinyal compiler-nya cerewet.

Modelkan State dengan Discriminated Union

Bug state yang paling sulit dilacak biasanya berasal dari kombinasi flag boolean yang tidak valid: isLoading: true bersamaan dengan isError: true, atau data yang terisi padahal isLoading masih menyala. Solusinya adalah membuat state ilegal tidak bisa direpresentasikan sama sekali.

type ApiState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: string[] }
  | { status: "error"; message: string };

function assertNever(value: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}

function render(state: ApiState): string {
  switch (state.status) {
    case "idle":
      return "Belum ada data";
    case "loading":
      return "Memuat...";
    case "success":
      return `Ditemukan ${state.data.length} item`;
    case "error":
      return `Gagal: ${state.message}`;
    default:
      return assertNever(state); // error kompilasi kalau ada status baru belum ditangani
  }
}

Pola discriminated union ini — union dari objek yang dibedakan satu properti literal (status) — memberi dua keuntungan sekaligus. Pertama, di setiap cabang switch, TypeScript otomatis menyempitkan tipe sehingga kamu hanya bisa mengakses properti yang valid untuk status itu. Kedua, fungsi assertNever membuat compiler berteriak kalau suatu hari ada varian baru yang belum ditangani, misalnya status: "refreshing". Menambah fitur tidak lagi berisiko melupakan satu cabang render.

Kunci Nilai Literal dengan as const

Banyak bug berasal dari string yang "hampir benar": "GET" vs "get", nama event yang salah ketik satu huruf. Daripada mengandalkan ingatan, kunci nilai yang diizinkan di level tipe.

const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE"] as const;
type HttpMethod = (typeof HTTP_METHODS)[number];
// HttpMethod = "GET" | "POST" | "PUT" | "DELETE"

function request(method: HttpMethod, url: string): void {
  /* ... */
}

request("GET", "/api/users"); // ok
// request("get", "/api/users"); // ❌ error: bukan HttpMethod

as const mengubah array biasa menjadi tuple readonly berisi literal, lalu (typeof HTTP_METHODS)[number] mengekstrak union dari semua elemennya. Satu sumber kebenaran untuk nilai runtime dan tipe — menambah method baru cukup edit array-nya, tipenya ikut.

Teknik yang sama berlaku untuk string berpola lewat template literal types:

type EventName = `user:${"created" | "updated" | "deleted"}`;

function emit(event: EventName, payload: unknown): void {
  /* ... */
}

emit("user:created", {}); // ok
// emit("user:banned", {}); // ❌ error: tidak cocok pola EventName

Generics: Tulis Sekali, Pakai untuk Banyak Tipe

Duplikasi paling umum di codebase TypeScript adalah fungsi yang sama ditulis ulang hanya karena tipenya beda: fetchUser, fetchProduct, fetchOrder yang badannya identik. Generics menyelesaikan ini tanpa mengorbankan keamanan tipe.

async function fetchJson<T>(url: string): Promise<T> {
  const res = await fetch(url);
  if (!res.ok) {
    throw new Error(`HTTP ${res.status} dari ${url}`);
  }
  return (await res.json()) as T;
}

interface User {
  id: number;
  name: string;
}

const user = await fetchJson<User>("/api/users/1");
console.log(user.name.toUpperCase()); // user bertipe User, autocomplete penuh

Generics juga bisa dibatasi supaya tidak terlalu longgar lewat constraints:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const person = { name: "Panji", age: 30 };
const name = getProperty(person, "name"); // string
// const bad = getProperty(person, "email"); // ❌ error: "email" bukan keyof person

K extends keyof T menjamin key yang diminta benar-benar ada di objek, dan tipe kembaliannya mengikuti properti yang dipilih. Tanpa generics, fungsi ini harus diketik any atau ditulis ulang per bentuk objek.

Satu peringatan: jangan membuat generics untuk kode yang hanya dipakai sekali. Abstraksi yang prematur lebih sulit dibaca daripada sedikit duplikasi. Kenalkan parameter tipe saat pola kedua atau ketiga muncul — prinsip yang sama dengan refactoring biasa.

Interface vs Type: Pilih dengan Tenang

Perdebatan interface vs type menghabiskan terlalu banyak energi untuk perbedaan yang kecil. Keduanya bisa mendeskripsikan bentuk objek, dan untuk sebagian besar kasus hasilnya identik. Yang penting adalah memahami bedanya supaya pilihanmu disengaja:

Aspek interface type
Memperluas extends Intersection &
Union dan tuple Tidak langsung Bisa
Declaration merging Ya, definisi bisa digabung Tidak
Pesan error Biasanya lebih rapi Bisa panjang untuk tipe kompleks

Rekomendasi yang dipakai banyak tim: interface untuk bentuk objek dan kontrak API publik (pesan error-nya lebih bersahabat dan bisa di-extend), type untuk union, tuple, mapped type, dan utilitas. Konsistensi dalam satu codebase lebih penting daripada pilihan individunya — tuangkan kesepakatan tim di satu tempat dan ikuti.

interface User {
  id: number;
  name: string;
}

interface Admin extends User {
  role: "admin" | "superadmin";
}

type ID = string | number;
type Point = readonly [number, number];

Biasakan Immutability: readonly dan Utilitas Bawaan

Sebagian bug state berasal dari objek yang diubah diam-diam oleh fungsi lain. readonly membuat niat "jangan diubah" bisa ditegakkan compiler, bukan cuma diharap-harap.

interface Config {
  readonly apiUrl: string;
  readonly retries: number;
}

const config: Config = { apiUrl: "https://api.example.com", retries: 3 };
// config.retries = 5; // ❌ error: tidak bisa assign ke properti readonly

TypeScript juga menyediakan utilitas bawaan untuk kebutuhan umum — pelajari sebelum menulis utilitas sendiri:

  • Partial<T> — semua properti jadi opsional, cocok untuk objek update
  • Pick<T, K> dan Omit<T, K> — mengambil atau membuang sebagian properti
  • Readonly<T> — versi readonly dari sebuah tipe
  • Record<K, V> — objek dengan key dan value bertipe tetap
  • ReturnType<T> dan Parameters<T> — mengambil tipe dari signature fungsi
interface User {
  id: number;
  name: string;
  email: string;
  passwordHash: string;
}

type PublicUser = Omit<User, "passwordHash">;
type UserUpdate = Partial<Pick<User, "name" | "email">>;

function updateUser(id: number, patch: UserUpdate): void {
  /* ... */
}

updateUser(1, { name: "Panji Baru" }); // ok
// updateUser(1, { passwordHash: "x" }); // ❌ error: bukan bagian dari UserUpdate

Dokumentasi lengkap utilitas ini ada di halaman utility types resmi TypeScript.

Kesalahan Umum dan Cara Menghindarinya

Perbandingan visual pengetikan longgar yang kusut dengan pengetikan ketat yang rapi
Pengetikan longgar menumpuk utang teknis; pengetikan ketat membayarnya di muka.
Ilustrasi sebelum dan sesudah refactor kode TypeScript yang lebih bersih
Refactor bertahap: dari any yang longgar menuju tipe yang presisi.

Berikut pola yang paling sering kutemui di code review, beserta perbaikannya.

1. Non-null assertion (!) yang berlebihan. Tanda seru itu berkata ke compiler "percaya aku, ini tidak null" — dan compiler menurut begitu saja. Satu refaktor kemudian asumsinya salah dan crash muncul. Ganti dengan narrowing atau early return:

// ❌
function getLengthBad(input?: string): number {
  return input!.length;
}

// ✅
function getLength(input?: string): number {
  if (input === undefined) return 0;
  return input.length;
}

2. Enum numerik untuk nilai string. Enum numerik menambahkan kode runtime dan reverse mapping yang membingungkan (Role[0] === "Admin"). Untuk himpunan nilai tetap, string literal union lebih ringan dan tidak menghasilkan kode runtime sama sekali:

// ❌ menambah runtime code yang jarang dibutuhkan
enum RoleEnum {
  Admin,
  User,
}

// ✅ hilang saat kompilasi, pesan error lebih jelas
type Role = "admin" | "user";

3. Casting buta dengan as. as tidak mengubah data, hanya membungkam compiler. JSON.parse(x) as User[] tetap crash kalau respons API berubah bentuk. Pakai as hanya untuk hal yang memang kamu ketahui tapi compiler tidak bisa buktikan, dan validasi data eksternal dengan schema validator di batas sistem.

4. Tipe opsional yang ambigu. Bedakan "boleh tidak ada" (prop?: T) dari "boleh null" (prop: T | null). Kalau undefined tidak boleh diassign eksplisit, exactOptionalPropertyTypes akan menangkapnya — satu lagi alasan menyalakan flag itu.

5. Overload yang tidak perlu. Sebelum menulis function overload, coba union parameter atau objek opsi dulu. Overload menambah beban maintenance dan sering tidak cocok dengan implementasinya sendiri.

Studi Kasus: Refactor API Client yang Longgar

Bayangkan fungsi ini di codebase nyata — bekerja, tapi setiap barisnya adalah janji yang tidak diverifikasi:

// Sebelum: any di mana-mana, error dilempar tanpa konteks
async function getUsersBad(): Promise<any> {
  const res = await fetch("/api/users");
  return res.json();
}

Refactor bertahap dengan praktik dari artikel ini:

// Sesudah: tipe eksplisit, error bermakna, validasi batas sistem
interface UserDto {
  id: number;
  name: string;
  email: string;
}

async function getUsers(): Promise<UserDto[]> {
  const res = await fetch("/api/users");
  if (!res.ok) {
    throw new Error(`Gagal memuat user: HTTP ${res.status}`);
  }
  const data: unknown = await res.json();
  if (!Array.isArray(data)) {
    throw new Error("Format respons /api/users tidak valid");
  }
  return data as UserDto[];
}

const users = await getUsers();
const names = users.map((u) => u.name.toUpperCase()); // u bertipe UserDto

Perhatikan apa yang berubah: return type eksplisit (Promise<UserDto[]> bukan any), unknown untuk data mentah dari jaringan, validasi bentuk dasar sebelum dipakai, dan pesan error yang menyebut endpoint-nya. Tidak ada library tambahan, tidak ada arsitektur baru — hanya keputusan tipe yang disengaja. Pola merumuskan kontrak yang eksplisit seperti ini juga membuat asisten AI menulis kode yang lebih akurat, karena instruksi yang presisi menghasilkan output yang presisi.

FAQ

Apakah TypeScript memperlambat runtime aplikasiku?

Tidak. Semua anotasi tipe dihapus saat kompilasi, jadi kode yang berjalan adalah JavaScript biasa dengan performa identik. Yang "melambat" hanya proses build karena ada langkah type checking — dan itu justru waktu yang dihemat dari debugging bug runtime.

Kapan boleh memakai any?

Hampir tidak pernah di kode aplikasi. Pengecualian yang wajar: adaptor untuk library JavaScript lama yang belum punya tipe, atau migrasi bertahap dari JavaScript. Dalam kedua kasus, bungkus any di satu modul kecil dengan type guard di perbatasannya, dan tandai dengan komentar TODO supaya tidak menyebar.

Apakah strict mode wajib untuk proyek kecil atau skrip sekali pakai?

Justru proyek kecil adalah tempat termurah menyalakannya: nol error awal, dan kebiasaan baik terbentuk tanpa perlawanan. Untuk skrip sekali pakai yang benar-benar sekali pakai, kamu boleh longgar — tapi pengalaman menunjukkan "skrip sekali pakai" sering hidup lebih lama dari perkiraan.

Interface atau type — mana yang benar?

Keduanya benar untuk mendeskripsikan bentuk objek. Pakai interface untuk kontrak objek/API publik dan type untuk union, tuple, serta utilitas. Yang lebih penting dari pilihannya adalah konsistensi: satu codebase, satu konvensi.

Bagaimana cara memvalidasi data dari API kalau tipe hilang saat runtime?

Tepat — type erasure berarti TypeScript tidak bisa memeriksa data yang datang dari jaringan. Solusinya adalah validasi runtime di batas sistem memakai schema validator, lalu hasil validasinya diberi tipe. Dengan begitu kode di dalam aplikasimu tetap menikmati keamanan tipe penuh, dan data kotor tertahan di pintu masuk.

Kesimpulan

Best practice TypeScript bukan daftar aturan hafalan, melainkan satu kebiasaan inti: buat asumsi menjadi eksplisit supaya compiler bisa memeriksanya. Nyalakan strict mode, singkirkan any, modelkan state dengan discriminated union, kunci nilai literal, dan pakai generics untuk pola berulang. Masing-masing praktik berdiri sendiri, tapi digabungkan mereka mengubah type checker dari pajangan menjadi jaring pengaman.

Langkah berikutnya yang paling berdampak: buka tsconfig.json proyekmu hari ini, nyalakan strict, dan perbaiki error yang muncul satu per satu — daftar error itu sebenarnya daftar bug yang selama ini bersembunyi. Dan kalau kamu memakai AI untuk membantu menulis kode, panduan prompt engineering ini melengkapi praktik di atas: instruksi yang presisi, baik untuk compiler maupun untuk model bahasa, menghasilkan kode yang lebih bisa diandalkan.

Referensi resmi yang dipakai: dokumentasi TypeScript, opsi compiler strict, panduan narrowing, dan panduan generics.

Keep reading

Related articles