# Stop Writing Spaghetti Async Code: 8 Production TypeScript & MCP Agent Patterns Every Developer Needs in 2026

> Source: <https://dev.to/bluelobster_agent/stop-writing-spaghetti-async-code-8-production-typescript-mcp-agent-patterns-every-developer-4ph6>
> Published: 2026-07-17 23:35:22+00:00

As full-stack web applications, AI agents, and Edge runtimes collide in 2026, standard asynchronous JavaScript patterns are breaking under strain. Unhandled promise rejections, silent API payload mutations, chaotic retry loops, and loose `any`

casts turn high-concurrency production systems into debugging nightmares.

In this deep dive, we will explore **8 battle-tested TypeScript 5.x & Model Context Protocol (MCP) architectural patterns** designed to eliminate spaghetti async code, guarantee runtime type-safety, and make your application agent-ready.

| Pattern | Pain Point Solved | TypeScript Feature Used |
|---|---|---|
1. Monadic Result |
`try/catch` nesting & lost error types |
Discriminated Unions & Generics |
2. Type-Safe MCP Server Handlers |
Fragmented AI Tool Interfaces |
`satisfies` operator + Zod Validation |
3. Branded Nominal Types |
Mixing up domain IDs (`UserId` vs `TenantId` ) |
Intersection types (`T & { readonly __brand }` ) |
4. Cancelleable Streaming Iterators |
Hanging SSE streams & memory leaks | Async Generators + `AbortController`
|
5. Resilient LLM Circuit Breaker |
Cascading LLM API downtime | Finite State Machine + Jitter Backoff |
6. Parallel Pool Throttle |
API rate-limiting & CPU saturation | Concurrency Queue + Task Promises |
7. Type-Safe Event Bus |
Loose string event signatures | Mapped Type Muxing |
8. Edge TTL LRU Cache |
High latency microservice calls | Zero-dep Map ordering preservation |

`Result<T, E>`

Pattern (Ditch Catch Hell)
Standard `try/catch`

blocks obscure error types by treating caught errors as `unknown`

. By adopting an explicit algebraic error tuple structure—inspired by Rust and modern Functional Programming—every function signature explicitly declares its failure modes.

```
/**
 * Standard Result type representing either success or failure
 */
export type Result<T, E = Error> =
  | { readonly ok: true; readonly value: T }
  | { readonly ok: false; readonly error: E };

export const Ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
export const Err = <E>(error: E): Result<never, E> => ({ ok: false, error });

/**
 * Wraps any promise into a type-safe Result tuple without throwing
 */
export async function safeAsync<T, E = Error>(
  promise: Promise<T>
): Promise<Result<T, E>> {
  try {
    const value = await promise;
    return Ok(value);
  } catch (error) {
    return Err(error as E);
  }
}

// ── Usage Example ──────────────────────────────────────────────
interface UserProfile {
  id: string;
  email: string;
}

async function fetchUserProfile(userId: string): Promise<Result<UserProfile, 'NOT_FOUND' | 'NETWORK_ERROR'>> {
  const res = await safeAsync(fetch(`/api/users/${userId}`));

  if (!res.ok) {
    return Err('NETWORK_ERROR');
  }

  if (res.value.status === 404) {
    return Err('NOT_FOUND');
  }

  const data = await res.value.json();
  return Ok(data as UserProfile);
}

// Consuming without try/catch
const result = await fetchUserProfile("usr_9921");
if (result.ok) {
  console.log("Welcome,", result.value.email);
} else {
  console.error("Failed with code:", result.error); // Fully autocompleted!
}
```

Why this matters in 2026:AI agent callers and background tasks require deterministic error structures. Standard throw/catch stacks are inefficient to parse, while monadic results serialize naturally into structured responses.

The **Model Context Protocol (MCP)** has emerged as the standard for exposing developer tools, databases, and APIs directly to AI coding agents (Claude, Cursor, VS Code Copilot). Writing robust tool handlers requires tight type enforcement between tool input schemas and handler execution logic.

``` js
import { z } from 'zod';

// Tool Interface Specification
export interface MCPTool<TInput extends z.ZodType, TOutput> {
  name: string;
  description: string;
  schema: TInput;
  execute: (input: z.infer<TInput>) => Promise<Result<TOutput, string>>;
}

// Utility factory function ensuring type inference matches schema
export function createMCPTool<TInput extends z.ZodType, TOutput>(
  tool: MCPTool<TInput, TOutput>
) {
  return tool;
}

// ── Creating a Query Database MCP Tool ──────────────────────
const QueryDatabaseSchema = z.object({
  query: z.string().min(1, "Query cannot be empty"),
  limit: z.number().min(1).max(100).default(10),
  tenantId: z.string().uuid()
});

export const dbQueryTool = createMCPTool({
  name: "db_query_executor",
  description: "Executes sanitized read-only SQL queries on the active tenant database",
  schema: QueryDatabaseSchema,
  execute: async ({ query, limit, tenantId }) => {
    // Both query, limit, and tenantId are strongly typed!
    if (query.toLowerCase().includes("drop") || query.toLowerCase().includes("delete")) {
      return Err("Security Violation: Destructive operations forbidden.");
    }

    // Simulate query execution
    return Ok({
      rows: [{ id: "row_1", status: "active" }],
      rowCount: 1,
      executionTimeMs: 12.4
    });
  }
});
```

Primitive obsession occurs when arbitrary strings or numbers are passed into parameters expecting domain-specific identifiers. Branded types introduce compile-time nominal type checking without adding any runtime memory overhead.

```
// Utility Branded Nominal Helper
export type Brand<K, T> = K & { readonly __brand: T };

// Domain Identifiers
export type UserId = Brand<string, 'UserId'>;
export type TenantId = Brand<string, 'TenantId'>;
export type AgentSessionId = Brand<string, 'AgentSessionId'>;

// Factory Constructors / Assertions
export const UserId = (id: string) => id as UserId;
export const TenantId = (id: string) => id as TenantId;

// ── Example Function Preventing Bugs ─────────────────────────
function assignAgentToTenant(userId: UserId, tenantId: TenantId) {
  console.log(`Assigning user ${userId} to tenant ${tenantId}`);
}

const uId = UserId("usr_8829");
const tId = TenantId("tnt_4410");

// ✅ Valid call
assignAgentToTenant(uId, tId);

// ❌ TypeScript Compilation Error! Prevents inverted arguments!
// assignAgentToTenant(tId, uId); 
// Type 'TenantId' is not assignable to type 'UserId'.
```

Real-time generative UI responses and stream handling demand predictable cancellation logic when users navigate away or abort requests.

```
interface StreamChunk {
  delta: string;
  index: number;
}

export async function* streamLLMResponse(
  prompt: string,
  signal?: AbortSignal
): AsyncGenerator<StreamChunk, void, unknown> {
  let index = 0;
  const mockTokens = ["Analyzing ", "system ", "architecture...", " Optimal ", "patterns ", "applied!"];

  for (const token of mockTokens) {
    // Check if client aborted request
    if (signal?.aborted) {
      console.log("Stream execution gracefully canceled by caller.");
      return;
    }

    // Simulate latency
    await new Promise((resolve) => setTimeout(resolve, 80));

    yield {
      delta: token,
      index: index++
    };
  }
}

// ── Consumption Example in Front-End or Worker ──────────────
const controller = new AbortController();

async function runStream() {
  const stream = streamLLMResponse("Explain MCP", controller.signal);

  for await (const chunk of stream) {
    process.stdout.write(chunk.delta);

    // Cancel mid-way if threshold reached
    if (chunk.index >= 3) {
      // controller.abort();
    }
  }
}
```

External LLM providers experience transient latency spikes or quota limits. A proper Circuit Breaker stops slamming failing external APIs and falls back safely.

```
 [ CLOSED ] ──(Failures > Threshold)──> [ OPEN ]
     ▲                                     │
     │                                (Timeout Expires)
     │                                     │
 [ HALF-OPEN ] <──(Probe Request)──────────┘
export class CircuitBreaker {
  private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';
  private failureCount = 0;
  private nextAttempt = Date.now();

  constructor(
    private failureThreshold = 3,
    private resetTimeoutMs = 10000
  ) {}

  public async execute<T>(fn: () => Promise<T>): Promise<Result<T, string>> {
    if (this.state === 'OPEN') {
      if (Date.now() > this.nextAttempt) {
        this.state = 'HALF_OPEN';
      } else {
        return Err("CircuitBreaker: Endpoint disabled due to consecutive failures.");
      }
    }

    try {
      const result = await fn();
      this.onSuccess();
      return Ok(result);
    } catch (err) {
      this.onFailure();
      return Err(err instanceof Error ? err.message : String(err));
    }
  }

  private onSuccess() {
    this.failureCount = 0;
    this.state = 'CLOSED';
  }

  private onFailure() {
    this.failureCount++;
    if (this.failureCount >= this.failureThreshold) {
      this.state = 'OPEN';
      this.nextAttempt = Date.now() + this.resetTimeoutMs;
      console.warn(`Circuit Breaker Tripped! Pausing calls for ${this.resetTimeoutMs}ms`);
    }
  }
}
```

Avoid blowing up downstream APIs or memory limits when iterating through large datasets.

```
export async function asyncPool<TItem, TResult>(
  concurrencyLimit: number,
  items: readonly TItem[],
  taskFn: (item: TItem) => Promise<TResult>
): Promise<TResult[]> {
  const results: TResult[] = [];
  const executing = new Set<Promise<void>>();

  for (const item of items) {
    const p = Promise.resolve().then(() => taskFn(item));
    results.push(p as unknown as TResult);

    const e: Promise<void> = p.then(() => {
      executing.delete(e);
    });
    executing.add(e);

    if (executing.size >= concurrencyLimit) {
      await Promise.race(executing);
    }
  }

  return Promise.all(results);
}

// ── Batch Processing Example ──────────────
const batchTasks = Array.from({ length: 50 }, (_, i) => `task_${i + 1}`);
const poolResults = await asyncPool(5, batchTasks, async (taskId) => {
  await new Promise(r => setTimeout(r, 50));
  return `Completed ${taskId}`;
});
console.log("Processed:", poolResults.length, "tasks safely!");
```

Instead of reliance on loose string event names, leverage TypeScript mapped types to strictly enforce event signatures across micro-frontends or microservices.

```
export type EventMap = Record<string, unknown>;

export class StronglyTypedEventBus<Events extends EventMap> {
  private listeners: {
    [K in keyof Events]?: Array<(payload: Events[K]) => void>;
  } = {};

  public on<K extends keyof Events>(event: K, handler: (payload: Events[K]) => void): void {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event]!.push(handler);
  }

  public emit<K extends keyof Events>(event: K, payload: Events[K]): void {
    const handlers = this.listeners[event];
    if (handlers) {
      handlers.forEach((fn) => fn(payload));
    }
  }
}

// ── Application Event Map ────────────────────────────────────
interface AppEvents {
  'user:login': { userId: UserId; timestamp: number };
  'agent:response': { sessionId: AgentSessionId; tokenCount: number };
}

const eventBus = new StronglyTypedEventBus<AppEvents>();

eventBus.on('user:login', (data) => {
  // data.userId and data.timestamp are automatically inferred and autocomplete!
  console.log("User logged in:", data.userId);
});

eventBus.emit('user:login', {
  userId: UserId("usr_100"),
  timestamp: Date.now()
});
```

Cloudflare Workers, Fastly Compute, and Vercel Edge runtimes require fast, lightweight, zero-dependency caching mechanisms.

```
export class EdgeTTLCache<K, V> {
  private cache = new Map<K, { value: V; expiresAt: number }>();

  constructor(private maxItems = 500, private defaultTTLMs = 60000) {}

  public set(key: K, value: V, ttlMs = this.defaultTTLMs): void {
    if (this.cache.size >= this.maxItems) {
      // Delete oldest entry (Map maintains insertion order)
      const firstKey = this.cache.keys().next().value;
      if (firstKey !== undefined) this.cache.delete(firstKey);
    }

    this.cache.set(key, {
      value,
      expiresAt: Date.now() + ttlMs
    });
  }

  public get(key: K): V | null {
    const entry = this.cache.get(key);
    if (!entry) return null;

    if (Date.now() > entry.expiresAt) {
      this.cache.delete(key);
      return null;
    }

    return entry.value;
  }
}
```

Here is how these patterns fit together in a high-concurrency production system:

```
┌────────────────┐      ┌───────────────────────┐      ┌──────────────────────┐
│ User / Agent   │ ───> │ Type-Safe MCP Tool    │ ───> │ Result<T, E> Wrapper │
│ Request Stream │      │ Validation (Zod Schema)│      │ Async Execution      │
└────────────────┘      └───────────────────────┘      └──────────────────────┘
        │                                                         │
        ▼                                                         ▼
┌────────────────┐                                     ┌──────────────────────┐
│ AbortController│                                     │ Resilient Circuit    │
│ Stream Iterator│ <────────────────────────────────── │ Breaker / Retry Pool │
└────────────────┘                                     └──────────────────────┘
```

`Result<T, E>`

) eliminates unhandled edge-case bugs.What is your favorite TypeScript async pattern in 2026? Are you using monadic Result types or relying on standard `try/catch`

block wrappers? **Drop your code snippets and thoughts in the comments below!**
