karawaci.kode

2026-06-08 · 6 min

TypeScript 5.7 noUncheckedIndexedAccess: 3 Bulan Penyesalan

Tiga bulan lalu saya enable noUncheckedIndexedAccess di codebase SaaS klien Jakarta yang sudah 2 tahun production. Saya pikir: “ah, mungkin 200 type error, semingguan beres.” Realita: 1,847 error, 3 minggu penyesalan. Tapi worth-it.

Konteks

App: SaaS HR (sama yang saya migrasi auth di post Bun + Supabase kemarin). Codebase ~45k LOC TypeScript. Strict mode sudah on sejak awal, tapi noUncheckedIndexedAccess belum.

Sebelumnya untuk arr[0], TS infer sebagai T. Dengan flag enable, infer sebagai T | undefined. Realistis: kalau array kosong, akses index 0 = undefined.

Setup eksperimen

Saya enable di branch terpisah, run tsc --noEmit. Hasil: 1,847 error di 312 file. ~6 error per file rata-rata, distribusi long-tail.

Bug distribution:

  • Array access tanpa bounds check: 1,120 (61%)
  • Object key access dengan string variable: 487 (26%)
  • Map/Record lookup: 240 (13%)

Saya planning: fix per-modul, urut dari yang paling critical (auth, payment, data layer) ke yang less critical (UI helper).

Fix patterns

Pattern 1: Array access dengan optional chain

// Sebelum
const firstName = users[0].name;

// Sesudah
const firstName = users[0]?.name ?? 'Unknown';

Trivial fix, tapi force saya think tentang “kalau array kosong, apa default-nya?”. Ternyata di banyak tempat, kalau kosong = bug. Saya tambah explicit guard:

if (users.length === 0) throw new Error('No users');
const firstName = users[0].name; // TS sekarang infer T

Pattern 2: Object key access dynamic

// Sebelum
const config: Record<string, string> = { dev: 'localhost', prod: 'api.example.com' };
const url = config[env]; // sebelum: string. sesudah: string | undefined

// Sesudah
const url = config[env];
if (!url) throw new Error(`Unknown env: ${env}`);
// pakai url di sini

Pattern ini paling banyak nemu bug. 23 bug latent di config lookup yang silently pakai undefined.

Pattern 3: Switch-style lookup

// Sebelum
const statusLabel: Record<Status, string> = {
  pending: 'Menunggu',
  approved: 'Disetujui',
  rejected: 'Ditolak',
};
const label = statusLabel[status];

// Sesudah, satisfies operator + as const
const statusLabel = {
  pending: 'Menunggu',
  approved: 'Disetujui',
  rejected: 'Ditolak',
} as const satisfies Record<Status, string>;
const label = statusLabel[status]; // sekarang tetap string karena Status exhaustive

Pattern as const satisfies membuat TS tau key sudah exhaustive untuk type Status.

Bug yang ke-catch (latent bug sebelumnya)

Selama fix 1,847 error, saya nemu bug nyata yang sudah live:

  1. Payslip generator: kalau employee tidak punya tax record bulan itu, code akses taxRecords[0] yang undefined. Result: NaN propagate ke total. 14 payslip wrong tax calculation selama 4 bulan terakhir. Discovered saat fix, lapor ke klien.

  2. Leave balance: balance[year][month] kalau bulan future undefined, dianggap 0 implicit. Spec klien sebenarnya: tampilkan “belum diset” supaya HR isi manual. 47 employee lihat balance 0 padahal harusnya pending.

  3. Export CSV: header dari columns[0] saat columns kosong = undefined. CSV malformed, partner accounting reject file. 3 incident dalam 6 bulan. Sekarang fixed.

Bug yang surfaced karena fix ini: cukup serius untuk justify effort 3 minggu sendirian.

Cost & velocity

3 minggu sendirian fix = ~120 jam. Saya tidak ship feature baru selama itu, klien tahu.

Setelah enable:

  • Bug runtime ditemukan post-deploy: dari ~8/bulan ke ~2/bulan
  • Junior tim (2 orang) lebih sedikit nanya “kenapa undefined error di prod”
  • Cost incident lebih rendah; saving estimated Rp 3-5jt/bulan dari less hot-fix work

Break-even: ~3 bulan dari saving incident. Sekarang sudah 3 bulan, on track.

Yang juga muncul

Edge case yang fail dengan flag enable:

// Loop dengan i
for (let i = 0; i < arr.length; i++) {
  const item = arr[i]; // arr[i] : T | undefined, meskipun i < length
}

TS tidak tau dari kode i < length bahwa arr[i] defined. Workaround:

for (const item of arr) {
  // item : T langsung
}

Atau pakai for...of consistently, atau accept T | undefined dan guard.

Library typing: beberapa library third-party return T[] tapi bisa undefined. Saya fork DefinitelyTyped contribution untuk 2 library yang typing-nya tidak akurat. Submit upstream, merged.

Performance dampak

Build time tsc --noEmit:

  • Sebelum: 32 detik (warm cache)
  • Sesudah: 38 detik (~18% lebih lama karena lebih banyak type narrowing inference)

Tidak signifikan untuk daily workflow.

Bundle size: tidak ada perubahan (TS flag tidak ngubah output JS).

Runtime: tidak ada overhead.

Verdict

Untuk codebase production dengan paying customer: enable noUncheckedIndexedAccess. Sakit di depan, tapi catch real bug yang silently live.

Untuk prototype / MVP: skip. Iterasi lebih dulu, type safety belakangan.

Untuk team junior: kasih training dulu pattern fix-nya. Otherwise mereka stress dan // @ts-ignore masal yang defeats the purpose.

Bukan magic, butuh effort. Tapi return-on-effort tinggi untuk codebase active. Saya sudah enable ini di 3 project klien sekarang, satu pattern yang stick di toolkit saya.

Ditulis oleh Reza Pradipta