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.
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:
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:
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:
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
type C = IsString<string>; // trueKeyword 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:
type MyNonNullable<T> = T extends null | undefined ? never : T;
type Test = MyNonNullable<string | null | undefined>;
// Result: stringType 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:
type MyExtract<T, U> = T extends U ? T : never;
type Numbers = Extract<string | number | boolean, number | boolean>;
// Result: number | booleanExclude — kebalikannya, menghapus anggota yang cocok:
type MyExclude<T, U> = T extends U ? never : T;
type WithoutStrings = Exclude<string | number | boolean, string>;
// Result: number | booleanDistributive 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.
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:
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:
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: stringIni 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#
type MyReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;
type Fn = (x: number, y: string) => boolean;
type Result = MyReturnType<Fn>; // booleanTypeScript melihat pola (...args) => something, mencocokkan fungsimu dengannya, dan meng-assign return type ke R.
Mengekstrak Parameter Types#
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:
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:
type DeepUnpackPromise<T> = T extends Promise<infer U>
? DeepUnpackPromise<U>
: T;
type Deep = DeepUnpackPromise<Promise<Promise<Promise<number>>>>;
// Result: numberMengekstrak Element Type dari Array#
type ElementOf<T> = T extends (infer E)[] ? E : never;
type Items = ElementOf<string[]>; // string
type Mixed = ElementOf<(string | number)[]>; // string | numberMultiple infer dalam Satu Type#
Kamu bisa menggunakan infer beberapa kali untuk mengekstrak bagian yang berbeda:
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:
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#
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:
type MyRequired<T> = {
[K in keyof T]-?: T[K];
};Sintaks -? menghapus modifier optional. Demikian pula, -readonly menghapus readonly.
Pick — memilih properties tertentu:
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:
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:
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:
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:
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:
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#
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:
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:
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:
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"); // ErrorParsing String dengan Template Literals#
Kamu bisa menggunakan template literal types dengan infer untuk mem-parse string:
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:
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:
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:
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: stringAutocomplete 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:
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 optionalPerhatikan 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:
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:
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:
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:
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 missingMethod 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:
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 AppEventsSaya 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:
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:
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:
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:
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 UserIdBaik 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.
// 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 --noEmitjadi terasa lebih lama setelah menambahkan sebuah type- Kamu melihat error "Type instantiation is excessively deep"
// 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 elemenKapan 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
// 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:
// 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-resolveTrik & {} 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:
// 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 cekTL;DR#
- Conditional types (
T extends U ? X : Y) adalah if/else untuk types. Hati-hati dengan perilaku distributif pada unions. inferadalah pattern matching — gunakan untuk mengekstrak types dari struktur seperti function signatures, promises, dan arrays.- Mapped types (
{ [K in keyof T]: ... }) melakukan iterasi properties. Kombinasikan denganasuntuk 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.