TypeScript Tricks for Better DX
·3 min read
typescriptdxpatterns
Beyond Basic Types
TypeScript's type system is more powerful than most codebases use. Here are patterns that have improved my daily development experience.
Branded Types
Prevent mixing up IDs that are all strings:
type UserId = string & { __brand: "UserId" };
type OrderId = string & { __brand: "OrderId" };
function createUserId(id: string): UserId {
return id as UserId;
}
function getUser(id: UserId) { /* ... */ }
function getOrder(id: OrderId) { /* ... */ }
const userId = createUserId("user_123");
const orderId = createOrderId("order_456");
getUser(userId); // ✓ Works
getUser(orderId); // ✗ Type error!
Exhaustive Switch Checks
Ensure all enum cases are handled:
type Status = "pending" | "active" | "completed" | "cancelled";
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${x}`);
}
function getStatusColor(status: Status): string {
switch (status) {
case "pending": return "yellow";
case "active": return "blue";
case "completed": return "green";
case "cancelled": return "red";
default: return assertNever(status);
// If you add a new status, this errors at compile time
}
}
Type-Safe Event Emitters
Define events once, get type safety everywhere:
type Events = {
"user:login": { userId: string; timestamp: Date };
"user:logout": { userId: string };
"order:created": { orderId: string; total: number };
};
class TypedEmitter<T extends Record<string, unknown>> {
private handlers = new Map<keyof T, Set<Function>>();
on<K extends keyof T>(event: K, handler: (data: T[K]) => void) {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
}
this.handlers.get(event)!.add(handler);
}
emit<K extends keyof T>(event: K, data: T[K]) {
this.handlers.get(event)?.forEach(handler => handler(data));
}
}
const events = new TypedEmitter<Events>();
events.on("user:login", ({ userId, timestamp }) => {
// userId and timestamp are typed
});
events.emit("user:login", { userId: "123", timestamp: new Date() });
Const Assertions for Config
Lock down configuration objects:
const config = {
api: {
baseUrl: "https://api.example.com",
timeout: 5000,
},
features: {
darkMode: true,
betaFeatures: false,
},
} as const;
// Type is deeply readonly with literal types
// config.api.timeout = 6000; // Error!
Template Literal Types
Type-safe route parameters:
type Route = `/users/${string}` | `/orders/${string}` | "/dashboard";
function navigate(route: Route) { /* ... */ }
navigate("/users/123"); // ✓
navigate("/orders/456"); // ✓
navigate("/dashboard"); // ✓
navigate("/invalid"); // ✗ Type error!
Satisfies Operator
Keep literal types while validating shape:
type Config = {
theme: "light" | "dark";
fontSize: number;
};
// Without satisfies: type is widened to Config
const config1: Config = { theme: "dark", fontSize: 14 };
config1.theme; // type: "light" | "dark"
// With satisfies: literal type preserved
const config2 = {
theme: "dark",
fontSize: 14,
} satisfies Config;
config2.theme; // type: "dark" (literal)
Infer for Complex Types
Extract types from existing structures:
type ApiResponse<T> = {
data: T;
meta: { page: number; total: number };
};
// Extract the data type from any ApiResponse
type ExtractData<T> = T extends ApiResponse<infer D> ? D : never;
type UserResponse = ApiResponse<{ id: string; name: string }>;
type UserData = ExtractData<UserResponse>;
// UserData = { id: string; name: string }
These patterns add minimal complexity while catching real bugs. Start with branded types and exhaustive checks — they have the highest ROI.