Programming

Advanced TypeScript Patterns for Production Applications

Go beyond basics with discriminated unions, template literals, and conditional types. Real patterns from enterprise codebases at Modelia.ai.

Harsh RastogiHarsh Rastogi
Nov 5, 202413 min
TypeScriptJavaScriptBest PracticesFrontendBackend

Beyond Basic Types

TypeScript's type system is one of the most powerful in mainstream programming. But most developers only use the surface — interfaces, basic generics, and type aliases. At Modelia.ai, we use advanced patterns daily to catch bugs at compile time that would otherwise only surface in production. When your Shopify extension processes financial transactions and AI model outputs, a runtime type error isn't just a bug — it's lost revenue for your merchant.

These patterns were refined across three production codebases: EduFly, Asynq.ai, and Modelia.ai.

Discriminated Unions

The most useful advanced pattern. Instead of checking for null or using optional properties, encode your state machine directly in the type system:

typescript
// BAD: Optional properties create ambiguity
interface ApiResponse {
  data?: User;
  error?: string;
  loading?: boolean;
}
// Is { data: undefined, error: undefined, loading: false } a success or error?

// GOOD: Discriminated union — each state is explicit and exclusive
type ApiResponse<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T; fetchedAt: number }
  | { status: 'error'; error: string; code: number; retryable: boolean };

function handleResponse(response: ApiResponse<User>) {
  switch (response.status) {
    case 'success':
      // TypeScript KNOWS response.data is User here
      return renderUser(response.data);
    case 'error':
      // TypeScript KNOWS response.error and response.code exist here
      if (response.retryable) return retryRequest();
      return showError(response.error);
    case 'loading':
      return <Spinner />;
    case 'idle':
      return null;
  }
}

At Asynq.ai, we used discriminated unions for our entire candidate pipeline state machine. A candidate could be in exactly one state: applied, screening, ai-evaluated, interview-scheduled, offered, hired, or rejected. Each state carried different data:

typescript
type CandidateState =
  | { stage: 'applied'; appliedAt: Date; resumeUrl: string }
  | { stage: 'screening'; screenedBy: string; notes: string }
  | { stage: 'ai-evaluated'; score: number; confidence: number; recommendation: string }
  | { stage: 'interview-scheduled'; interviewerId: string; scheduledAt: Date; meetingLink: string }
  | { stage: 'offered'; offerAmount: number; offerExpiry: Date }
  | { stage: 'hired'; startDate: Date; teamId: string }
  | { stage: 'rejected'; reason: string; rejectedAt: Date; rejectedBy: string };

This eliminated an entire class of bugs where code would try to access interviewerId on a candidate who hadn't reached the interview stage yet.

Template Literal Types

TypeScript lets you create types from string templates. We use these at Modelia.ai for type-safe event systems, API route definitions, and cache key patterns:

typescript
// Type-safe event names
type Domain = 'product' | 'order' | 'customer' | 'ai-recommendation';
type Action = 'created' | 'updated' | 'deleted' | 'processed';
type EventName = `${Domain}.${Action}`;

// Only valid combinations compile
function emit(event: EventName, payload: unknown): void { /* ... */ }
emit('product.created', { id: '123' });     // Valid
emit('order.updated', { status: 'shipped' }); // Valid
emit('product.archived', {});                 // Compile error! 'archived' not in Action

// Type-safe Redis cache keys
type CacheKey =
  | `candidate:${string}`
  | `product:${string}`
  | `catalog:${string}:v${number}`
  | `session:${string}`
  | `ratelimit:${string}:${number}`;

async function cacheGet(key: CacheKey): Promise<string | null> {
  return redis.get(key);
}

cacheGet('candidate:abc-123');     // Valid
cacheGet('catalog:m1:v3');         // Valid
cacheGet('random-key');            // Compile error!

Conditional Types

For building flexible utility types that adapt to their input:

typescript
// Extract the element type from an array, or return never
type ElementOf<T> = T extends Array<infer U> ? U : never;

type StringElement = ElementOf<string[]>;  // string
type NumberElement = ElementOf<number[]>;  // number
type Nothing = ElementOf<boolean>;         // never

// Make specific properties required while keeping others optional
type RequireFields<T, K extends keyof T> = T & Required<Pick<T, K>>;

// Usage: A product needs at least name and price to be saved
type DraftProduct = Partial<Product>;
type SaveableProduct = RequireFields<DraftProduct, 'name' | 'price'>;

Conditional Types for API Responses

At Modelia.ai, different API endpoints return data in different shapes. We use conditional types to infer the response type from the endpoint:

typescript
type ApiEndpoint = '/products' | '/orders' | '/recommendations';

type ApiResponseFor<E extends ApiEndpoint> =
  E extends '/products' ? Product[] :
  E extends '/orders' ? Order[] :
  E extends '/recommendations' ? Recommendation[] :
  never;

async function fetchApi<E extends ApiEndpoint>(endpoint: E): Promise<ApiResponseFor<E>> {
  const response = await fetch(endpoint);
  return response.json();
}

// TypeScript infers the exact return type:
const products = await fetchApi('/products');     // Product[]
const orders = await fetchApi('/orders');          // Order[]
const recs = await fetchApi('/recommendations');   // Recommendation[]

Branded Types

Prevent mixing up values that are structurally identical but semantically different:

typescript
// Without branding: these are all just strings — easy to mix up
function assignCandidate(candidateId: string, jobId: string, recruiterId: string) { }
assignCandidate(recruiterId, candidateId, jobId); // Compiles but wrong! Arguments swapped.

// With branding: each ID type is unique
type CandidateId = string & { readonly __brand: 'CandidateId' };
type JobId = string & { readonly __brand: 'JobId' };
type RecruiterId = string & { readonly __brand: 'RecruiterId' };

function assignCandidate(candidateId: CandidateId, jobId: JobId, recruiterId: RecruiterId) { }

// Helper to create branded values
function candidateId(id: string): CandidateId { return id as CandidateId; }
function jobId(id: string): JobId { return id as JobId; }

// Now this is a compile error:
assignCandidate(recruiterId, candidateId, jobId); // Error! Types don't match.

At Asynq.ai, branded types prevented a bug where a function was called with a candidate ID where a job ID was expected. This would have silently returned wrong data from the database.

Type-Safe API Client

For REST and GraphQL APIs (like the Shopify Admin API at Modelia.ai), we build fully typed clients:

typescript
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';

interface RouteDefinition {
  params?: Record<string, string>;
  body?: unknown;
  response: unknown;
}

type ApiRoutes = {
  'GET /products': { response: Product[] };
  'GET /products/:id': { params: { id: string }; response: Product };
  'POST /products': { body: CreateProductInput; response: Product };
  'PUT /products/:id': { params: { id: string }; body: UpdateProductInput; response: Product };
  'DELETE /products/:id': { params: { id: string }; response: { deleted: true } };
  'POST /ai/recommend': { body: RecommendationInput; response: Recommendation[] };
};

type Route = keyof ApiRoutes;

async function api<R extends Route>(
  route: R,
  options?: Omit<ApiRoutes[R], 'response'>
): Promise<ApiRoutes[R]['response']> {
  // Implementation handles param substitution, body serialization, etc.
  const [method, path] = route.split(' ');
  // ... fetch logic
}

// Fully type-safe API calls:
const products = await api('GET /products');           // Product[]
const product = await api('POST /products', {
  body: { name: 'T-Shirt', price: 29.99 }             // CreateProductInput
});

Builder Pattern with Type Inference

For complex query builders at Asynq.ai:

typescript
class QueryBuilder<TSelect extends Record<string, boolean> = {}> {
  private selectFields: TSelect = {} as TSelect;
  private whereClause: Record<string, unknown> = {};

  select<K extends string>(field: K): QueryBuilder<TSelect & Record<K, true>> {
    (this.selectFields as any)[field] = true;
    return this as any;
  }

  where(field: string, value: unknown): this {
    this.whereClause[field] = value;
    return this;
  }

  async execute(): Promise<Array<{ [K in keyof TSelect]: unknown }>> {
    // Execute query with selected fields and where clause
    return [];
  }
}

// Usage — TypeScript tracks which fields are selected
const results = await new QueryBuilder()
  .select('name')
  .select('email')
  .where('status', 'active')
  .execute();
// Type: Array<{ name: unknown; email: unknown }>

Strict Configuration Types

At Modelia.ai, our application config must be validated at startup. Using as const and conditional types ensures every environment variable is accounted for:

typescript
const requiredEnvVars = [
  'DATABASE_URL',
  'REDIS_URL',
  'SHOPIFY_API_KEY',
  'SHOPIFY_API_SECRET',
  'AI_MODEL_ENDPOINT',
] as const;

type EnvVar = typeof requiredEnvVars[number];
type Config = Record<EnvVar, string>;

function loadConfig(): Config {
  const config = {} as Config;

  for (const key of requiredEnvVars) {
    const value = process.env[key];
    if (!value) {
      throw new Error(`Missing required environment variable: ${key}`);
    }
    config[key] = value;
  }

  return config;
}

// Application fails fast at startup if any env var is missing
const config = loadConfig();
// config.DATABASE_URL — string (guaranteed)
// config.MISSING_KEY — compile error!

Key Takeaways

  • Discriminated unions eliminate null checks and type assertions — encode your state machines in the type system, as we do at Asynq.ai for candidate pipeline states
  • Template literal types enforce naming conventions at compile time — type-safe event names, cache keys, and API routes
  • Conditional types power generic utilities that adapt to their input — use them for API response inference
  • Branded types prevent argument swapping bugs — when two parameters are both strings, branding catches mix-ups at compile time
  • Builder patterns with type inference create fluent APIs that track state through generics
  • Strict configuration types catch missing environment variables at startup, not in production at 3 AM
  • Invest time in your type system — at Modelia.ai, advanced types have eliminated an entire category of production bugs. The upfront investment pays back 10x in reliability.

Share this article

Harsh Rastogi - Full Stack Engineer

Harsh Rastogi

Full Stack Engineer

Full Stack Engineer building production AI systems at Modelia. Previously at Asynq and Bharat Electronics Limited. Published researcher.

Connect on LinkedIn

Follow me for more insights on software engineering, system design, and career growth.

View Profile