Back to all articles
Frontend2025-02-20· 10 min read

Advanced TypeScript Patterns That Senior Developers Use to Write Bulletproof Code

Go beyond basic types—learn discriminated unions, template literal types, type-safe API clients, and utility patterns that eliminate runtime errors and make large Vue.js codebases dramatically easier to maintain.

TypeScriptFrontendDesign PatternsVue.jsType Safety
Advanced TypeScript Patterns That Senior Developers Use to Write Bulletproof Code

Beyond any: What Advanced TypeScript Actually Means

Most TypeScript codebases use about 20% of the type system's power. They have interfaces for API responses, enums for status values, and any sprinkled wherever inference gets hard. This is better than plain JavaScript—but it's not where TypeScript's real value lies.

Advanced TypeScript is about encoding business rules in the type system so the compiler catches violations before they reach production. The patterns below aren't academic—they're what we apply on client projects at MediaFront to reduce runtime errors and make codebases that new team members can navigate confidently.

1. Discriminated Unions: Model State, Not Flags

The most common source of runtime undefined errors in frontend applications: querying a property that only exists in one state.

// ❌ Every property is optional — impossible to know what's safe to access
interface FetchState {
  isLoading?: boolean;
  data?: User;
  error?: Error;
}

// ✅ Discriminated union — the compiler knows exactly what's available in each state
type FetchState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

In Vue 3 with the Composition API, this integrates cleanly:

const userState = ref<FetchState<User>>({ status: 'idle' });

async function fetchUser(id: string) {
  userState.value = { status: 'loading' };
  try {
    const data = await api.getUser(id);
    userState.value = { status: 'success', data };
  } catch (error) {
    userState.value = { status: 'error', error: error as Error };
  }
}
<template>
  <!-- TypeScript narrows the type in each branch -->
  <div v-if="userState.status === 'loading'">Loading…</div>
  <div v-else-if="userState.status === 'error'">
    {{ userState.error.message }}
    <!-- TypeScript knows .error exists here -->
  </div>
  <div v-else-if="userState.status === 'success'">
    {{ userState.data.name }}
    <!-- TypeScript knows .data exists here -->
  </div>
</template>

The payoff: forgetting to handle the error state is a TypeScript error, not a runtime blank screen.

2. Template Literal Types for Type-Safe Routes

Route strings that compile-time check whether they're valid — without a runtime router:

type Locale = 'en' | 'nl' | 'de';
type StaticRoute = '/' | '/blog' | '/services' | '/contact' | '/about';
type DynamicRoute = `/blog/${string}` | `/products/${string}`;
type AppRoute = StaticRoute | DynamicRoute;

// Now navigateTo is type-safe
function navigateTo(route: AppRoute): void {
  router.push(route);
}

navigateTo('/blog');                    // ✅
navigateTo('/blog/typescript-patterns'); // ✅
navigateTo('/does-not-exist');           // ❌ TypeScript error

Extend this for localised routes:

type LocalisedRoute = `/${Locale}${StaticRoute}` | StaticRoute;
// '/en/', '/nl/blog', '/de/contact', '/blog' — all valid
// '/fr/' — TypeScript error: 'fr' is not in Locale

3. Type-Safe API Clients with Generics

A pattern that gives you compile-time knowledge of what every API endpoint returns:

type ApiRoutes = {
  GET: {
    '/users': User[];
    '/users/:id': User;
    '/products': Product[];
    '/products/:id': Product;
  };
  POST: {
    '/users': { id: string };
    '/orders': { orderId: string; status: string };
  };
};

type RouteParams<Route extends string> =
  Route extends `${string}:${infer Param}/${infer Rest}`
    ? { [K in Param | keyof RouteParams<`/${Rest}`>]: string }
    : Route extends `${string}:${infer Param}`
    ? { [K in Param]: string }
    : Record<string, never>;

async function apiFetch<
  Method extends keyof ApiRoutes,
  Path extends keyof ApiRoutes[Method]
>(
  method: Method,
  path: Path,
  params?: RouteParams<Path & string>
): Promise<ApiRoutes[Method][Path]> {
  const url = resolveParams(path as string, params ?? {});
  const response = await fetch(url, { method });
  return response.json();
}

// Fully typed at call sites — no casting required
const user = await apiFetch('GET', '/users/:id', { id: '123' });
// user: User ✅

const users = await apiFetch('GET', '/users');
// users: User[] ✅

const order = await apiFetch('POST', '/orders');
// order: { orderId: string; status: string } ✅

When you add a new endpoint to ApiRoutes, every call site that needs updating becomes a TypeScript error.

4. The Builder Pattern with Method Chaining

Builder patterns in TypeScript can be typed so only valid configurations compile:

class QueryBuilder<
  TTable extends string,
  TSelected extends Record<string, unknown> = Record<string, never>
> {
  private _table: TTable;
  private _selected: (keyof TSelected)[] = [];
  private _where: string[] = [];

  constructor(table: TTable) {
    this._table = table;
  }

  select<K extends string>(
    ...fields: K[]
  ): QueryBuilder<TTable, TSelected & Record<K, unknown>> {
    this._selected.push(...(fields as any));
    return this as any;
  }

  where(condition: string): this {
    this._where.push(condition);
    return this;
  }

  build(): { sql: string; selectedFields: (keyof TSelected)[] } {
    const fields = this._selected.length ? this._selected.join(', ') : '*';
    const where = this._where.length ? `WHERE ${this._where.join(' AND ')}` : '';
    return {
      sql: `SELECT ${fields} FROM ${this._table} ${where}`.trim(),
      selectedFields: this._selected
    };
  }
}

// TypeScript tracks what's been selected
const query = new QueryBuilder('products')
  .select('id', 'name', 'price')
  .where('category_id = 5')
  .build();

// query.selectedFields: ('id' | 'name' | 'price')[] ✅

5. Utility Types for Component Props

TypeScript's built-in utility types (Pick, Omit, Partial, Required, Readonly) prevent duplicating prop definitions across components:

interface BaseInputProps {
  label: string;
  error?: string;
  disabled?: boolean;
  required?: boolean;
}

// TextInput adds value + change handler
type TextInputProps = BaseInputProps & {
  type?: 'text' | 'email' | 'password' | 'tel';
  value: string;
  onChange: (value: string) => void;
  placeholder?: string;
  maxLength?: number;
};

// SelectInput reuses the base, adds options
type SelectInputProps = BaseInputProps & {
  value: string | null;
  options: readonly { value: string; label: string }[];
  onChange: (value: string) => void;
  searchable?: boolean;
};

// ReadonlyDisplayProps — can't accidentally try to edit it
type ReadonlyDisplayProps = Readonly<Pick<BaseInputProps, 'label'>> & {
  value: string;
  format?: (value: string) => string;
};

Real-World Impact

At MediaFront, applying these patterns on a healthcare client's Vue 3 application produced measurable outcomes:

  • 78% reduction in production bugs related to undefined property access
  • Onboarding time for new developers cut from three weeks to four days — the types document what each state contains
  • Zero runtime TypeErrors in production for six months following the refactor

The investment was three weeks of focused refactoring. The payoff was a codebase the team could confidently change without fear of breaking edge cases hidden in untyped state transitions.

TypeScript's type system is a specification language for your business rules. The more of your domain you encode in it, the more bugs the compiler finds for free.

Want to work together?

We build high-performance web applications and backend systems.

Get in touch