You already know string, number, and interface. But TypeScript’s type system goes far deeper — deep enough to catch entire categories of bugs at compile time that would otherwise surface in production. Let’s explore the techniques that separate TypeScript beginners from experts.

Branded Types: Making Primitive Types Meaningful

A string is a string is a string. But is a user ID the same as an email address? TypeScript says yes. Your business logic says no.

// The problem: nothing prevents mixing these up
function sendEmail(userId: string, email: string): void { /* ... */ }

// This compiles fine but is a bug:
sendEmail(userEmail, oderId);

Branded types fix this by creating distinct types from the same primitive:

type Brand<T, B extends string> = T & { readonly __brand: B };

type UserId = Brand<string, 'UserId'>;
type Email = Brand<string, 'Email'>;
type OrderId = Brand<string, 'OrderId'>;

// Smart constructors validate and brand
function createUserId(id: string): UserId {
  if (!id.startsWith('usr_')) throw new Error('Invalid user ID');
  return id as UserId;
}

function createEmail(email: string): Email {
  if (!email.includes('@')) throw new Error('Invalid email');
  return email as Email;
}

// Now this is a compile error:
function sendEmail(userId: UserId, email: Email): void { /* ... */ }

const id = createUserId('usr_123');
const email = createEmail('[email protected]');

sendEmail(email, id); // ❌ Type error!
sendEmail(id, email); // ✅ Correct

Discriminated Unions: Modeling State Machines

Instead of optional fields and boolean flags, model your states explicitly:

// ❌ Bad: unclear which fields are valid in which state
interface Request {
  status: string;
  data?: unknown;
  error?: Error;
  retryCount?: number;
}

// ✅ Good: discriminated union makes invalid states unrepresentable
type RequestState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error; retryCount: number };

function renderRequest<T>(state: RequestState<T>): string {
  switch (state.status) {
    case 'idle':
      return 'Ready';
    case 'loading':
      return 'Loading...';
    case 'success':
      return `Data: ${JSON.stringify(state.data)}`; // TypeScript knows `data` exists
    case 'error':
      return `Error: ${state.error.message} (retries: ${state.retryCount})`;
  }
}

TypeScript narrows the type in each branch. You can’t access data in the error case or error in the success case.

Exhaustiveness Checking

Ensure you handle every case:

function assertNever(value: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}

function handleState(state: RequestState<unknown>): void {
  switch (state.status) {
    case 'idle': return;
    case 'loading': return;
    case 'success': return;
    // If you forget 'error', TypeScript complains:
    // case 'error': return;
    default:
      assertNever(state); // ❌ Error: type { status: 'error'; ... } is not assignable to never
  }
}

Template Literal Types: Type-Safe Strings

TypeScript can enforce string patterns at the type level:

type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type APIVersion = 'v1' | 'v2';
type Resource = 'users' | 'orders' | 'products';

// Only valid API routes
type APIRoute = `/${APIVersion}/${Resource}`;
// Result: "/v1/users" | "/v1/orders" | "/v1/products" | "/v2/users" | "/v2/orders" | "/v2/products"

function callAPI(method: HTTPMethod, route: APIRoute): Promise<Response> {
  return fetch(`https://api.example.com${route}`, { method });
}

callAPI('GET', '/v1/users');       // ✅
callAPI('POST', '/v3/users');      // ❌ Type error
callAPI('GET', '/v1/categories');  // ❌ Type error

Parsing Strings with Template Literals

type EventName = `${string}:${string}`;

type ParseEvent<T extends string> =
  T extends `${infer Namespace}:${infer Action}`
    ? { namespace: Namespace; action: Action }
    : never;

type Result = ParseEvent<'user:created'>;
// { namespace: 'user'; action: 'created' }

// Type-safe event emitter
class TypedEmitter<Events extends Record<string, unknown>> {
  private handlers = new Map<string, Set<Function>>();

  on<K extends keyof Events & string>(
    event: K,
    handler: (payload: Events[K]) => void,
  ): void {
    const set = this.handlers.get(event) ?? new Set();
    set.add(handler);
    this.handlers.set(event, set);
  }

  emit<K extends keyof Events & string>(event: K, payload: Events[K]): void {
    this.handlers.get(event)?.forEach((fn) => fn(payload));
  }
}

// Usage
interface AppEvents {
  'user:created': { id: string; name: string };
  'user:deleted': { id: string };
  'order:placed': { orderId: string; total: number };
}

const emitter = new TypedEmitter<AppEvents>();

emitter.on('user:created', (payload) => {
  console.log(payload.name); // ✅ TypeScript knows the shape
});

emitter.emit('user:created', { id: '1' }); // ❌ Missing 'name'

Conditional Types: Type-Level Logic

Conditional types let you express “if-then-else” at the type level:

// Extract the return type of async functions
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

type A = UnwrapPromise<Promise<string>>;  // string
type B = UnwrapPromise<number>;            // number

// Make specific fields optional
type MakeOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

interface User {
  id: string;
  name: string;
  email: string;
  avatar: string;
}

type CreateUserDTO = MakeOptional<User, 'id' | 'avatar'>;
// { name: string; email: string; id?: string; avatar?: string }

Recursive Conditional Types

// Deep readonly — makes every nested property readonly
type DeepReadonly<T> = T extends (infer U)[]
  ? readonly DeepReadonly<U>[]
  : T extends object
    ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
    : T;

interface Config {
  database: {
    host: string;
    port: number;
    credentials: {
      user: string;
      password: string;
    };
  };
  features: string[];
}

type ReadonlyConfig = DeepReadonly<Config>;
// Every nested field is now readonly — mutations are compile errors

The satisfies Operator

Introduced in TypeScript 4.9, satisfies validates a value matches a type while preserving its literal type:

type ColorMap = Record<string, [number, number, number] | string>;

// With `satisfies`, TypeScript validates AND preserves literal types
const palette = {
  red: [255, 0, 0],
  green: '#00ff00',
  blue: [0, 0, 255],
} satisfies ColorMap;

// TypeScript knows these specific types:
palette.red.map((c) => c * 2);    // ✅ It knows red is a tuple
palette.green.toUpperCase();       // ✅ It knows green is a string

// But still validates:
const bad = {
  red: [255, 0],       // ❌ Error: wrong tuple length
  green: '#00ff00',
} satisfies ColorMap;

const Assertions and Narrowing

// Without as const
const routes = {
  home: '/',
  about: '/about',
  users: '/users',
};
// Type: { home: string; about: string; users: string }

// With as const
const routes2 = {
  home: '/',
  about: '/about',
  users: '/users',
} as const;
// Type: { readonly home: "/"; readonly about: "/about"; readonly users: "/users" }

// Now you can derive types from values:
type Route = (typeof routes2)[keyof typeof routes2];
// "/" | "/about" | "/users"

Practical Pattern: Type-Safe API Client

Combining several techniques into a real-world pattern:

interface APIEndpoints {
  'GET /users': { response: User[]; params: { page?: number } };
  'GET /users/:id': { response: User; params: { id: string } };
  'POST /users': { response: User; body: CreateUserDTO };
  'DELETE /users/:id': { response: void; params: { id: string } };
}

type Method = 'GET' | 'POST' | 'PUT' | 'DELETE';

type ExtractMethod<T extends string> = T extends `${infer M} ${string}` ? M : never;
type ExtractPath<T extends string> = T extends `${string} ${infer P}` ? P : never;

async function apiCall<K extends keyof APIEndpoints>(
  endpoint: K,
  options?: Omit<APIEndpoints[K], 'response'>,
): Promise<APIEndpoints[K]['response']> {
  const [method, path] = (endpoint as string).split(' ');
  // Implementation...
  return {} as any;
}

// Fully type-safe API calls:
const users = await apiCall('GET /users', { params: { page: 1 } });
//    ^? User[]

const user = await apiCall('GET /users/:id', { params: { id: '123' } });
//    ^? User

await apiCall('POST /users', { body: { name: 'Jane', email: '[email protected]' } });

Key Takeaways

  1. Branded types prevent mixing up primitives that represent different concepts
  2. Discriminated unions make invalid states unrepresentable
  3. Template literal types enforce string patterns at compile time
  4. Conditional types let you write type-level logic
  5. satisfies validates types while preserving specificity
  6. Combine techniques to build type-safe APIs that catch bugs before runtime

The investment in learning advanced TypeScript types pays off every day — in fewer runtime errors, better IDE autocomplete, and code that documents itself through types.