# Modern TypeScript Patterns That Changed My Code

**Author:** kelexine  
**Date:** 2025-12-09  
**Category:** Engineering  
**Tags:** TypeScript, JavaScript, Best Practices, Web Development  
**URL:** https://kelexine.is-a.dev/blog/modern-typescript-patterns

---

# Beyond Basic Types

TypeScript isn't just "JavaScript with types." It's a completely different way of thinking about code. After years of writing TypeScript, these are the patterns that genuinely changed how I build software.

## The satisfies Operator

TypeScript 4.9 introduced `satisfies`, and it's a game-changer. It validates types without widening them.

```typescript
// Without satisfies - type is widened
const config = {
  theme: "dark",
  port: 3000
} as const;
// config.theme is "dark" (literal), good!

// But what if you want type checking?
type Config = { theme: string; port: number };
const config2: Config = {
  theme: "dark",
  port: 3000
};
// config2.theme is string, not "dark" - we lost the literal!

// With satisfies - best of both worlds
const config3 = {
  theme: "dark",
  port: 3000
} satisfies Config;
// config3.theme is "dark" AND it's validated against Config
```

Real-world use case:

```typescript
const routes = {
  home: "/",
  blog: "/blog",
  about: "/about"
} satisfies Record<string, string>;

// Type is { home: "/"; blog: "/blog"; about: "/about" }
// Not Record<string, string>
routes.home // Autocomplete works!
```

## Discriminated Unions

This pattern makes impossible states unrepresentable:

```typescript
// Bad: states can be inconsistent
interface RequestState {
  loading: boolean;
  error: string | null;
  data: User | null;
}

// Good: discriminated union
type RequestState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "error"; error: string }
  | { status: "success"; data: User };

function handleRequest(state: RequestState) {
  switch (state.status) {
    case "idle":
      return <p>Click to load</p>;
    case "loading":
      return <Spinner />;
    case "error":
      return <Error message={state.error} />; // error exists!
    case "success":
      return <UserCard user={state.data} />; // data exists!
  }
}
```

## Template Literal Types

Create precise string types dynamically:

```typescript
type EventName = "click" | "focus" | "blur";
type Handler = `on${Capitalize<EventName>}`;
// "onClick" | "onFocus" | "onBlur"

type Locale = "en" | "fr" | "de";
type Route = "home" | "about" | "blog";
type LocalizedRoute = `/${Locale}/${Route}`;
// "/en/home" | "/en/about" | ... all 9 combinations

// Real API example
type HTTPMethod = "GET" | "POST" | "PUT" | "DELETE";
type APIEndpoint = `/api/${"users" | "posts"}`;
type APIRoute = `${HTTPMethod} ${APIEndpoint}`;
// "GET /api/users" | "POST /api/users" | ...
```

## Const Assertions and Inference

```typescript
// Make everything readonly and literal
const PERMISSIONS = ["read", "write", "admin"] as const;
type Permission = typeof PERMISSIONS[number];
// "read" | "write" | "admin"

// Object inference
const COLORS = {
  primary: "#A8E063",
  secondary: "#56AB2F"
} as const;

type ColorKey = keyof typeof COLORS; // "primary" | "secondary"
type ColorValue = typeof COLORS[ColorKey]; // "#A8E063" | "#56AB2F"
```

## Branded Types

Prevent mixing similar types:

```typescript
type UserId = string & { readonly brand: unique symbol };
type PostId = string & { readonly brand: unique symbol };

function createUserId(id: string): UserId {
  return id as UserId;
}

function getUser(id: UserId): User { /* ... */ }
function getPost(id: PostId): Post { /* ... */ }

const userId = createUserId("123");
getUser(userId); // OK
getPost(userId); // Error! Can't use UserId where PostId expected
```

## Utility Types Deep Dive

```typescript
// Partial<T> - make all properties optional
type PartialUser = Partial<User>;

// Required<T> - make all properties required
type RequiredConfig = Required<Config>;

// Pick & Omit
type UserPreview = Pick<User, "id" | "name">;
type UserWithoutPassword = Omit<User, "password">;

// ReturnType & Parameters
function createApi() { return { fetch, post }; }
type Api = ReturnType<typeof createApi>;

// Awaited - unwrap Promise types
type Data = Awaited<Promise<Promise<User>>>; // User

// Custom utility: make specific keys optional
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
type CreateUser = PartialBy<User, "id" | "createdAt">;
```

## Type Guards and Assertions

```typescript
// Type predicates
function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    "name" in value
  );
}

// Assertion functions
function assertUser(value: unknown): asserts value is User {
  if (!isUser(value)) {
    throw new Error("Invalid user");
  }
}

// Usage
const data: unknown = await fetch("/api/user").then(r => r.json());
assertUser(data);
// data is now typed as User
console.log(data.name);
```

## Generic Constraints

```typescript
// Constrain to objects with specific properties
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

// Constrain to types with specific shape
interface HasId { id: string; }
function findById<T extends HasId>(items: T[], id: string): T | undefined {
  return items.find(item => item.id === id);
}

// Default type parameters
type Response<T = unknown> = {
  data: T;
  status: number;
};
```

## Conditional Types

```typescript
// Basic conditional
type IsString<T> = T extends string ? true : false;

// Extract array element type
type ArrayElement<T> = T extends (infer E)[] ? E : never;
type Nums = ArrayElement<number[]>; // number

// Infer return type
type GetReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

// Practical: unwrap Promise
type Unwrap<T> = T extends Promise<infer U> ? Unwrap<U> : T;
```

## Mapped Types

```typescript
// Make all properties nullable
type Nullable<T> = { [K in keyof T]: T[K] | null };

// Make all properties getters
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
};

interface Person { name: string; age: number; }
type PersonGetters = Getters<Person>;
// { getName: () => string; getAge: () => number }
```

## Conclusion

These patterns aren't just about type safety—they're about designing better APIs and catching bugs before they happen.

> TypeScript's type system is Turing complete. Yes, you can technically compute anything. No, that doesn't mean you should.

---

**Resources**:
- [TypeScript Handbook](https://www.typescriptlang.org/docs/)
- [Type Challenges](https://github.com/type-challenges/type-challenges)
- [Matt Pocock's Total TypeScript](https://totaltypescript.com/)

---

*This content is available at [kelexine.is-a.dev/blog/modern-typescript-patterns](https://kelexine.is-a.dev/blog/modern-typescript-patterns)*
