kodingup.camp

Beranda/TypeScript Mastery

Generics

Modul 7 dari 124 menit bacaGenerics

Isi modul

    Kenapa ini penting

    Coba bayangkan kamu menulis function ambilPertama yang mengembalikan elemen pertama array. Untuk array angka, hasilnya angka. Untuk array string, hasilnya string. Untuk array Produk, hasilnya Produk. Tanpa generics, pilihanmu cuma dua: menulis tiga function yang isinya sama, atau memakai any dan kehilangan semua informasi tipe. Generics adalah jalan ketiga: satu function yang "mengingat" tipe yang dimasukkan dan mengembalikannya dengan tepat.

    Generics ada di mana-mana walaupun mungkin belum kamu sadari: Array<string>, Promise<User>, useState<number>(), useQuery<Produk[]>(), z.infer<typeof skema>. Begitu kamu paham generics, membaca tipe library seperti React, TanStack Query, dan Zod jauh lebih mudah. Analoginya seperti cetakan kue: cetakannya satu, adonannya bisa cokelat, pandan, atau keju. Kue yang keluar selalu sesuai adonan yang dimasukkan.

    Konsep inti

    Function generic

    function ambilPertama<T>(daftar: T[]): T | undefined {
      return daftar[0];
    }
    
    const angka = ambilPertama([10, 20, 30]);     // T = number → number | undefined
    const kata = ambilPertama(["apel", "jeruk"]); // T = string → string | undefined
    

    <T> adalah parameter tipe. Seperti parameter function biasa yang menerima nilai, parameter tipe menerima tipe. Biasanya kamu tidak perlu menulis ambilPertama<number>(...) karena TypeScript menyimpulkan T dari argumen. Nama T hanya konvensi; untuk kode yang lebih jelas boleh pakai nama bermakna seperti TItem atau TData.

    Beberapa parameter tipe

    function pasangkan<A, B>(a: A, b: B): [A, B] {
      return [a, b];
    }
    const pasangan = pasangkan("Sari", 25); // [string, number]
    

    Interface dan type generic

    interface ResponsApi<T> {
      data: T;
      status: number;
      pesan: string;
    }
    
    type Halaman<T> = {
      items: T[];
      halaman: number;
      totalHalaman: number;
    };
    
    const responsUser: ResponsApi<{ id: number; nama: string }> = {
      data: { id: 1, nama: "Sari" },
      status: 200,
      pesan: "OK",
    };
    

    Satu bentuk ResponsApi bisa dipakai untuk user, produk, atau apa pun. Ini pola yang sangat umum saat membungkus respons backend.

    Constraint dengan extends

    Kadang T tidak boleh sembarang tipe. Misalnya function yang membaca .length hanya masuk akal untuk tipe yang punya length:

    function terpanjang<T extends { length: number }>(a: T, b: T): T {
      return a.length >= b.length ? a : b;
    }
    terpanjang("kucing", "gajah");  // OK, string punya length
    terpanjang([1, 2], [1, 2, 3]);  // OK, array punya length
    

    T extends X berarti "T boleh tipe apa saja asalkan memenuhi X". Constraint yang sangat sering dipakai adalah K extends keyof T: kunci yang benar-benar ada di object T.

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

    keyof dan T[K] (indexed access) akan dibahas lebih dalam di modul Utility Types.

    Default type parameter

    Seperti parameter function yang punya nilai default, parameter tipe juga bisa:

    type Respons<TData = unknown, TError = string> = {
      data?: TData;
      error?: TError;
    };
    const r1: Respons = {};                       // TData = unknown, TError = string
    const r2: Respons<number[]> = { data: [1, 2] }; // TError tetap string
    

    Generics di class

    class Antrian<T> {
      private isi: T[] = [];
      masuk(item: T): void { this.isi.push(item); }
      keluar(): T | undefined { return this.isi.shift(); }
    }
    

    Membaca tipe generic milik library

    Setelah paham <T>, signature library yang dulu terlihat menakutkan jadi bisa dibaca. Beberapa contoh yang sudah sering kamu pakai:

    Yang kamu tulis Signature (disederhanakan) Artinya
    [1, 2].map((n) => String(n)) map<U>(fn: (value: T) => U): U[] Tipe hasil U disimpulkan dari callback, jadi hasilnya string[]
    useState(0) useState<S>(initial: S): [S, Dispatch<SetStateAction<S>>] S disimpulkan dari nilai awal, jadi number
    fetch(url) fetch(...): Promise<Response> Promise adalah generic: isi yang akan datang adalah Response
    new Map<string, number>() Map<K, V> Dua parameter tipe: kunci dan nilai

    Pola membacanya selalu sama: cari huruf di dalam < >, lalu lihat di mana huruf itu muncul lagi (parameter dan nilai kembalian). Tempat munculnya itulah yang "terhubung".

    Kapan tidak perlu generics

    Kalau parameter tipe hanya dipakai satu kali (misalnya function cetak<T>(x: T): void), generics tidak memberi apa-apa; cukup unknown. Generics berguna ketika ia menghubungkan dua hal: input dengan output, atau dua parameter satu sama lain.

    Contoh kode

    // generics.ts
    
    // 1. Function generic + constraint
    function kelompokkan<T, K extends string>(daftar: T[], ambilKunci: (item: T) => K): Record<K, T[]> {
      const hasil = {} as Record<K, T[]>;
      for (const item of daftar) {
        const kunci = ambilKunci(item);
        (hasil[kunci] ??= []).push(item);
      }
      return hasil;
    }
    
    type Produk = { nama: string; kategori: "makanan" | "minuman"; harga: number };
    
    const menu: Produk[] = [
      { nama: "Nasi Goreng", kategori: "makanan", harga: 25000 },
      { nama: "Es Teh", kategori: "minuman", harga: 5000 },
      { nama: "Mie Ayam", kategori: "makanan", harga: 20000 },
    ];
    
    const perKategori = kelompokkan(menu, (p) => p.kategori);
    // tipe: Record<"makanan" | "minuman", Produk[]>
    console.log(perKategori.makanan.map((p) => p.nama)); // → [ 'Nasi Goreng', 'Mie Ayam' ]
    console.log(perKategori.minuman.length);              // → 1
    
    // 2. keyof constraint
    function ambilNilai<T, K extends keyof T>(obj: T, kunci: K): T[K] {
      return obj[kunci];
    }
    const hargaPertama = ambilNilai(menu[0]!, "harga"); // tipe: number
    console.log(hargaPertama); // → 25000
    ambilNilai(menu[0]!, "stok"); // ❌ error TS2345: Argument of type '"stok"' is not assignable to parameter of type 'keyof Produk'.
    
    // 3. Interface generic untuk respons API
    interface ResponsApi<T> {
      ok: boolean;
      data: T;
    }
    function bungkus<T>(data: T): ResponsApi<T> {
      return { ok: true, data };
    }
    const respons = bungkus(menu); // ResponsApi<Produk[]>
    console.log(respons.data[1]?.nama); // → Es Teh
    
    // 4. Constraint yang tidak terpenuhi
    function terpanjang<T extends { length: number }>(a: T, b: T): T {
      return a.length >= b.length ? a : b;
    }
    console.log(terpanjang("kucing", "gajah")); // → kucing
    terpanjang(10, 20); // ❌ error TS2345: Argument of type 'number' is not assignable to parameter of type '{ length: number; }'.
    

    Catatan: tanda ! pada menu[0]! adalah non-null assertion ("saya yakin ini tidak undefined"). Kita memakainya karena tsconfig latihan menyalakan noUncheckedIndexedAccess, sehingga menu[0] bertipe Produk | undefined. Pakai ! hanya kalau kamu benar-benar yakin.

    Contoh nyata: custom hook useFetch<T>

    Inilah generics yang akan sering kamu tulis di React. Hook ini tidak tahu data apa yang diambil; pemanggil yang menentukan lewat T.

    // useFetch.tsx
    import { useEffect, useState } from "react";
    
    type StatusFetch<T> =
      | { status: "memuat" }
      | { status: "sukses"; data: T }
      | { status: "gagal"; pesan: string };
    
    export function useFetch<T>(url: string): StatusFetch<T> {
      const [state, setState] = useState<StatusFetch<T>>({ status: "memuat" });
    
      useEffect(() => {
        const controller = new AbortController();
        setState({ status: "memuat" });
    
        fetch(url, { signal: controller.signal })
          .then((res) => {
            if (!res.ok) throw new Error(`HTTP ${res.status}`);
            return res.json() as Promise<T>; // asumsi bentuk data; modul Data API membahas validasinya
          })
          .then((data) => setState({ status: "sukses", data }))
          .catch((err: unknown) => {
            if (controller.signal.aborted) return;
            setState({ status: "gagal", pesan: err instanceof Error ? err.message : "Gagal memuat" });
          });
    
        return () => controller.abort();
      }, [url]);
    
      return state;
    }
    
    // Pemakaian
    type Todo = { id: number; title: string; completed: boolean };
    
    export function DaftarTodo() {
      const hasil = useFetch<Todo[]>("https://jsonplaceholder.typicode.com/todos?_limit=5");
    
      if (hasil.status === "memuat") return <p>Memuat...</p>;
      if (hasil.status === "gagal") return <p>Error: {hasil.pesan}</p>;
    
      return (
        <ul>
          {hasil.data.map((todo) => (
            <li key={todo.id}>{todo.completed ? "✅" : "⬜"} {todo.title}</li>
          ))}
        </ul>
      );
    }
    

    Di dalam hasil.data.map, editor tahu todo punya id, title, dan completed. Kalau kamu salah ketik todo.tittle, error langsung muncul. Itu semua karena satu <Todo[]> saat memanggil hook.

    Kesalahan umum

    1. Memakai any padahal butuh generics. function pertama(arr: any[]): any membuang informasi tipe. Dengan <T>, tipe mengalir dari input ke output.
    2. Generics yang dipakai sekali saja. function log<T>(x: T): void tidak lebih baik daripada x: unknown. Generics berguna jika menghubungkan beberapa posisi.
    3. Lupa constraint lalu bingung kenapa .length atau .id ditolak. Kalau function butuh properti tertentu, nyatakan dengan T extends { id: number }.
    4. Menulis argumen tipe secara manual padahal bisa disimpulkan. ambilPertama<number>([1, 2]) tidak salah tapi berisik. Tulis manual hanya kalau TypeScript tidak bisa menebak, misalnya useState<User | null>(null).
    5. Mengira useFetch<T> memvalidasi data. T hanya janji di compile time. Kalau API mengirim bentuk lain, TypeScript tidak tahu. Solusinya ada di modul Data API (Zod).

    Latihan

    1. Mudah, Tulis ambilTerakhir<T>(daftar: T[]): T | undefined dan uji dengan array angka dan array string.
    2. Sedang, Tulis type Hasil<T, E = string> = { ok: true; nilai: T } | { ok: false; error: E } lalu function bagiAman(a: number, b: number): Hasil<number> yang gagal kalau b === 0.
    3. Menantang, Tulis urutkanBerdasarkan<T, K extends keyof T>(daftar: T[], kunci: K): T[] yang mengembalikan array baru terurut naik berdasarkan properti kunci (angka atau string).
    Lihat solusi
    // 1. Mudah
    function ambilTerakhir<T>(daftar: T[]): T | undefined {
      return daftar[daftar.length - 1];
    }
    console.log(ambilTerakhir([1, 2, 3]));        // → 3
    console.log(ambilTerakhir(["a", "b", "c"]));  // → c
    
    // 2. Sedang
    type Hasil<T, E = string> = { ok: true; nilai: T } | { ok: false; error: E };
    
    function bagiAman(a: number, b: number): Hasil<number> {
      if (b === 0) return { ok: false, error: "Tidak bisa membagi dengan nol" };
      return { ok: true, nilai: a / b };
    }
    const h = bagiAman(10, 0);
    console.log(h.ok ? h.nilai : h.error); // → Tidak bisa membagi dengan nol
    
    // 3. Menantang
    function urutkanBerdasarkan<T, K extends keyof T>(daftar: T[], kunci: K): T[] {
      return [...daftar].sort((a, b) => {
        const x = a[kunci];
        const y = b[kunci];
        if (x < y) return -1;
        if (x > y) return 1;
        return 0;
      });
    }
    const siswa = [
      { nama: "Citra", nilai: 90 },
      { nama: "Adi", nilai: 75 },
      { nama: "Bayu", nilai: 82 },
    ];
    console.log(urutkanBerdasarkan(siswa, "nilai").map((s) => s.nama)); // → [ 'Adi', 'Bayu', 'Citra' ]
    console.log(urutkanBerdasarkan(siswa, "nama").map((s) => s.nama));  // → [ 'Adi', 'Bayu', 'Citra' ]
    

    Ringkasan

    Video pendamping

    Kalau penjelasan di atas belum masuk, tonton ini. Video dari kanal YouTube pihak lain.

    Learn TypeScript Generics In 13 MinutesWeb Dev Simplified · Bahasa InggrisBuka di YouTube

    Kuis pemahaman

    Pilih satu jawaban per soal. Penjelasan muncul setelah kamu menjawab.