Lompat ke konten
·19 menit membaca

TypeScript Advanced Types: Keajaiban Conditional, Mapped, dan Template Literal

Pembahasan mendalam tentang fitur type paling powerful di TypeScript — conditional types, mapped types, keyword infer, dan template literal types. Pola-pola nyata yang benar-benar akan kamu gunakan.

Bagikan:X / TwitterLinkedIn

Kebanyakan developer TypeScript mentok di titik yang sama. Mereka sudah paham interfaces, generics, union types. Bisa nge-type React component. Bisa menghilangkan garis merah di editor. Tapi mereka memperlakukan type system sebagai kejahatan yang perlu — sesuatu yang ada untuk mencegah bug, bukan sesuatu yang secara aktif membantu mereka mendesain software yang lebih baik.

Saya bertahan di titik itu selama sekitar dua tahun. Yang membuat saya keluar adalah kesadaran bahwa type system TypeScript itu sebenarnya bahasa pemrograman tersendiri. Ia punya conditionals, loops, pattern matching, rekursi, dan manipulasi string. Begitu kamu menginternalisasi itu, semuanya berubah. Kamu berhenti melawan compiler dan mulai berkolaborasi dengannya.

Tulisan ini membahas fitur type-level yang saya gunakan terus-menerus di kode produksi. Bukan latihan akademis — pola-pola nyata yang telah menyelamatkan saya dari bug yang nyata.

Mindset Pemrograman Type-Level#

Sebelum masuk ke sintaks, saya ingin mengubah cara pandang kamu tentang types.

Dalam pemrograman value-level, kamu menulis fungsi yang mentransformasi data saat runtime:

typescript
function extractIds(users: User[]): number[] {
  return users.map(u => u.id);
}

Dalam pemrograman type-level, kamu menulis types yang mentransformasi types lain saat compile time:

typescript
type ExtractIds<T extends { id: unknown }[]> = T[number]["id"];

Mental modelnya sama — input, transformasi, output. Bedanya, kode type-level berjalan saat kompilasi, bukan saat runtime. Ia menghasilkan nol JavaScript. Satu-satunya tujuannya adalah membuat state yang mustahil menjadi tidak bisa direpresentasikan.

Kalau kamu pernah menulis fungsi di mana tiga dari empat parameternya hanya masuk akal dalam kombinasi tertentu, atau di mana return type-nya tergantung apa yang kamu masukkan, kamu sebenarnya sudah membutuhkan pemrograman type-level. Kamu mungkin hanya belum tahu TypeScript bisa mengekspresikannya.

Conditional Types#

Conditional types adalah if/else dari type system. Sintaksnya mirip ternary:

typescript
type IsString<T> = T extends string ? true : false;
 
type A = IsString<"hello">; // true
type B = IsString<42>;      // false
type C = IsString<string>;  // true

Keyword extends di sini bukan berarti inheritance. Artinya "bisa di-assign ke." Bayangkan sebagai "apakah T cocok dengan bentuk string?"

Membangun Utility Types Sendiri#

Mari kita buat ulang beberapa utility types bawaan TypeScript untuk memahami cara kerjanya.

NonNullable — menghapus null dan undefined dari sebuah union:

typescript
type MyNonNullable<T> = T extends null | undefined ? never : T;
 
type Test = MyNonNullable<string | null | undefined>;
// Result: string

Type never adalah "himpunan kosong" — menghapus sesuatu dari union. Ketika sebuah branch dari conditional type menghasilkan never, anggota union tersebut langsung menghilang.

Extract — hanya menyimpan anggota union yang cocok dengan constraint:

typescript
type MyExtract<T, U> = T extends U ? T : never;
 
type Numbers = Extract<string | number | boolean, number | boolean>;
// Result: number | boolean

Exclude — kebalikannya, menghapus anggota yang cocok:

typescript
type MyExclude<T, U> = T extends U ? never : T;
 
type WithoutStrings = Exclude<string | number | boolean, string>;
// Result: number | boolean

Distributive Conditional Types#

Di sinilah hal-hal mulai menarik, dan di mana kebanyakan orang bingung. Ketika kamu memasukkan union type ke conditional type, TypeScript mendistribusikan kondisi tersebut ke setiap anggota union secara individual.

typescript
type ToArray<T> = T extends unknown ? T[] : never;
 
type Result = ToArray<string | number>;
// Kamu mungkin mengira: (string | number)[]
// Hasil sebenarnya:    string[] | number[]

TypeScript tidak mengevaluasi (string | number) extends unknown. Sebaliknya, ia mengevaluasi string extends unknown dan number extends unknown secara terpisah, lalu meng-union hasilnya.

Inilah mengapa Extract dan Exclude bekerja seperti itu. Distribusi terjadi otomatis ketika T adalah type parameter "telanjang" (tidak dibungkus apa-apa).

Kalau kamu ingin mencegah distribusi, bungkus kedua sisi dalam tuple:

typescript
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
 
type Result = ToArrayNonDist<string | number>;
// Result: (string | number)[]

Saya sudah kena masalah ini lebih sering dari yang mau saya akui. Kalau conditional type-mu memberikan hasil tak terduga dengan input union, distribusi hampir selalu penyebabnya.

Contoh Praktis: API Response Handler#

Ini pola yang saya gunakan saat berurusan dengan API response yang bisa berhasil atau gagal:

typescript
type ApiResponse<T> =
  | { status: "success"; data: T; error: never }
  | { status: "error"; data: never; error: string };
 
type ExtractData<R> = R extends { status: "success"; data: infer D } ? D : never;
type ExtractError<R> = R extends { status: "error"; error: infer E } ? E : never;
 
type UserResponse = ApiResponse<{ id: number; name: string }>;
 
type UserData = ExtractData<UserResponse>;
// Result: { id: number; name: string }
 
type UserError = ExtractError<UserResponse>;
// Result: string

Ini lebih bersih daripada menulis type guards manual untuk setiap bentuk response.

Keyword infer#

infer adalah pattern matching untuk types. Ia memungkinkan kamu mendeklarasikan variabel type di dalam conditional type yang akan TypeScript tentukan untukmu.

Bayangkan sebagai: "Saya tahu type ini punya bentuk tertentu. Ambilkan bagian yang saya butuhkan."

Mengekstrak Return Types#

typescript
type MyReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;
 
type Fn = (x: number, y: string) => boolean;
type Result = MyReturnType<Fn>; // boolean

TypeScript melihat pola (...args) => something, mencocokkan fungsimu dengannya, dan meng-assign return type ke R.

Mengekstrak Parameter Types#

typescript
type MyParameters<T> = T extends (...args: infer P) => unknown ? P : never;
 
type Fn = (x: number, y: string) => void;
type Params = MyParameters<Fn>; // [x: number, y: string]

Membongkar Promises#

Yang ini sering banget saya pakai saat bekerja dengan fungsi async:

typescript
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;
 
type A = UnpackPromise<Promise<string>>;  // string
type B = UnpackPromise<Promise<number[]>>; // number[]
type C = UnpackPromise<string>;            // string (passthrough)

Untuk promises yang nested dalam:

typescript
type DeepUnpackPromise<T> = T extends Promise<infer U>
  ? DeepUnpackPromise<U>
  : T;
 
type Deep = DeepUnpackPromise<Promise<Promise<Promise<number>>>>;
// Result: number

Mengekstrak Element Type dari Array#

typescript
type ElementOf<T> = T extends (infer E)[] ? E : never;
 
type Items = ElementOf<string[]>;         // string
type Mixed = ElementOf<(string | number)[]>; // string | number

Multiple infer dalam Satu Type#

Kamu bisa menggunakan infer beberapa kali untuk mengekstrak bagian yang berbeda:

typescript
type FirstAndLast<T extends unknown[]> =
  T extends [infer First, ...unknown[], infer Last]
    ? { first: First; last: Last }
    : never;
 
type Result = FirstAndLast<[1, 2, 3, 4]>;
// Result: { first: 1; last: 4 }

Dunia Nyata: Type-Safe Event Extraction#

Ini pola yang saya gunakan di sistem berbasis event:

typescript
type EventMap = {
  "user:login": { userId: string; timestamp: number };
  "user:logout": { userId: string };
  "order:created": { orderId: string; total: number };
  "order:shipped": { orderId: string; trackingId: string };
};
 
type EventName = keyof EventMap;
type EventPayload<E extends EventName> = EventMap[E];
 
// Ekstrak semua event terkait user
type UserEvents = Extract<EventName, `user:${string}`>;
// Result: "user:login" | "user:logout"
 
// Dapatkan payload untuk user events saja
type UserEventPayloads = EventPayload<UserEvents>;
// Result: { userId: string; timestamp: number } | { userId: string }

Mapped Types#

Mapped types memungkinkan kamu membuat object type baru dengan mentransformasi setiap property dari type yang sudah ada. Mereka adalah map() dari type system.

Dasar-Dasar#

typescript
type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};
 
type Partial<T> = {
  [K in keyof T]?: T[K];
};

keyof T memberikanmu union dari semua property keys. [K in ...] melakukan iterasi ke setiap key. T[K] adalah type dari property tersebut.

Membangun Utility Types dari Nol#

Required — membuat semua properties menjadi wajib:

typescript
type MyRequired<T> = {
  [K in keyof T]-?: T[K];
};

Sintaks -? menghapus modifier optional. Demikian pula, -readonly menghapus readonly.

Pick — memilih properties tertentu:

typescript
type MyPick<T, K extends keyof T> = {
  [P in K]: T[P];
};
 
interface User {
  id: number;
  name: string;
  email: string;
  password: string;
}
 
type PublicUser = MyPick<User, "id" | "name" | "email">;
// { id: number; name: string; email: string }

Omit — menghapus properties tertentu:

typescript
type MyOmit<T, K extends keyof T> = {
  [P in Exclude<keyof T, K>]: T[P];
};
 
type SafeUser = MyOmit<User, "password">;
// { id: number; name: string; email: string }

Record — membuat object type dengan keys dan value types tertentu:

typescript
type MyRecord<K extends string | number | symbol, V> = {
  [P in K]: V;
};
 
type StatusMap = MyRecord<"active" | "inactive" | "banned", boolean>;
// { active: boolean; inactive: boolean; banned: boolean }

Key Remapping dengan as#

TypeScript 4.1 memperkenalkan key remapping, yang memungkinkanmu mentransformasi nama property:

typescript
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
 
interface Person {
  name: string;
  age: number;
  email: string;
}
 
type PersonGetters = Getters<Person>;
// {
//   getName: () => string;
//   getAge: () => number;
//   getEmail: () => string;
// }

Kamu juga bisa memfilter properties dengan me-remap ke never:

typescript
type OnlyStrings<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};
 
type StringProps = OnlyStrings<Person>;
// { name: string; email: string }

Ini sangat powerful. Kamu secara bersamaan melakukan iterasi property, memfilternya, dan mentransformasi namanya — semua di level type.

Contoh Praktis: Form Validation Types#

Saya sudah menggunakan pola ini di beberapa form library:

typescript
interface FormFields {
  username: string;
  email: string;
  age: number;
  bio: string;
}
 
type ValidationErrors<T> = {
  [K in keyof T]?: string[];
};
 
type TouchedFields<T> = {
  [K in keyof T]?: boolean;
};
 
type DirtyFields<T> = {
  [K in keyof T]?: boolean;
};
 
type FormState<T> = {
  values: T;
  errors: ValidationErrors<T>;
  touched: TouchedFields<T>;
  dirty: DirtyFields<T>;
  isValid: boolean;
  isSubmitting: boolean;
};
 
// Sekarang form state-mu sepenuhnya typed berdasarkan fields
type MyFormState = FormState<FormFields>;

Tambahkan field ke FormFields, dan setiap type terkait otomatis terupdate. Hapus field, dan compiler menangkap setiap referensi. Inilah hal yang mencegah seluruh kategori bug.

Template Literal Types#

Template literal types memungkinkanmu memanipulasi string di level type. Mereka menggunakan sintaks backtick yang sama dengan JavaScript template literals, tapi untuk types.

Manipulasi String Dasar#

typescript
type Greeting = `Hello, ${string}`;
 
const a: Greeting = "Hello, world";    // OK
const b: Greeting = "Hello, TypeScript"; // OK
// const c: Greeting = "Hi, world";    // Error!

TypeScript juga menyediakan type manipulasi string bawaan:

typescript
type Upper = Uppercase<"hello">;     // "HELLO"
type Lower = Lowercase<"HELLO">;     // "hello"
type Cap = Capitalize<"hello">;      // "Hello"
type Uncap = Uncapitalize<"Hello">;  // "hello"

Pola Event Name#

Di sinilah template literal types benar-benar bersinar. Ini pola yang saya gunakan untuk sistem event yang type-safe:

typescript
type EventNames<T extends string> = `${T}Changed` | `${T}Deleted` | `${T}Created`;
 
type ModelEvents = EventNames<"user" | "order" | "product">;
// "userChanged" | "userDeleted" | "userCreated"
// | "orderChanged" | "orderDeleted" | "orderCreated"
// | "productChanged" | "productDeleted" | "productCreated"

Sembilan event dari dua type parameter. Dan semuanya type-safe.

Pola CSS-in-TypeScript#

Template literal types bisa menerapkan pola CSS di level type:

typescript
type CSSUnit = "px" | "rem" | "em" | "vh" | "vw" | "%";
type CSSValue = `${number}${CSSUnit}`;
 
function setWidth(value: CSSValue): void {
  // implementation
}
 
setWidth("100px");   // OK
setWidth("2.5rem");  // OK
setWidth("50%");     // OK
// setWidth("100");  // Error: bukan CSS value yang valid
// setWidth("big");  // Error

Parsing String dengan Template Literals#

Kamu bisa menggunakan template literal types dengan infer untuk mem-parse string:

typescript
type ParseRoute<T extends string> =
  T extends `${infer _Start}:${infer Param}/${infer Rest}`
    ? Param | ParseRoute<`/${Rest}`>
    : T extends `${infer _Start}:${infer Param}`
      ? Param
      : never;
 
type RouteParams = ParseRoute<"/users/:userId/posts/:postId">;
// Result: "userId" | "postId"

Ini adalah fondasi cara kerja type-safe routing library seperti tRPC. String /users/:userId/posts/:postId di-parse di level type untuk mengekstrak nama parameter.

Menggabungkan Template Literals dengan Mapped Types#

Di sinilah hal-hal menjadi benar-benar powerful:

typescript
type EventHandlers<T extends Record<string, unknown>> = {
  [K in keyof T as `on${Capitalize<string & K>}Change`]: (
    newValue: T[K],
    oldValue: T[K]
  ) => void;
};
 
interface ThemeConfig {
  color: string;
  fontSize: number;
  darkMode: boolean;
}
 
type ThemeHandlers = EventHandlers<ThemeConfig>;
// {
//   onColorChange: (newValue: string, oldValue: string) => void;
//   onFontSizeChange: (newValue: number, oldValue: number) => void;
//   onDarkModeChange: (newValue: boolean, oldValue: boolean) => void;
// }

Dari sebuah config interface sederhana, kamu mendapatkan event handlers yang sepenuhnya typed dengan parameter types yang tepat. Inilah jenis kode type-level yang benar-benar menghemat waktu development.

Lanjutan: Dot-Notation Path Types#

Ini pola yang lebih advanced — menghasilkan semua kemungkinan jalur dot-notation melalui nested object:

typescript
type DotPaths<T, Prefix extends string = ""> = T extends object
  ? {
      [K in keyof T & string]: T[K] extends object
        ? `${Prefix}${K}` | DotPaths<T[K], `${Prefix}${K}.`>
        : `${Prefix}${K}`;
    }[keyof T & string]
  : never;
 
interface Config {
  database: {
    host: string;
    port: number;
    credentials: {
      username: string;
      password: string;
    };
  };
  server: {
    port: number;
  };
}
 
type ConfigPaths = DotPaths<Config>;
// "database" | "database.host" | "database.port"
// | "database.credentials" | "database.credentials.username"
// | "database.credentials.password" | "server" | "server.port"

Sekarang kamu bisa menulis fungsi getConfig yang autocomplete setiap kemungkinan path dan mengembalikan type yang tepat untuk masing-masing:

typescript
type GetValueByPath<T, P extends string> =
  P extends `${infer Key}.${infer Rest}`
    ? Key extends keyof T
      ? GetValueByPath<T[Key], Rest>
      : never
    : P extends keyof T
      ? T[P]
      : never;
 
function getConfig<P extends ConfigPaths>(path: P): GetValueByPath<Config, P> {
  // implementation
  const keys = path.split(".");
  let result: unknown = config;
  for (const key of keys) {
    result = (result as Record<string, unknown>)[key];
  }
  return result as GetValueByPath<Config, P>;
}
 
const host = getConfig("database.host");         // type: string
const port = getConfig("database.port");         // type: number
const username = getConfig("database.credentials.username"); // type: string

Autocomplete penuh, type safety penuh, nol overhead runtime dari types.

Recursive Types#

TypeScript mendukung definisi type rekursif, yang memungkinkanmu menangani struktur nested sedalam apa pun.

Deep Partial#

Partial<T> bawaan hanya membuat properties top-level menjadi optional. Untuk nested objects, kamu butuh rekursi:

typescript
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object
    ? T[K] extends Array<infer U>
      ? Array<DeepPartial<U>>
      : DeepPartial<T[K]>
    : T[K];
};
 
interface Settings {
  theme: {
    colors: {
      primary: string;
      secondary: string;
    };
    fonts: {
      heading: string;
      body: string;
    };
  };
  notifications: {
    email: boolean;
    push: boolean;
  };
}
 
type PartialSettings = DeepPartial<Settings>;
// Setiap nested property sekarang optional

Perhatikan pengecekan array: tanpanya, array akan diperlakukan sebagai object dan index numeriknya akan menjadi optional, yang bukan yang kamu inginkan.

Deep Readonly#

Pola yang sama, modifier berbeda:

typescript
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object
    ? T[K] extends Function
      ? T[K]
      : T[K] extends Array<infer U>
        ? ReadonlyArray<DeepReadonly<U>>
        : DeepReadonly<T[K]>
    : T[K];
};

Pengecekan Function itu penting — tanpanya, property fungsi akan mendapat readonly yang diterapkan ke struktur internalnya, yang tidak masuk akal.

JSON Type#

Ini type rekursif klasik — merepresentasikan nilai JSON yang valid:

typescript
type JsonPrimitive = string | number | boolean | null;
type JsonArray = JsonValue[];
type JsonObject = { [key: string]: JsonValue };
type JsonValue = JsonPrimitive | JsonArray | JsonObject;
 
function parseJson(input: string): JsonValue {
  return JSON.parse(input);
}
 
function isJsonObject(value: JsonValue): value is JsonObject {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

Ini jauh lebih baik daripada menggunakan any untuk JSON parsing. Ini memberi tahu consumer "kamu akan mendapat sesuatu, tapi kamu perlu mempersempitnya sebelum menggunakan." Yang memang kenyataannya.

Recursive Type untuk Nested Menus#

Contoh praktis — mengetikkan struktur menu navigasi:

typescript
interface MenuItem {
  label: string;
  href: string;
  icon?: string;
  children?: MenuItem[];
}
 
type Menu = MenuItem[];
 
const navigation: Menu = [
  {
    label: "Products",
    href: "/products",
    children: [
      {
        label: "Software",
        href: "/products/software",
        children: [
          { label: "IDE", href: "/products/software/ide" },
          { label: "CLI Tools", href: "/products/software/cli" },
        ],
      },
      { label: "Hardware", href: "/products/hardware" },
    ],
  },
  { label: "About", href: "/about" },
];

Property children yang rekursif memberikanmu nesting tak terbatas dengan type safety penuh.

Pola Dunia Nyata#

Izinkan saya membagikan beberapa pola yang benar-benar saya gunakan di sistem produksi.

Builder Pattern dengan Types#

Builder pattern menjadi jauh lebih berguna ketika type system melacak apa yang sudah di-set:

typescript
type RequiredKeys = "host" | "port" | "database";
 
type BuilderState = {
  [K in RequiredKeys]?: true;
};
 
class DatabaseConfigBuilder<State extends BuilderState = {}> {
  private config: Partial<{
    host: string;
    port: number;
    database: string;
    ssl: boolean;
    poolSize: number;
  }> = {};
 
  setHost(host: string): DatabaseConfigBuilder<State & { host: true }> {
    this.config.host = host;
    return this as unknown as DatabaseConfigBuilder<State & { host: true }>;
  }
 
  setPort(port: number): DatabaseConfigBuilder<State & { port: true }> {
    this.config.port = port;
    return this as unknown as DatabaseConfigBuilder<State & { port: true }>;
  }
 
  setDatabase(db: string): DatabaseConfigBuilder<State & { database: true }> {
    this.config.database = db;
    return this as unknown as DatabaseConfigBuilder<State & { database: true }>;
  }
 
  setSsl(ssl: boolean): DatabaseConfigBuilder<State> {
    this.config.ssl = ssl;
    return this as unknown as DatabaseConfigBuilder<State>;
  }
 
  setPoolSize(size: number): DatabaseConfigBuilder<State> {
    this.config.poolSize = size;
    return this as unknown as DatabaseConfigBuilder<State>;
  }
 
  build(
    this: DatabaseConfigBuilder<{ host: true; port: true; database: true }>
  ): Required<Pick<typeof this.config, "host" | "port" | "database">> &
    Partial<Pick<typeof this.config, "ssl" | "poolSize">> {
    return this.config as ReturnType<typeof this.build>;
  }
}
 
// Ini berhasil dikompilasi:
const config = new DatabaseConfigBuilder()
  .setHost("localhost")
  .setPort(5432)
  .setDatabase("myapp")
  .setSsl(true)
  .build();
 
// Ini gagal saat compile time — .setDatabase() belum dipanggil:
// const bad = new DatabaseConfigBuilder()
//   .setHost("localhost")
//   .setPort(5432)
//   .build(); // Error: 'database' is missing

Method build() hanya bisa dipanggil ketika semua field yang required sudah di-set. Ini ditangkap saat compile time, bukan runtime.

Type-Safe Event Emitter#

Ini event emitter di mana payload types ditegakkan:

typescript
type EventMap = Record<string, unknown>;
 
class TypedEventEmitter<Events extends EventMap> {
  private listeners: {
    [K in keyof Events]?: Array<(payload: Events[K]) => void>;
  } = {};
 
  on<K extends keyof Events>(
    event: K,
    listener: (payload: Events[K]) => void
  ): void {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event]!.push(listener);
  }
 
  emit<K extends keyof Events>(event: K, payload: Events[K]): void {
    this.listeners[event]?.forEach((listener) => listener(payload));
  }
 
  off<K extends keyof Events>(
    event: K,
    listener: (payload: Events[K]) => void
  ): void {
    const handlers = this.listeners[event];
    if (handlers) {
      this.listeners[event] = handlers.filter((h) => h !== listener) as typeof handlers;
    }
  }
}
 
// Penggunaan
interface AppEvents {
  "user:login": { userId: string; timestamp: number };
  "user:logout": { userId: string };
  "notification:new": { message: string; level: "info" | "warning" | "error" };
}
 
const emitter = new TypedEventEmitter<AppEvents>();
 
// Sepenuhnya typed — IDE autocomplete nama event dan bentuk payload
emitter.on("user:login", (payload) => {
  console.log(payload.userId);     // string
  console.log(payload.timestamp);  // number
});
 
// Type error: payload tidak cocok
// emitter.emit("user:login", { userId: "123" });
// Error: missing 'timestamp'
 
// Type error: event tidak ada
// emitter.on("user:signup", () => {});
// Error: "user:signup" is not in AppEvents

Saya sudah menggunakan pola ini di tiga proyek produksi berbeda. Ini menangkap seluruh kategori bug terkait event saat compile time.

Discriminated Unions dengan Exhaustive Checking#

Discriminated unions mungkin adalah pola tunggal paling berguna di TypeScript. Dikombinasikan dengan exhaustive checking, mereka menjamin kamu menangani setiap kasus:

typescript
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };
 
function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return 0.5 * shape.base * shape.height;
    default:
      // Baris ini memastikan exhaustive checking
      const _exhaustive: never = shape;
      return _exhaustive;
  }
}

Kalau seseorang menambahkan varian shape baru (misalnya "pentagon"), fungsi ini akan gagal dikompilasi karena never tidak bisa diberi nilai. Compiler memaksamu menangani setiap kasus.

Saya mengambilnya lebih jauh dengan helper function:

typescript
function assertNever(value: never, message?: string): never {
  throw new Error(message ?? `Unexpected value: ${JSON.stringify(value)}`);
}
 
function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return 0.5 * shape.base * shape.height;
    default:
      assertNever(shape, `Unknown shape kind: ${(shape as Shape).kind}`);
  }
}

Type-Safe State Machines#

Discriminated unions juga memodelkan state machines dengan indah:

typescript
type ConnectionState =
  | { status: "disconnected" }
  | { status: "connecting"; attempt: number }
  | { status: "connected"; socket: WebSocket; connectedAt: Date }
  | { status: "error"; error: Error; lastAttempt: Date };
 
type ConnectionAction =
  | { type: "CONNECT" }
  | { type: "CONNECTED"; socket: WebSocket }
  | { type: "DISCONNECT" }
  | { type: "ERROR"; error: Error }
  | { type: "RETRY" };
 
function connectionReducer(
  state: ConnectionState,
  action: ConnectionAction
): ConnectionState {
  switch (state.status) {
    case "disconnected":
      if (action.type === "CONNECT") {
        return { status: "connecting", attempt: 1 };
      }
      return state;
 
    case "connecting":
      if (action.type === "CONNECTED") {
        return {
          status: "connected",
          socket: action.socket,
          connectedAt: new Date(),
        };
      }
      if (action.type === "ERROR") {
        return {
          status: "error",
          error: action.error,
          lastAttempt: new Date(),
        };
      }
      return state;
 
    case "connected":
      if (action.type === "DISCONNECT") {
        state.socket.close();
        return { status: "disconnected" };
      }
      if (action.type === "ERROR") {
        return {
          status: "error",
          error: action.error,
          lastAttempt: new Date(),
        };
      }
      return state;
 
    case "error":
      if (action.type === "RETRY") {
        return { status: "connecting", attempt: 1 };
      }
      if (action.type === "DISCONNECT") {
        return { status: "disconnected" };
      }
      return state;
 
    default:
      assertNever(state, "Unknown connection state");
  }
}

Setiap state hanya memiliki properties yang masuk akal untuk state tersebut. Kamu tidak bisa mengakses socket saat disconnected. Kamu tidak bisa mengakses error saat connected. Type system menegakkan constraint state machine.

Branded Types untuk Keamanan Domain#

Satu pola lagi yang saya anggap esensial — menggunakan branded types untuk mencegah tercampurnya nilai-nilai yang kebetulan berbagi underlying type yang sama:

typescript
type Brand<T, B extends string> = T & { readonly __brand: B };
 
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Email = Brand<string, "Email">;
 
function createUserId(id: string): UserId {
  return id as UserId;
}
 
function createOrderId(id: string): OrderId {
  return id as OrderId;
}
 
function getUser(id: UserId): Promise<User> {
  // ...
}
 
function getOrder(id: OrderId): Promise<Order> {
  // ...
}
 
const userId = createUserId("user_123");
const orderId = createOrderId("order_456");
 
getUser(userId);   // OK
// getUser(orderId); // Error! OrderId is not assignable to UserId

Baik UserId maupun OrderId adalah string saat runtime. Tapi saat compile time, mereka adalah types yang berbeda. Kamu secara harfiah tidak bisa memasukkan order ID di tempat user ID dibutuhkan. Ini sudah menangkap bug nyata di setiap proyek di mana saya menggunakannya.

Jebakan Umum#

Advanced types itu powerful, tapi ada jebakannya. Ini yang sudah saya pelajari dengan cara yang sulit.

Batas Circular Reference#

TypeScript punya batas kedalaman rekursi untuk types (saat ini sekitar 50 level, meski bervariasi). Kalau kamu terlalu dalam, kamu akan mendapat error "Type instantiation is excessively deep and possibly infinite" yang menakutkan.

typescript
// Ini akan menabrak batas rekursi untuk objek yang nested dalam
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
 
// Fix: Tambahkan depth counter
type DeepPartialSafe<T, Depth extends number[] = []> =
  Depth["length"] extends 10
    ? T
    : {
        [K in keyof T]?: T[K] extends object
          ? DeepPartialSafe<T[K], [...Depth, 0]>
          : T[K];
      };

Trik depth counter menggunakan tuple yang bertambah di setiap langkah rekursif. Ketika mencapai batasmu, rekursi berhenti.

Implikasi Performa#

Types yang kompleks bisa secara signifikan memperlambat editor dan build times-mu. Saya pernah melihat proyek di mana satu type yang terlalu pintar menambahkan 3 detik ke feedback loop setiap keystroke.

Tanda-tanda peringatan:

  • IDE-mu butuh lebih dari 2 detik untuk menampilkan autocomplete
  • tsc --noEmit jadi terasa lebih lama setelah menambahkan sebuah type
  • Kamu melihat error "Type instantiation is excessively deep"
typescript
// Ini terlalu pintar — menghasilkan ledakan kombinatorial
type AllCombinations<T extends string[]> = T extends [
  infer First extends string,
  ...infer Rest extends string[]
]
  ? `${First}${AllCombinations<Rest>}` | AllCombinations<Rest>
  : "";
 
// Jangan lakukan ini dengan lebih dari 5-6 elemen

Kapan JANGAN Menggunakan Advanced Types#

Ini mungkin bagian terpenting. Advanced types harus digunakan ketika mereka mencegah bug atau meningkatkan developer experience. Bukan untuk pamer.

Jangan gunakan ketika:

  • Record<string, unknown> sederhana sudah cukup
  • Type hanya digunakan di satu tempat dan concrete type akan lebih jelas
  • Kamu menghabiskan lebih banyak waktu debugging type daripada yang bisa dihemat
  • Timmu tidak bisa membaca atau maintain-nya
  • Runtime check akan lebih tepat

Gunakan ketika:

  • Kamu punya pola yang berulang di banyak types (mapped types)
  • Return type fungsi tergantung inputnya (conditional types)
  • Kamu membangun API library yang perlu self-documenting
  • Kamu ingin membuat state ilegal menjadi tidak bisa direpresentasikan
  • Kamu lelah dengan kategori bug yang sama muncul di code review
typescript
// Over-engineered — jangan lakukan ini untuk config sederhana
type Config = DeepReadonly<
  DeepRequired<
    Merge<DefaultConfig, Partial<UserConfig>>
  >
>;
 
// Cukup lakukan ini
interface Config {
  readonly host: string;
  readonly port: number;
  readonly debug: boolean;
}

Aturan praktis saya: kalau menjelaskan type-nya butuh waktu lebih lama dari menjelaskan bug yang dicegahnya, sederhanakan.

Debugging Complex Types#

Ketika type yang kompleks tidak bekerja, saya menggunakan helper ini untuk "melihat" apa yang TypeScript hasilkan:

typescript
// Expand type untuk inspeksi di IDE hover tooltips
type Prettify<T> = {
  [K in keyof T]: T[K];
} & {};
 
// Gunakan untuk debugging
type Problematic = SomeComplexType<Input>;
type Debug = Prettify<Problematic>;
// Sekarang hover di Debug di IDE-mu untuk melihat type yang sudah di-resolve

Trik & {} memaksa TypeScript untuk mengevaluasi type secara eager alih-alih menampilkan type alias. Ini sudah menghemat saya berjam-jam kebingungan.

Teknik lain — isolasi dan tes secara incremental:

typescript
// Alih-alih debugging ini semua sekaligus:
type Final = StepThree<StepTwo<StepOne<Input>>>;
 
// Pecah:
type AfterStep1 = StepOne<Input>;       // hover untuk cek
type AfterStep2 = StepTwo<AfterStep1>;  // hover untuk cek
type AfterStep3 = StepThree<AfterStep2>; // hover untuk cek

TL;DR#

  • Conditional types (T extends U ? X : Y) adalah if/else untuk types. Hati-hati dengan perilaku distributif pada unions.
  • infer adalah pattern matching — gunakan untuk mengekstrak types dari struktur seperti function signatures, promises, dan arrays.
  • Mapped types ({ [K in keyof T]: ... }) melakukan iterasi properties. Kombinasikan dengan as untuk key remapping dan filtering.
  • Template literal types memanipulasi string di level type. Dikombinasikan dengan mapped types, mereka sangat powerful untuk API design.
  • Recursive types menangani struktur nested tapi butuh batas kedalaman untuk menghindari ledakan compiler.
  • Discriminated unions dengan exhaustive checking adalah pola bernilai tertinggi di TypeScript. Gunakan di mana-mana.
  • Branded types mencegah tercampurnya nilai yang berbagi underlying type yang sama. Sederhana untuk diimplementasikan, dampak tinggi.
  • Jangan over-engineer types. Kalau type-nya lebih sulit dipahami daripada bug yang dicegahnya, sederhanakan. Tujuannya membuat codebase-mu lebih aman, bukan memenangkan kompetisi type golf.

Type system TypeScript itu Turing complete, yang berarti kamu bisa melakukan hampir apa saja dengannya. Seninya adalah mengetahui kapan kamu harus.

Artikel Terkait