Proxy Decorator Pattern

Proxy Decorator Pattern

The proxy decorator pattern creates a wrapper object that controls access to another object, intercepting method calls to add behavior like lazy initialization, logging, or access control.

The Proxy implements the same interface as the target (dynamically without having to write boilerplate code), making it transparent to clients.

Note: Use libraries instead of self implementation

There are caveats to handle proxy pattern appropriately that would warrant looking for libraries if proxy pattern is needed.

Like having appropriate handling for

  if (prop === 'then' || prop === 'toJSON') 

Simple Examples:

GT-Sandbox-Snapshot: In this example, the proxy defers object creation until first use—method calls wait for the real object to become available, then forward automatically once it's ready.

Code

import { describe, it, expect, beforeEach, vi } from 'vitest';


// Factory that creates a proxy wrapping deferred initialization
function createDeferredClient(clientPromise: Promise<ApiClient>): ApiClient {
  let resolvedClient: ApiClient | undefined;

  // Cache the resolved client
  clientPromise
    .then(c => resolvedClient = c);

  return new Proxy({} as ApiClient, {
    get: (_, prop: keyof ApiClient) => {
      return async (...args: any[]) => {
        let client;
        if (resolvedClient) {
          client = resolvedClient;
        } else {
          myLog("In-Proxy", "client is NOT yet defined awaiting on client Promise");

          client = await clientPromise;
        }

        return (client[prop] as any)(...args);
      };
    }
  });
}

describe('Proxy Pattern - Deferred Client', () => {
  it('should defer method calls until client is ready (factory approach)', async () => {
    console.log("");
    myLog("In-Test", "should defer method calls until client is ready (factory approach)...")

    // Setup: Create a promise we control
    // [Deferred Promise Resolution Pattern](http://www.glassthought.com/notes/3twym7huaodrb22qva8d4lf)
    let resolveClient: (client: ApiClient) => void;
    const clientPromise = new Promise<ApiClient>(resolve => {
      resolveClient = resolve;
    });

    // Create deferred client directly (no separate factory call)
    const client = createDeferredClient(clientPromise);

    // Start calls before real client exists (they'll wait)
    myLog("In-Test", "calling: client.getName()")
    const namePromise = client.getName();

    myLog("In-Test", "calling: client.getCount()")
    const countPromise = client.getCount();

    // Simulate async client initialization (e.g., after 100ms)
    let delay = 100;
    myLog("In-Test", `setTimeout to resolve client (after ${delay}ms)`);
    setTimeout(() => {
      myLog("In-Test", "resolving the client")

      resolveClient!(new RealApiClient());
    }, delay);

    // Calls complete after client becomes available
    expect(await namePromise).toBe('Real Client');
    expect(await countPromise).toBe(42);
  });


  it('should work with methods that have parameters', async () => {
    console.log("");
    myLog("In-Test", "should work with methods that have parameters...");

    const client = createDeferredClient(Promise.resolve(new RealApiClient()));

    const results = await client.search('typescript proxy');
    expect(results).toEqual(['result for: typescript proxy']);
  });
});


// Simulated client interface
interface ApiClient {
  getName(): Promise<string>;

  getCount(): Promise<number>;

  search(query: string): Promise<string[]>;
}

// Real implementation (would be created later)
class RealApiClient implements ApiClient {
  async getName() {
    myLog("In-RealClient", "real getName() called")
    return 'Real Client';
  }

  async getCount() {
    myLog("In-RealClient", "real getCount() called")
    return 42;
  }

  async search(query: string) {
    myLog("In-RealClient", `real search("${query}") called`)
    return [`result for: ${query}`];
  }
}

const startMillis = Date.now();

function myLog(where: "In-Test" | "In-Proxy" | "In-RealClient", msg: string) {
  console.log(`[elapsed: ${String(Date.now() - startMillis).padStart(4)}][${where}] ${msg}`);
}

Command to reproduce:

gt.sandbox.checkout.commit ba9797f26d8ef5c76854 \
&& cd "${GT_SANDBOX_REPO}" \
&& cmd.run.announce "./run.sh"

Recorded output of command:


up to date, audited 179 packages in 3s

33 packages are looking for funding
  run `npm fund` for details

2 moderate severity vulnerabilities

To address all issues (including breaking changes), run:
  npm audit fix --force

Run `npm audit` for details.

> glassthought-sandbox@1.0.0 test
> vitest run


 RUN  v0.34.6 /home/nickolaykondratyev/git_repos/glassthought-sandbox

stdout | src/main.test.ts > Proxy Pattern - Deferred Client > should defer method calls until client is ready (factory approach)

[elapsed:    1][In-Test] should defer method calls until client is ready (factory approach)...
[elapsed:    1][In-Test] calling: client.getName()
[elapsed:    2][In-Proxy] client is NOT yet defined awaiting on client Promise
[elapsed:    2][In-Test] calling: client.getCount()
[elapsed:    2][In-Proxy] client is NOT yet defined awaiting on client Promise
[elapsed:    2][In-Test] setTimeout to resolve client (after 100ms)

stdout | src/main.test.ts > Proxy Pattern - Deferred Client > should defer method calls until client is ready (factory approach)
[elapsed:  102][In-Test] resolving the client
[elapsed:  102][In-RealClient] real getName() called
[elapsed:  103][In-RealClient] real getCount() called

stdout | src/main.test.ts > Proxy Pattern - Deferred Client > should work with methods that have parameters

[elapsed:  104][In-Test] should work with methods that have parameters...
[elapsed:  104][In-Proxy] client is NOT yet defined awaiting on client Promise
[elapsed:  104][In-RealClient] real search("typescript proxy") called

 ✓ src/main.test.ts  (2 tests) 105ms

 Test Files  1 passed (1)
      Tests  2 passed (2)
   Start at  12:41:21
   Duration  613ms (transform 200ms, setup 0ms, collect 13ms, tests 105ms, environment 0ms, prepare 234ms)
GT-Sandbox-Snapshot: In this example Proxy throws if its called prior to real client being present.

Code

import { describe, it, expect, beforeEach, vi } from 'vitest';


// Factory that creates a proxy wrapping deferred initialization
function createDeferredClient(clientPromise: Promise<ApiClient>): ApiClient {
  let resolvedClient: ApiClient | undefined;

  // Cache the resolved client
  clientPromise
    .then(c => resolvedClient = c);

  return new Proxy({} as ApiClient, {
    get: (_, prop: keyof ApiClient) => {
      return async (...args: any[]) => {
        let client;
        if (resolvedClient) {
          client = resolvedClient;
        } else {
          throw Error("Not ready yet you shouldn't be calling me!")
        }

        return (client[prop] as any)(...args);
      };
    }
  });
}

describe('Proxy Pattern - Deferred Client', () => {
  it('Proxy throws if not ready yet', async () => {
    console.log("");

    // Setup: Create a promise we control
    // [Deferred Promise Resolution Pattern](http://www.glassthought.com/notes/3twym7huaodrb22qva8d4lf)
    let resolveClient: (client: ApiClient) => void;
    const clientPromise = new Promise<ApiClient>(resolve => {
      resolveClient = resolve;
    });

    // Create deferred client directly (no separate factory call)
    const client = createDeferredClient(clientPromise);

    // Start calls before real client exists (they'll wait)
    myLog("In-Test", "calling: client.getName()")

    // expect to throw
    await expect(client.getName()).rejects.toThrow("Not ready yet you shouldn't be calling me!");
  });
});


// Simulated client interface
interface ApiClient {
  getName(): Promise<string>;

  getCount(): Promise<number>;

  search(query: string): Promise<string[]>;
}

// Real implementation (would be created later)
class RealApiClient implements ApiClient {
  async getName() {
    myLog("In-RealClient", "real getName() called")
    return 'Real Client';
  }

  async getCount() {
    myLog("In-RealClient", "real getCount() called")
    return 42;
  }

  async search(query: string) {
    myLog("In-RealClient", `real search("${query}") called`)
    return [`result for: ${query}`];
  }
}

const startMillis = Date.now();

function myLog(where: "In-Test" | "In-Proxy" | "In-RealClient", msg: string) {
  console.log(`[elapsed: ${String(Date.now() - startMillis).padStart(4)}][${where}] ${msg}`);
}

Command to reproduce:

gt.sandbox.checkout.commit 98d13ea135c3eac3c0c7 \
&& cd "${GT_SANDBOX_REPO}" \
&& cmd.run.announce "./run.sh"

Recorded output of command:


up to date, audited 179 packages in 1s

33 packages are looking for funding
  run `npm fund` for details

2 moderate severity vulnerabilities

To address all issues (including breaking changes), run:
  npm audit fix --force

Run `npm audit` for details.

> glassthought-sandbox@1.0.0 test
> vitest run


 RUN  v0.34.6 /home/nickolaykondratyev/git_repos/glassthought-sandbox

 ✓ src/main.test.ts  (1 test) 2ms
stdout | src/main.test.ts > Proxy Pattern - Deferred Client > Proxy throws if not ready yet

[elapsed:    1][In-Test] calling: client.getName()


 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  12:46:03
   Duration  462ms (transform 52ms, setup 0ms, collect 12ms, tests 2ms, environment 0ms, prepare 120ms)