CodeFixesHub
    programming tutorial

    Typing Mediator Pattern Implementations in TypeScript

    Implement strongly typed Mediator patterns in TypeScript—decoupled messaging, safe routing, and testable systems. Follow hands-on examples and start building now.

    article details

    Quick Overview

    TypeScript
    Category
    Sep 17
    Published
    25
    Min Read
    3K
    Words
    article summary

    Implement strongly typed Mediator patterns in TypeScript—decoupled messaging, safe routing, and testable systems. Follow hands-on examples and start building now.

    Typing Mediator Pattern Implementations in TypeScript

    Introduction

    Large applications often suffer when many modules talk to each other directly: tight coupling, sprawling dependencies, and brittle code. The Mediator pattern provides a clean solution by centralizing communication into a single component that encapsulates how objects interact. For TypeScript projects, however, simply wiring a mediator in JavaScript is not enough. Strong typings ensure safe message contracts, discoverability, and better tooling support as systems evolve.

    In this tutorial you'll learn how to design, type, and implement Mediator pattern variations in TypeScript. We'll cover message models, strongly typed channels, command vs event mediators, request/response patterns, lifecycle and subscription management, runtime validation, and strategies for testing and composition. Code examples focus on practical patterns that scale: typed APIs for publishers and subscribers, generics for message payloads, and runtime assertion hooks to keep behavior resilient in production.

    This article is aimed at intermediate TypeScript developers who are comfortable with generics, mapped types, and basic design patterns. Expect step-by-step examples, explanations of trade-offs, and references to related patterns (such as Observer, Command, and Proxy) so you can integrate the mediator into real systems safely.

    What you will learn:

    • How to design a typed message bus with compile-time guarantees
    • When to choose command-style or event-style mediators
    • How to add request/response and typed error handling
    • Techniques for composition, testing, and performance tuning

    By the end, you'll have reusable, well-documented mediator implementations you can adapt to web apps, microservices coordination layers, or in-process domain logic.

    Background & Context

    The Mediator pattern centralizes interactions between components into a single object, reducing direct coupling among peers. Unlike the Observer pattern, which is often a many-to-many publish/subscribe relationship, a Mediator tends to orchestrate conversations and may embed higher-level rules, validation, and routing logic. TypeScript adds another dimension: the ability to describe the exact shape of messages and handlers using the type system, which prevents many bugs at compile time.

    Using strong typing with patterns like Mediator is especially valuable in teams and larger codebases, because it documents runtime contracts and helps IDEs provide meaningful autocompletion. The Mediator pattern also pairs naturally with other patterns: for example, commands routed through a mediator often map to implementations described in a typed Command pattern. When you need memory-safe subscriptions or async streaming, consider also reading about typed Observer implementations to compare trade-offs.

    For additional context on decoupling patterns and typed approaches, see practical discussions of Typing Command Pattern Implementations in TypeScript and Typing Observer Pattern Implementations in TypeScript.

    Key Takeaways

    • Design message contracts with discriminated unions and mapped types to get compile-time guarantees.
    • Separate command (one handler) from event (many handlers) semantics in your mediator API.
    • Use generics to express request/response shapes and maintain type safety across publish/subscribe calls.
    • Add runtime assertion functions for external input validation to complement compile-time types.
    • Test mediator wiring with lightweight mocks and deterministic handler registration.
    • Consider performance: avoid unnecessary allocations and prefer synchronous dispatch where appropriate.

    Prerequisites & Setup

    You should have:

    • TypeScript 4.x or later installed.
    • Familiarity with generics, mapped types, and conditional types.
    • A code editor with TypeScript language support.

    Create a small project scaffold with a tsconfig and the usual npm setup. For runtime type assertions consider adding a lightweight validation tool or using custom assertion functions — see the guide on Using Assertion Functions in TypeScript (TS 3.7+) for patterns you can reuse.

    Example initial setup commands:

    javascript
    mkdir typed-mediator && cd typed-mediator
    npm init -y
    npm install -D typescript
    npx tsc --init

    Now create a src folder and open your editor to follow the examples below.

    Main Tutorial Sections

    1) Define a Typed Message Catalog

    Start by defining a catalog of message types. The catalog maps a message name to its payload and response shape. Using a centralized type map allows the compiler to enforce handler signatures.

    Example:

    javascript
    type MessageMap = {
      'user.created': { payload: { id: string; name: string }; response: void };
      'auth.request': { payload: { userId: string }; response: { token: string } };
      'log.info': { payload: { message: string }; response: void };
    };
    
    type MessageNames = keyof MessageMap;

    This approach makes it easy to add new messages while keeping the contract explicit. When you design large APIs, a similar catalog helps avoid ad-hoc string keys or mismatched payloads.

    Related reading: typed module design can help structure these catalogs. See Typing Module Pattern Implementations in TypeScript — Practical Guide for packaging strategies.

    2) Basic Mediator Type Signatures

    With a catalog, declare a mediator interface that exposes publish and request operations with generic constraints derived from MessageMap.

    javascript
    interface Mediator<M extends Record<string, any>> {
      publish<K extends keyof M>(topic: K, payload: M[K]['payload']): void;
      request<K extends keyof M>(topic: K, payload: M[K]['payload']): Promise<M[K]['response']>;
      subscribe<K extends keyof M>(topic: K, handler: (payload: M[K]['payload']) => void): () => void;
    }

    This enforces that publishers and subscribers agree on the message shape. For request/response, request returns a Promise of the typed response.

    If you come from patterns like Command, the request/response style is similar to typed command dispatch and factories. See Typing Command Pattern Implementations in TypeScript and Typing Factory Pattern Implementations in TypeScript for complementary techniques.

    3) Implement a Simple In-Process Mediator

    Implement an in-memory mediator optimized for synchronous publish and async request/response. Keep the implementation minimal and type-safe.

    javascript
    function createMediator<M extends Record<string, any>>() {
      const subs = new Map<keyof M, Set<Function>>();
    
      return {
        publish<K extends keyof M>(topic: K, payload: M[K]['payload']) {
          const handlers = subs.get(topic);
          if (!handlers) return;
          for (const h of handlers) {
            try { h(payload); } catch (e) { console.error(e); }
          }
        },
    
        async request<K extends keyof M>(topic: K, payload: M[K]['payload']) {
          const handlers = subs.get(topic);
          if (!handlers || handlers.size === 0) throw new Error('No handler');
          // For a command-style request, expect a single handler
          const handler = Array.from(handlers)[0] as (p: any) => Promise<any> | any;
          return Promise.resolve(handler(payload));
        },
    
        subscribe<K extends keyof M>(topic: K, handler: (payload: M[K]['payload']) => any) {
          let handlers = subs.get(topic);
          if (!handlers) { handlers = new Set(); subs.set(topic, handlers); }
          handlers.add(handler as Function);
          return () => handlers!.delete(handler as Function);
        }
      } as Mediator<M>;
    }

    This basic mediator is good for in-process communication. For memory-safe subscriptions in complex apps, consider patterns described in the Typing Observer Pattern Implementations in TypeScript.

    4) Enforcing Command vs Event Semantics

    A robust mediator API distinguishes commands (single handler, potentially returning a response) from events (multiple handlers, no response). Use separate typed methods to avoid accidental misuse.

    javascript
    interface TypedMediator<M> {
      emitEvent<K extends keyof M>(topic: K, payload: M[K]['payload']): void; // many handlers
      handleCommand<K extends keyof M>(topic: K, handler: (p: M[K]['payload']) => Promise<M[K]['response']> | M[K]['response']): void;
      sendCommand<K extends keyof M>(topic: K, payload: M[K]['payload']): Promise<M[K]['response']>;
    }

    This eliminates ambiguity: commands have a single designated handler set by handleCommand; sendCommand delegates to it and returns a typed response. Events use emitEvent, delivering to all subscribers. This separation mirrors practice in typed Command and Event/Observer implementations.

    5) Adding Runtime Assertions and Guards

    Types are compile-time only; when messages come from external sources (HTTP, sockets), add runtime checks. Use assertion functions to validate payloads and keep type safety at runtime.

    javascript
    function assertIs<T>(value: unknown, guard: (v: unknown) => v is T): asserts value is T {
      if (!guard(value)) throw new Error('Invalid payload');
    }
    
    // Example guard for user.created
    function isUserCreated(v: unknown): v is { id: string; name: string } {
      return typeof v === 'object' && v != null && 'id' in v && 'name' in v;
    }

    Check the guide on Using Assertion Functions in TypeScript (TS 3.7+) for patterns and performance considerations. Validating externally-sourced messages prevents class of runtime errors that TypeScript cannot catch.

    6) Request/Response with Timeout and Error Types

    Often requests need timeouts and standardized error types. Encode response types as either success or failure to preserve typing.

    javascript
    type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
    
    // Mediator request returns Result<M[K]['response'], MyError>

    Implementing timeout:

    javascript
    async function withTimeout<T>(p: Promise<T>, ms: number) {
      let timer: any;
      const t = new Promise<never>((_, rej) => timer = setTimeout(() => rej(new Error('timeout')), ms));
      return Promise.race([p, t]).finally(() => clearTimeout(timer));
    }

    This pattern helps building resilient systems — combine with typed errors for predictable handling.

    7) Composition and Modularization

    When mediators grow, split responsibilities into domain-specific sub-mediators and compose them with a root mediator that routes messages. Use typed modules and factories to construct submediators with narrowed MessageMaps.

    javascript
    // domain A messages
    type AMap = { 'a.do': { payload: { x: number }; response: number } };
    // domain B messages
    type BMap = { 'b.log': { payload: { msg: string }; response: void } };

    Compose or adapt message names to avoid collisions. For module composition patterns, see the Typing Module Pattern Implementations in TypeScript — Practical Guide and Typing Adapter Pattern Implementations in TypeScript — A Practical Guide for techniques to adapt and expose clean APIs.

    8) Integration with Other Patterns (Command, State, Proxy)

    Mediator often complements other patterns. Commands can be dispatched through the mediator; state machines can emit events and react to commands; proxies can intercept messages for logging or caching.

    Example of a proxy that logs messages before forwarding to the real mediator:

    javascript
    function loggingProxy<M extends Record<string, any>>(mediator: Mediator<M>) {
      return {
        publish(topic: any, payload: any) {
          console.log('publish', String(topic), payload);
          return mediator.publish(topic, payload);
        },
        // forward other methods similarly
      } as Mediator<M>;
    }

    9) Performance Considerations and Memory Safety

    When subscriptions are dynamic, avoid leaks by returning disposable unsubscribes and using weak references where supported. Keep payload cloning optional to avoid allocations on hot paths and prefer synchronous dispatch for small microsecond handlers.

    • Return and call unsubscribe functions for deterministic cleanup.
    • Avoid creating arrays from sets on every publish; iterate using for-of.
    • For high-frequency events, consider batching or debouncing — the same techniques used in typed utilities for debounce and throttling and memoization help here.

    Example: batch publishes within a tick using a microtask queue.

    javascript
    let scheduled = false;
    const queue: Array<() => void> = [];
    function schedule(fn: () => void) {
      queue.push(fn);
      if (!scheduled) {
        scheduled = true;
        Promise.resolve().then(() => {
          scheduled = false;
          while (queue.length) queue.shift()!();
        });
      }
    }

    Advanced Techniques

    Once the basics are working, you can optimize and extend the mediator with advanced strategies:

    • Strongly typed middleware chain: implement a pipeline that decorates requests and responses with typed middlewares similar to HTTP middleware, preserving generic types across stages.
    • Variadic messaging: support tuple payloads using variadic tuple types to model handlers with multiple args.
    • Generic routing rules: use mapped types to auto-generate routing tables that validate at compile time which domain can handle which message.
    • Runtime schema validation: integrate lightweight schema validators for cross-service contracts; combine runtime guards with compile-time types to get the best of both worlds. See Using Assertion Functions in TypeScript (TS 3.7+) for patterns.
    • Performance profiling: measure handler invocation time; if a handler is expensive, consider offloading to background workers or throttling the event stream.

    If you use higher-order utilities or compose functions around handlers, review best practices from Typing Higher-Order Functions in TypeScript — Advanced Scenarios to preserve types and 'this' behavior.

    Best Practices & Common Pitfalls

    Dos:

    • Use a clear message catalog with discriminated unions to keep contracts explicit.
    • Distinguish commands vs events in the API surface.
    • Return unsubscribe functions and test for memory leaks.
    • Add runtime validation on external inputs.
    • Write unit tests that assert handler registration and message routing.

    Don'ts / Pitfalls:

    • Avoid mixing event and command semantics under a single method — this causes surprising behavior.
    • Don’t assume order of handlers unless you control registration semantics.
    • Avoid excessive cloning of payloads on hot paths; prefer documentation that payloads are immutable or cloned by callers.
    • Don’t couple business logic directly into the mediator — keep it as orchestration and routing.

    Troubleshooting tips:

    • If a request throws 'No handler', check registration timing and lifecycle: ensure the handler is registered before the request is sent.
    • If handlers do not receive messages, inspect topic keys for typos or mismatched string constants. Centralized catalogs help avoid this.
    • Use runtime logs or a lightweight proxy to inspect messages during development. Consider a Proxy pattern for read-only inspection: see Typing Proxy Pattern Implementations in TypeScript.

    Real-World Applications

    Mediator patterns are useful across many domains:

    • UI coordination: decouple components that must communicate indirectly; events carry typed payloads for predictable interactions.
    • Micro-frontends: mediators can coordinate cross-app messages in a typed fashion, making integration safer.
    • Background tasks and workers: dispatch commands to worker handlers with request/response semantics.
    • Domain orchestration: mediate complex domain workflows by wiring commands and events between bounded contexts; pairing mediator with typed State Pattern implementations helps keep transitions explicit.

    If caching or memoization of results is needed for request-heavy handlers, reuse patterns from Typing Memoization Functions in TypeScript. For timed or rate-limited events, review the Typing Debounce and Throttling Functions in TypeScript guide.

    Conclusion & Next Steps

    A typed Mediator brings disciplined, discoverable, and testable communication into TypeScript applications. Start simple with a catalog and an in-process mediator, then evolve the API to separate command and event semantics, add runtime guards, and compose domain mediators. Continue by exploring typed implementations of related patterns (Command, Observer, Proxy) to build robust, decoupled systems.

    Next steps:

    • Implement a small module using the mediator in a demo app.
    • Add runtime assertions and unit tests for all handlers.
    • Explore middleware and advanced routing for production needs.

    For complementary patterns and packaging, check the guides on Typing Adapter Pattern Implementations in TypeScript — A Practical Guide and Typing Module Pattern Implementations in TypeScript — Practical Guide.

    Enhanced FAQ

    Q: What is the main difference between Mediator and Observer?

    A: Observer is typically a publish/subscribe mechanism where subjects broadcast to observers; multiple observers can handle the same event and their order is not usually guaranteed. Mediator centralizes interactions and often enforces orchestration rules, making it suitable for coordinating complex workflows. For a deep dive on the Observer pattern and how it handles typed subscriptions, see Typing Observer Pattern Implementations in TypeScript.

    Q: Should I use a mediator for every cross-component interaction?

    A: Not necessarily. Use mediator where decoupling and orchestration help manage complexity. For trivial one-off interactions, simple callbacks or direct method calls might be clearer. Overusing a mediator can lead to a god object that hides control flow. Prefer small domain mediators when needed and compose them with factories and modules as described in Typing Factory Pattern Implementations in TypeScript and Typing Module Pattern Implementations in TypeScript — Practical Guide.

    Q: How do I type middleware that wraps requests and responses?

    A: Design middleware as a higher-order function that preserves generic types. A middleware receives the next handler and returns a new handler. Maintaining accurate types across multiple middleware layers is easier if you rely on helper generic types and follow patterns in Typing Higher-Order Functions in TypeScript — Advanced Scenarios.

    Q: How do I test a typed mediator effectively?

    A: Keep tests focused on wiring and contracts rather than implementation details. Register mock handlers and assert they receive expected payloads. For request/response flows, use deterministic mocks returning known values and assert result shapes. Use dependency injection to replace real handlers with spies. For complex orchestration, test at the domain integration level and mock external services.

    Q: Can a mediator support cross-process communication (e.g., between services)?

    A: Yes. The mediator conceptually applies across boundaries, but you must bridge serialization and transport. Define a shared schema (typed catalog) and runtime validators on both sides to avoid mismatches. Consider using adapters for transport specifics; refer to Typing Adapter Pattern Implementations in TypeScript — A Practical Guide for patterns to adapt message shapes and transport protocols.

    Q: How do I handle persistence or caching for mediator responses?

    A: Cache responses at the handler level or via a proxy layer that intercepts requests and returns cached results when appropriate. Typed memoization utilities can help; review Typing Memoization Functions in TypeScript for safe caching strategies. For complex caches, maintain typed eviction policies and ensure you invalidate caches when underlying data changes.

    Q: What about security and validation for messages coming from untrusted clients?

    A: Always validate untrusted inputs at runtime. Use assertion functions or schema validators to guard payload shapes; see Using Assertion Functions in TypeScript (TS 3.7+). Additionally, perform authorization checks in middleware or handler code before acting on a message.

    Q: When should I choose Mediator over a message broker or event bus?

    A: Use an in-process mediator where low-latency, synchronous routing, and direct function calls are acceptable. For cross-process or distributed systems, a message broker is often necessary. You can still apply the same typed contract principles: keep a shared message catalog and validation logic, and implement adapters that translate broker messages into in-process mediator calls. For distributed coordination patterns, pairing mediator-style orchestration with typed adapters and proxies avoids contract drift; see the Typing Adapter Pattern Implementations in TypeScript — A Practical Guide for bridging patterns.

    Q: Any tips for scaling mediators in large codebases?

    A: Split the message catalog by domain, use namespacing for topics, and compose domain mediators into a root router. Keep handler responsibilities small and avoid monolithic mediator logic. Use typed factories to instantiate domain mediators and apply typed module patterns to manage visibility and exports; see Typing Module Pattern Implementations in TypeScript — Practical Guide for modularization tips.

    Q: How do I combine mediator with state machines?

    A: Use mediator events to drive transitions and commands to request state changes. Keep state machines isolated and let the mediator orchestrate interactions between machines or other modules. For detailed state typing and safe transitions, consult Typing State Pattern Implementations in TypeScript.


    Further reading and related patterns mentioned throughout this article include guides on command, observer, proxy, adapter, factory, module, and higher-order typing utilities. If you enjoyed this tutorial, check out the linked resources to deepen your skills and integrate the Mediator pattern into a robust, typed architecture.

    article completed

    Great Work!

    You've successfully completed this TypeScript tutorial. Ready to explore more concepts and enhance your development skills?

    share this article

    Found This Helpful?

    Share this TypeScript tutorial with your network and help other developers learn!

    continue learning

    Related Articles

    Discover more programming tutorials and solutions related to this topic.

    No related articles found.

    Try browsing our categories for more content.

    Content Sync Status
    Offline
    Changes: 0
    Last sync: 11:19:58 PM
    Next sync: 60s
    Loading CodeFixesHub...