Skip to content
AI-Native Portfolio

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.

Related Content