TYPESCRIPT TYPE-LEVEL-PROGRAMMING ADVANCED-TYPESCRIPT TYPE-SAFETY DEVELOPER-EXPERIENCE PROGRAMMING WEB-DEVELOPMENT BACKEND-DEVELOPMENT FRONTEND-DEVELOPMENT SOFTWARE-ENGINEERING CODE-QUALITY DX METAPROGRAMMING

Type-Level Programming di TypeScript: Membangun Tipe Dinamis dan Kuat untuk Aplikasi Modern

⏱️ 10 menit baca
👨‍💻

Type-Level Programming di TypeScript: Membangun Tipe Dinamis dan Kuat untuk Aplikasi Modern

Selamat datang kembali di blog saya! Jika Anda seorang developer web yang menggunakan TypeScript, kemungkinan besar Anda sudah familiar dengan bagaimana TypeScript membantu Anda menangkap error lebih awal, memberikan autokompletasi yang luar biasa, dan membuat kode Anda lebih mudah dipahami. Tapi, tahukah Anda bahwa sistem tipe TypeScript itu sendiri bisa menjadi “bahasa pemrograman” yang sangat kuat?

Hari ini, kita akan menyelami dunia Type-Level Programming (TLP) di TypeScript. Ini bukan tentang menulis kode JavaScript atau TypeScript yang akan dieksekusi saat runtime, melainkan tentang menulis tipe yang akan dieksekusi oleh compiler TypeScript saat compile-time. Tujuannya? Untuk menciptakan tipe yang sangat dinamis, fleksibel, dan kuat, yang secara otomatis beradaptasi dengan perubahan data atau logika, memberikan keamanan yang tak tertandingi, dan meningkatkan Developer Experience (DX) secara signifikan.

📌 Mengapa TLP Penting untuk Anda? Di dunia web development modern, kompleksitas aplikasi terus meningkat. Mulai dari API yang dinamis, state management yang rumit, hingga library yang sangat fleksibel. TLP memungkinkan Anda:

Mari kita mulai petualangan kita di dunia tipe!

1. Fondasi TLP: Conditional Types dan infer

Conditional Types adalah tulang punggung dari Type-Level Programming. Mereka memungkinkan Anda untuk membuat keputusan di level tipe, mirip dengan operator ternary (condition ? trueValue : falseValue) di JavaScript.

type IsString<T> = T extends string ? true : false;

type A = IsString<"hello">; // type A = true
type B = IsString<123>;     // type B = false
type C = IsString<string>;  // type C = true

Contoh di atas sederhana, tapi bayangkan bagaimana kita bisa menggunakannya untuk logika yang lebih kompleks.

Selanjutnya, ada keyword infer. infer digunakan dalam Conditional Types untuk “menyimpulkan” (infer) tipe dari suatu posisi dan kemudian menggunakannya. Ini sangat powerful untuk mengekstrak bagian-bagian dari tipe yang lebih besar.

// Mengambil tipe kembalian dari sebuah fungsi
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

function greet(name: string): string {
    return `Hello, ${name}`;
}

type GreetResult = ReturnType<typeof greet>; // type GreetResult = string

// Mengambil tipe elemen dari sebuah array
type ElementType<T> = T extends (infer E)[] ? E : T;

type NumberArray = ElementType<number[]>; // type NumberArray = number
type StringArray = ElementType<string[]>; // type StringArray = string
type NotAnArray = ElementType<boolean>;  // type NotAnArray = boolean

💡 Tips: infer sangat berguna saat Anda ingin “membongkar” suatu tipe menjadi komponen-komponennya.

2. Mapped Types: Mengubah Struktur Properti Secara Dinamis

Mapped Types memungkinkan Anda untuk mengiterasi melalui properti dari suatu tipe dan mengubah propertinya. Ini mirip dengan map() pada array, tetapi untuk objek. Sintaksnya menggunakan [P in keyof T].

Beberapa utility types bawaan TypeScript seperti Partial<T>, Readonly<T>, dan Required<T> adalah contoh Mapped Types:

// Contoh implementasi Partial<T>
type MyPartial<T> = {
    [P in keyof T]?: T[P]; // Membuat semua properti menjadi opsional
};

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

type PartialUser = MyPartial<User>;
/*
type PartialUser = {
    id?: number | undefined;
    name?: string | undefined;
    email?: string | undefined;
}
*/

// Contoh implementasi Readonly<T>
type MyReadonly<T> = {
    readonly [P in keyof T]: T[P]; // Membuat semua properti menjadi readonly
};

type ReadonlyUser = MyReadonly<User>;
/*
type ReadonlyUser = {
    readonly id: number;
    readonly name: string;
    readonly email: string;
}
*/

Anda juga bisa memfilter properti dengan Conditional Types di dalam Mapped Types, atau mengubah nama properti.

// Pick<T, K> dan Omit<T, K> juga merupakan Mapped Types yang menggunakan Conditional Types
type MyPick<T, K extends keyof T> = {
    [P in K]: T[P];
};

type UserInfo = MyPick<User, "name" | "email">;
/*
type UserInfo = {
    name: string;
    email: string;
}
*/

🎯 Use Case Nyata: Membuat tipe untuk opsi konfigurasi yang dapat di-override sebagian (Partial), atau untuk state yang tidak boleh diubah (Readonly).

3. Template Literal Types: Bekerja dengan String di Level Tipe

Di TypeScript 4.1, kita mendapatkan Template Literal Types, yang memungkinkan kita membuat string literal types yang dinamis. Ini sangat berguna untuk skenario di mana nama properti atau nilai string memiliki pola tertentu.

type EventName<T extends string> = `${T}Event`;

type ClickEvent = EventName<"click">; // type ClickEvent = "clickEvent"
type SaveEvent = EventName<"save">;   // type SaveEvent = "saveEvent"

// Menggabungkan dengan Union Types
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type Endpoint = "/users" | "/products";

type ApiRoute = `${Lowercase<HttpMethod>}${Endpoint}`;
/*
type ApiRoute = "get/users" | "get/products" | "post/users" | "post/products" | "put/users" | "put/products" | "delete/users" | "delete/products"
*/

Template Literal Types juga dilengkapi dengan beberapa utility types untuk manipulasi string: Uppercase<T>, Lowercase<T>, Capitalize<T>, dan Uncapitalize<T>.

Keuntungan: Ini sangat powerful untuk membuat API yang sangat type-safe, misalnya untuk event emitter di mana nama event harus mengikuti pola tertentu, atau untuk rute API yang dinamis.

4. Recursive Conditional Types: Mengatasi Struktur Data Kompleks

Beberapa struktur data memiliki kedalaman (nesting) yang tidak terbatas atau bervariasi. Untuk menangani ini di level tipe, kita memerlukan rekursi. TypeScript memungkinkan Conditional Types untuk memanggil diri mereka sendiri secara rekursif, meskipun ada batasan kedalaman rekursi untuk mencegah infinite loop saat kompilasi.

Mari kita buat tipe DeepPartial<T> yang membuat semua properti, termasuk properti dari objek bersarang, menjadi opsional.

type DeepPartial<T> = T extends object ? {
    [P in keyof T]?: DeepPartial<T[P]>;
} : T;

interface Config {
    app: {
        name: string;
        version: string;
    };
    database: {
        host: string;
        port: number;
        user?: string;
    };
}

type PartialConfig = DeepPartial<Config>;
/*
type PartialConfig = {
    app?: {
        name?: string | undefined;
        version?: string | undefined;
    } | undefined;
    database?: {
        host?: string | undefined;
        port?: number | undefined;
        user?: string | undefined;
    } | undefined;
}
*/

Dalam contoh DeepPartial di atas:

⚠️ Perhatian: Rekursi tipe bisa menjadi sangat kompleks dan memakan waktu kompilasi yang lebih lama jika kedalamannya terlalu besar. Gunakan dengan bijak.

5. Studi Kasus: Membangun Type-Safe Event Emitter

Mari kita terapkan beberapa konsep TLP untuk membangun EventEmitter yang type-safe. Kita ingin agar saat mendaftarkan event listener (on), kita hanya bisa mendaftar untuk nama event yang valid, dan payload yang diterima oleh listener juga memiliki tipe yang benar.

interface AppEvents {
    "userLoggedIn": { userId: string; timestamp: number };
    "productAdded": { productId: string; quantity: number };
    "orderPlaced": { orderId: string; total: number };
}

class EventEmitter<Events extends Record<string, any>> {
    private listeners: {
        [K in keyof Events]?: Array<(payload: Events[K]) => void>;
    } = {};

    on<K extends keyof Events>(eventName: K, listener: (payload: Events[K]) => void) {
        if (!this.listeners[eventName]) {
            this.listeners[eventName] = [];
        }
        this.listeners[eventName]?.push(listener);
    }

    emit<K extends keyof Events>(eventName: K, payload: Events[K]) {
        this.listeners[eventName]?.forEach(listener => listener(payload));
    }

    off<K extends keyof Events>(eventName: K, listener: (payload: Events[K]) => void) {
        if (this.listeners[eventName]) {
            const index = this.listeners[eventName]?.indexOf(listener);
            if (index !== undefined && index > -1) {
                this.listeners[eventName]?.splice(index, 1);
            }
        }
    }
}

const appEmitter = new EventEmitter<AppEvents>();

// ✅ Autokompletasi untuk nama event dan payload berfungsi dengan baik
appEmitter.on("userLoggedIn", (data) => {
    console.log(`User ${data.userId} logged in at ${new Date(data.timestamp)}`);
});

appEmitter.on("productAdded", (data) => {
    console.log(`Product ${data.productId} added, quantity: ${data.quantity}`);
});

// ❌ Ini akan error di compile time karena 'unknownEvent' bukan bagian dari AppEvents
// appEmitter.on("unknownEvent", (data) => {});

// ❌ Ini akan error di compile time karena payload tidak sesuai dengan tipe 'orderPlaced'
// appEmitter.emit("orderPlaced", { id: "123", amount: 100 });

appEmitter.emit("userLoggedIn", { userId: "dev-andi", timestamp: Date.now() });
appEmitter.emit("orderPlaced", { orderId: "ORD-001", total: 250000 });

Dalam contoh ini:

Ini adalah contoh nyata bagaimana TLP dapat membuat kode Anda jauh lebih aman dan menyenangkan untuk digunakan!

6. Kapan dan Kapan Tidak Menggunakan TLP

TLP adalah alat yang sangat kuat, tetapi seperti alat lainnya, ada waktu dan tempat untuk menggunakannya.

Kapan Menggunakan TLP?

Kapan Tidak Menggunakan TLP? (atau Menggunakan dengan Hati-hati)

Intinya, TLP harus digunakan untuk memecahkan masalah nyata dan memberikan nilai tambah yang jelas, bukan sekadar memamerkan kemampuan TypeScript.

Kesimpulan

Type-Level Programming di TypeScript membuka dimensi baru dalam membangun aplikasi yang robust, fleksibel, dan memiliki developer experience yang luar biasa. Dengan menguasai Conditional Types, infer, Mapped Types, dan Template Literal Types, Anda memiliki kekuatan untuk membuat tipe yang beradaptasi secara dinamis dengan logika aplikasi Anda.

Meskipun TLP bisa terasa seperti “meta-programming” yang rumit pada awalnya, manfaatnya dalam mencegah bug, meningkatkan autokompletasi, dan memungkinkan pembuatan library yang kuat sangatlah besar. Mulailah dengan kasus sederhana, pahami setiap konsep, dan secara bertahap terapkan di proyek Anda. Selamat bereksperimen dengan kekuatan tipe!

🔗 Baca Juga