Pernah nggak sih, nulis kode TypeScript yang seharusnya type-safe tapi tetap terasa repetitif? Atau mungkin kamu udah lama pakai TypeScript tapi baru sampai di level interface, type, dan enum — belum pernah menyentuh fitur-fitur advanced-nya?
Kalau jawabannya iya, tenang — kamu nggak sendirian. Dulu aku juga lama stuck di “TypeScript basic” sebelum akhirnya berani ngulik Generic, Decorator, dan Utility Types. Dan jujur, setelah mulai pakai fitur-fitur ini, cara aku nulis kode berubah total. Kodenya jadi lebih fleksibel, lebih aman, dan yang paling penting: lebih sedikit duplikasi.
Di artikel ini, aku mau sharing pengalaman dan pengetahuan seputar tiga fitur advanced TypeScript ini. Nggak pakai gaya textbook, tapi kayak teman lagi ngobrol sambil ngoding. Yuk, mulai.
Kenapa Perlu Naik Level di TypeScript?
Sebelum masuk ke materi beratnya, penting banget buat paham mengapa fitur-fitur ini ada.
TypeScript pada dasarnya diciptakan untuk menambahkan static typing ke JavaScript. Di level basic, kita udah bisa nikmatin manfaatnya: autocomplete yang lebih pintar, error yang ketangkep sebelum runtime, dan dokumentasi kode yang lebih jelas lewat tipe.
Tapi begitu codebase mulai besar — apalagi kalau kamu kerja di tim — masalah baru muncul:
- Duplikasi kode yang brutal karena harus bikin fungsi mirip-mirip untuk tipe berbeda.
- Kode yang sulit di-maintain karena nggak ada cara elegan untuk menambah behavior ke class atau function.
- Tipe yang terlalu kaku, sulit di-transform sesuai kebutuhan tanpa kehilangan type safety.
Tiga masalah ini persis yang dijawab oleh Generic, Decorator, dan Utility Types. Masing-masing punya “domain” penyelesaiannya sendiri, dan begitu kamu paham kapan harus pakai yang mana, produktivitas kamu bakal naik drastis.
Generic: Menulis Sekali, Dipakai di Mana Saja
Apa Itu Generic?
Generic itu konsep di mana kamu bisa bikin komponen (function, class, interface) yang bisa bekerja dengan berbagai tipe tanpa harus kehilangan type safety. Bayangin kamu punya fungsi yang bisa menerima string, number, atau bahkan object custom — tapi tetap type-safe.
Tanpa Generic, kamu mungkin bakal melakukan ini:
function getFirstString(items: string[]): string | undefined {
return items[0];
}
function getFirstNumber(items: number[]): number | undefined {
return items[0];
}
Dua fungsi yang secara logika identik, cuma beda tipe. Ini contoh sempurna duplikasi yang bisa dieliminasi pakai Generic.
Generic Function
Dengan Generic, cukup satu fungsi:
function getFirst<T>(items: T[]): T | undefined {
return items[0];
}
// TypeScript otomatis menginferensi tipe T
const firstString = getFirst(["hello", "world"]); // string | undefined
const firstNumber = getFirst([10, 20, 30]); // number | undefined
Parameter T di sini disebut type parameter. Kamu bisa pakai huruf apa saja, tapi konvensi umumnya memang T (singkatan dari Type), U, K, V, dst.
Yang bikin Generic powerful adalah inferensi tipe. Kamu nggak perlu selalu eksplisit menyebut <string> atau <number> — TypeScript cukup pintar untuk mendeteksinya dari konteks.
Generic dengan Constraint
Kadang kita nggak mau T bisa jadi apa saja. Kita mau membatasinya. Di sinilah extends masuk:
interface HasLength {
length: number;
}
function logLength<T extends HasLength>(value: T): T {
console.log(`Length: ${value.length}`);
return value;
}
logLength("hello"); // ✅ string punya .length
logLength([1, 2, 3]); // ✅ array punya .length
logLength({ length: 10 }); // ✅ object dengan property length
// logLength(123); // ❌ number nggak punya .length
Constraint ini bikin Generic tetap fleksibel tapi nggak liar. Kamu bisa memastikan bahwa T selalu punya fitur tertentu yang kamu butuhkan.
Generic pada Interface dan Class
Generic bukan cuma untuk function. Di interface dan class, ini bahkan lebih sering dipakai di dunia nyata:
// Generic Interface — contoh: API Response wrapper
interface ApiResponse<T> {
data: T;
status: number;
message: string;
timestamp: Date;
}
// Dipakai untuk berbagai endpoint
const userResponse: ApiResponse<{ id: number; name: string }> = {
data: { id: 1, name: "Budi" },
status: 200,
message: "Success",
timestamp: new Date(),
};
const productsResponse: ApiResponse<Array<{ id: number; title: string; price: number }>> = {
data: [
{ id: 1, title: "Keyboard", price: 350000 },
{ id: 2, title: "Mouse", price: 150000 },
],
status: 200,
message: "Success",
timestamp: new Date(),
};
Di contoh di atas, struktur ApiResponse-nya sama — yang beda cuma isi data-nya. Tanpa Generic, kamu harus bikin UserApiResponse, ProductApiResponse, dst.
Sekarang coba lihat Generic di class:
class DataStore<T> {
private items: T[] = [];
add(item: T): void {
this.items.push(item);
}
getAll(): T[] {
return [...this.items];
}
findBy(predicate: (item: T) => boolean): T | undefined {
return this.items.find(predicate);
}
}
// Type-safe store untuk user
const userStore = new DataStore<{ id: number; name: string }>();
userStore.add({ id: 1, name: "Andi" });
// userStore.add({ id: "abc" }); // ❌ Error: 'id' harus number
const found = userStore.findBy((u) => u.id === 1);
console.log(found?.name); // "Andi"
DataStore<T> bisa dipakai untuk menyimpan data apa pun dengan struktur yang kamu tentukan. Ini pattern yang sangat umum di repository layer aplikasi backend.
Multiple Type Parameters
Kamu juga bisa pakai lebih dari satu type parameter. Contoh klasiknya adalah fungsi merge:
function merge<T, U>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
const result = merge({ name: "Budi" }, { age: 25 });
// result: { name: string } & { age: number }
console.log(result.name); // "Budi"
console.log(result.age); // 25
T & U di sini adalah intersection type — hasilnya punya semua property dari kedua objek. Tanpa Generic, fungsi ini either kehilangan type safety atau harus mengandalkan any.
Decorator: Menambah Behavior Tanpa Mengubah Kode Asli
Apa Itu Decorator?
Decorator adalah special kind of declaration yang bisa dilampirkan ke class, method, property, atau parameter. Fungsinya? Menambah metadata atau behavior tambahan tanpa menyentuh kode aslinya.
Kalau kamu pernah pakai framework kayak NestJS atau Angular, kamu pasti sering lihat decorator:
@Controller('/users')
class UserController {
@Get('/:id')
getUser(@Param('id') id: string) {
// ...
}
}
@Controller, @Get, @Param — itu semua decorator. Dan di balik layar, mereka hanyalah function biasa yang menerima target dan mengubahnya.
Cara Kerja Decorator
Decorator pada dasarnya adalah function yang dipanggil dengan cara khusus. Untuk mengaktifkannya, kamu perlu set experimentalDecorators: true di tsconfig.json:
{
"compilerOptions": {
"experimentalDecorators": true,
"target": "ES2021",
"emitDecoratorMetadata": true
}
}
Mari kita bikin decorator sederhana dari nol. Contoh paling intuitif: decorator untuk logging.
Method Decorator
function LogExecutionTime(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
const start = performance.now();
const result = originalMethod.apply(this, args);
const end = performance.now();
console.log(`[${propertyKey}] executed in ${(end - start).toFixed(2)}ms`);
return result;
};
return descriptor;
}
class MathService {
@LogExecutionTime
heavyCalculation(n: number): number {
let result = 0;
for (let i = 0; i < n; i++) {
result += Math.sqrt(i);
}
return result;
}
}
const service = new MathService();
service.heavyCalculation(1000000);
// Output: [heavyCalculation] executed in 12.34ms
Lihat apa yang terjadi? Kita sama sekali nggak mengubah isi heavyCalculation. Behavior logging ditambahkan dari luar via decorator. Ini prinsip Open/Closed Principle dalam praktik — kode terbuka untuk ekstensi, tertutup untuk modifikasi.
Class Decorator
Decorator juga bisa diterapkan ke class itu sendiri. Contoh: menambahkan property atau memodifikasi constructor.
function Sealed(constructor: Function) {
Object.seal(constructor);
Object.seal(constructor.prototype);
}
@Sealed
class Vehicle {
constructor(public brand: string, public year: number) {}
}
// Mencoba menambah property baru ke prototype akan gagal
// (dalam strict mode)
Decorator Factory
Seringkali kamu butuh decorator yang bisa dikonfigurasi. Caranya? Bungkus decorator dalam function yang mengembalikan decorator. Ini disebut decorator factory:
function MinLength(min: number) {
return function (target: any, propertyKey: string) {
let value: string;
const getter = () => value;
const setter = (newVal: string) => {
if (newVal.length < min) {
throw new Error(
`${propertyKey} harus minimal ${min} karakter.`
);
}
value = newVal;
};
Object.defineProperty(target, propertyKey, {
get: getter,
set: setter,
});
};
}
class UserForm {
@MinLength(3)
username: string = "";
@MinLength(8)
password: string = "";
}
const form = new UserForm();
form.username = "ab"; // ❌ Error: username harus minimal 3 karakter.
form.password = "securePass123"; // ✅
Contoh di atas sangat applicable di real-world: validasi form input. Bayangin kamu bisa bikin decorator @Required, @Email, @MaxValue(100), dst. Tanpa perlu menulis logic validasi di dalam class.
Kapan Sebaiknya Pakai Decorator?
Ini catatan penting. Decorator itu powerful, tapi bukan berarti harus dipakai di mana-mana. Pakai decorator ketika:
- Kamu menambahkan cross-cutting concerns: logging, caching, authentication check, validation.
- Kamu bekerja di framework yang memang mendukung decorator (NestJS, Angular, TypeORM).
- Kamu ingin memisahkan apa yang dilakukan dari bagaimana caranya.
Jangan pakai decorator untuk hal yang bisa diselesaikan dengan function biasa. Over-engineering itu nyata, dan decorator yang berlebihan bikin kode sulit di-trace.
Utility Types: Transformasi Tipe Tanpa Ribet
Apa Itu Utility Types?
Utility Types adalah built-in helper types yang disediakan TypeScript untuk memanipulasi tipe. Bayangin kamu punya interface, lalu kamu mau bikin versi di mana semua propertinya optional, atau cuma sebagian yang diambil, atau semua propertinya read-only. Tanpa Utility Types, kamu harus mendefinisikan ulang interface-nya.
Dengan Utility Types? Tinggal pakai.
Partial dan Required
Paling sering dipakai di situasi update data:
interface Product {
id: number;
name: string;
price: number;
category: string;
inStock: boolean;
}
// Saat update, nggak semua field perlu dikirim
function updateProduct(id: number, updates: Partial<Product>): Product {
const existing = getProductById(id); // assume ini ada
return { ...existing, ...updates };
}
// Hanya name dan price yang mau di-update
updateProduct(1, { name: "Keyboard RGB", price: 450000 });
Partial<Product> mengubah semua property jadi optional. Sebaliknya, Required<Product> mengubah semua property jadi wajib.
interface Config {
host?: string;
port?: number;
debug?: boolean;
}
// Semua property sekarang wajib
const fullConfig: Required<Config> = {
host: "localhost",
port: 3000,
debug: false,
};
Pick dan Omit
Kadang kamu cuma butuh sebagian property dari interface:
// Cuma butuh name dan price
type ProductPreview = Pick<Product, "name" | "price">;
const preview: ProductPreview = {
name: "Mouse Logitech",
price: 250000,
// id, category, inStock tidak diperlukan di sini
};
Sebaliknya, Omit menghilangkan property tertentu:
// Semua kecuali 'id' — berguna untuk create payload
type CreateProductPayload = Omit<Product, "id">;
const newProduct: CreateProductPayload = {
name: "Monitor 24 inch",
price: 2500000,
category: "Electronics",
inStock: true,
};
Pattern Omit<Entity, 'id'> untuk create payload dan Partial<Entity> untuk update payload itu sangat umum di codebase production. Sekarang kamu tahu cara bikinnya secara type-safe.
Record
Record<K, V> bikin tipe object dengan key tertentu dan value tertentu:
type Role = "admin" | "user" | "guest";
type RolePermissions = Record<Role, string[]>;
const permissions: RolePermissions = {
admin: ["read", "write", "delete", "manage_users"],
user: ["read", "write"],
guest: ["read"],
};
// permissions.moderator; // ❌ Error: 'moderator' bukan Role
Record sangat berguna untuk mapping, lookup table, dan konfigurasi berbasis key.
Readonly
Mau memastikan property nggak bisa diubah setelah diinisialisasi?
interface Point {
x: number;
y: number;
}
const origin: Readonly<Point> = { x: 0, y: 0 };
// origin.x = 5; // ❌ Cannot assign to 'x' because it is a read-only property
ReturnType dan Parameters
Dua utility type ini bikin kamu bisa mengekstrak tipe return dan parameter dari sebuah function:
function createUser(name: string, age: number) {
return { id: Math.random(), name, age, createdAt: new Date() };
}
// Ekstrak tipe return value
type User = ReturnType<typeof createUser>;
// { id: number; name: string; age: number; createdAt: Date }
// Ekstrak tipe parameter
type CreateUserParams = Parameters<typeof createUser>;
// [string, number]
Ini super powerful di situasi di mana kamu mau bikin tipe yang bergantung pada function lain. Kamu nggak perlu mendefinisikan ulang — cukup ekstrak dari function yang sudah ada.
Membangun Custom Utility Type
Kamu juga bisa bikin utility type sendiri. Contoh: bikin tipe yang membuat semua nested properties menjadi optional (deep partial):
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object
? DeepPartial<T[K]>
: T[K];
};
interface Settings {
display: {
theme: "light" | "dark";
fontSize: number;
notifications: {
email: boolean;
push: boolean;
};
};
language: string;
}
// Semua nested property jadi optional
const partialSettings: DeepPartial<Settings> = {
display: {
theme: "dark",
// fontSize dan notifications bisa di-skip
},
// language juga bisa di-skip
};
Contoh di atas pakai mapped types dan conditional types — dua konsep advanced lain yang saling melengkapi dengan utility types.
Menggabungkan Ketiganya dalam Project Nyata
Sekarang pertanyaannya: gimana cara pakai ketiga fitur ini bersamaan?
Bayangin kamu bikin sistem ORM mini. Kamu butuh:
- Generic untuk repository yang bisa handle entity apa pun.
- Decorator untuk menandai kolom, validasi, dan metadata entity.
- Utility Types untuk membuat create/update payload secara otomatis.
// === DECORATORS ===
function Entity(tableName: string) {
return function (constructor: Function) {
constructor.prototype._tableName = tableName;
};
}
function Column(target: any, propertyKey: string) {
if (!target._columns) {
target._columns = [];
}
target._columns.push(propertyKey);
}
// === ENTITY ===
@Entity("users")
class UserEntity {
@Column id!: number;
@Column name!: string;
@Column email!: string;
createdAt!: Date; // nggak di-decorate, bukan kolom
}
// === GENERIC REPOSITORY ===
class Repository<T extends { id: number }> {
constructor(private tableName: string) {}
findById(id: number): T | undefined {
console.log(`SELECT * FROM ${this.tableName} WHERE id = ${id}`);
return undefined; // placeholder
}
create(data: Omit<T, "id">): T {
console.log(`INSERT INTO ${this.tableName}`, data);
return { ...data, id: Date.now() } as unknown as T;
}
update(id: number, data: Partial<Omit<T, "id">>): T | undefined {
console.log(`UPDATE ${this.tableName} SET ... WHERE id = ${id}`, data);
return undefined; // placeholder
}
}
// === PENGGUNAAN ===
const userRepo = new Repository<UserEntity>("users");
userRepo.create({
name: "Sari",
email: "[email protected]",
// id auto-generated
});
userRepo.update(1, { name: "Sari Updated" });
Di contoh ini, Generic bikin Repository<T> reusable untuk entitas apa pun. Decorator menambah metadata nama tabel dan kolom. Utility Types (Omit<T, 'id'> dan Partial<>) memastikan payload create dan update selalu type-safe.
Ketiga fitur ini bekerja sama menghasilkan kode yang DRY (Don’t Repeat Yourself), type-safe, dan mudah dirawat.
Tips Praktis dari Pengalaman
Setelah pakai fitur-fitur ini di berbagai project, berikut beberapa tips yang bisa aku share:
1. Mulai dari Generic dulu. Dari ketiga fitur, Generic adalah yang paling fundamental dan paling sering dipakai. Kuasai ini sebelum lanjut ke Decorator.
2. Decorator butuh framework yang mendukung. Kalau kamu pakai Express murni, decorator kurang terasa manfaatnya. Tapi kalau pakai NestJS, TypeORM, atau class-validator, paham decorator itu essential.
3. Utility Types sering di-overlook. Banyak developer TypeScript yang sudah advanced pun masih bikin type baru secara manual padahal bisa pakai Pick, Omit, atau Partial. Biasakan cek daftar utility types bawaan TypeScript sebelum bikin tipe baru.
4. Jangan over-engineer. Generic yang terlalu kompleks bikin tipe error jadi susah dipahami. Decorator yang terlalu banyak bikin alur program sulit di-trace. Pakai seperlunya.
5. Baca source code framework favoritmu. Kalau kamu mau paham cara Generic dan Decorator dipakai di skala besar, baca source code NestJS atau TypeORM. Kamu akan belajar banyak dari pattern yang mereka gunakan.
Penutup
TypeScript advanced bukan sekadar “tahu” — tapi kapan dan bagaimana memakainya. Generic bikin kode reusable tanpa kehilangan type safety. Decorator bikin kode modular dan bersih. Utility Types bikin transformasi tipe jadi trivial.
Kalau kamu udah sampai di titik ini, selamat! Kamu udah melewati batas yang memisahkan “pengguna TypeScript” dari “developer TypeScript yang produktif.” Terus eksplor, terus praktik, dan yang paling penting — terus baca dokumentasi resmi TypeScript. Ada banyak utility types dan fitur type system lain yang nggak sempat kita bahas di sini.
Semoga artikel ini bermanfaat. Kalau ada pertanyaan, saran, atau mau diskusi lebih lanjut soal TypeScript, jangan ragu buat reach out!
Ada pertanyaan atau mau diskusi lebih lanjut soal proyek TypeScript-mu? Kirim email ke [email protected] — aku senang bisa ngobrol dan bantu!
FAQ (Pertanyaan yang Sering Ditanyakan)
1. Apa bedanya Generic dengan pakai any di TypeScript?
any mematikan type checking sepenuhnya — kamu kehilangan semua keuntungan TypeScript. Generic, di sisi lain, tetap mempertahankan type safety. Saat kamu pakai T, TypeScript tetap meng-track tipe aktual yang digunakan dan memberikan autocomplete serta error checking yang tepat. Pakai any itu “jalan pintas” yang mengorbankan keamanan; Generic itu solusi yang benar secara arsitektur.
2. Apakah Decorator sudah stabil di TypeScript?
Sejak TypeScript 5.0, ECMAScript Decorators (sesuai proposal TC39 stage 3) sudah didukung secara resmi. Ini berbeda dari experimentalDecorators yang sudah ada sebelumnya. Versi baru ini lebih konsisten dengan standar JavaScript dan tidak lagi “experimental”. Namun, banyak library besar (NestJS, TypeORM) masih menggunakan versi lama, jadi sebaiknya sesuaikan dengan ekosistem yang kamu pakai.
3. Kapan sebaiknya bikin custom Utility Type daripada pakai yang built-in?
Pakai utility type built-in (Partial, Pick, Omit, Record, dll.) kalau kebutuhanmu tercukupi. Bikin custom kalau kamu butuh transformasi yang lebih kompleks, seperti DeepPartial (partial secara nested), Nullable<T> (membuat semua property bisa null), atau tipe kondisional berdasarkan business logic tertentu. Kuncinya: kalau kamu menulis type mapping yang sama di lebih dari dua tempat, kemungkinan besar itu layak jadi custom utility type.
4. Apakah ketiga fitur ini bisa dipakai di project frontend (React/Vue)?
Tentu! Generic sangat umum di React untuk component props (React.FC<Props>), hooks custom, dan state management. Decorator jarang di React tapi sangat umum di Angular. Utility Types dipakai di mana saja — frontend maupun backend — karena ini fitur dasar TypeScript, bukan framework-specific. Di React, kamu akan sering pakai Pick, Omit, dan Partial untuk mengelola props type.
5. Bagaimana cara belajar fitur advanced TypeScript secara efektif?
Fokus pada proyek nyata, bukan sekadar baca dokumentasi. Coba refactor codebase lama pakai Generic. Buat mini framework pakai Decorator. Transform existing types pakai Utility Types. Baca source code library open source yang pakai TypeScript. Dan yang paling penting: aktifkan strict: true di tsconfig.json — ini akan "