,

5 Real-World Examples of the Decorator Pattern in JavaScript

Posted by

Add logging, retries, caching, access control, and rate limits to your code without changing the original implementation; wrap it.

Add logging, retries, caching, access control, and rate limits to your code without changing the original implementation; wrap it.

Introduction: What “Decorator” really means (in JS that ships today)

In the GoF sense, a Decorator is a wrapper that preserves an object’s interface while adding behavior before/after delegating to the original. In JavaScript, you don’t need language-level @decorator syntax to do this:

  • At the function level, decorators are just higher-order functions (HOFs) that return a wrapped function.
  • At the object level, decorators are wrappers that implement the same methods and forward calls.
  • If you use TypeScript (or a transpiler), you can also use method/class decorators for syntactic sugar.

Below are 5 battle-tested decorator patterns you’ll use in real apps, with copy-paste code and gotchas.


1) Logging + Timing Decorator (observability without if-spam)

Problem: You want structured logs and durations for critical functions, without sprinkling console.log everywhere.

Idea: Wrap any function so it logs inputs, outputs, errors, and elapsed milliseconds.

// logDecorator.js
export function withLogging(fn, { name = fn.name || "anonymous", sink = console } = {}) {
return async function decorated(...args) {
const start = performance.now?.() ?? Date.now();
sink.info?.(`[${name}] call`, { args });
try {
const result = await fn(...args); // supports sync & async
const ms = (performance.now?.() ?? Date.now()) - start;
sink.info?.(`[${name}] ok`, { ms, resultPreview: preview(result) });
return result;
} catch (err) {
const ms = (performance.now?.() ?? Date.now()) - start;
sink.error?.(`[${name}] fail`, { ms, error: String(err) });
throw err;
}
};
}

function preview(v) {
if (v == null) return v;
const s = typeof v === "string" ? v : JSON.stringify(v);
return s.length > 120 ? s.slice(0, 117) + "…" : s;
}

Usage

const fetchUser = withLogging(async (id) => {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}, { name: "fetchUser" });

await fetchUser(42);

Why is this a true decorator

  • Interface is unchanged (same params/return).
  • Behavior is extended (logging + timing).
  • Can be stacked with other decorators (see next sections).

Gotchas

  • Avoid logging secrets (tokens, passwords). Redact sensitive keys in preview.

2) Retry + Exponential Backoff Decorator (resiliency for flaky IO)

Problem: Network/DB operations fail transiently. You want safe retries with jitter, but you don’t want retry code in every function.

Idea: Decorate an async function so failures are retried automatically before surfacing the error.

// retryDecorator.js
export function withRetry(fn, {
retries = 3,
baseDelayMs = 200,
maxDelayMs = 2000,
jitter = true,
shouldRetry = (err) => true // customize for 5xx, network errors, etc.
} = {}) {
return async function decorated(...args) {
let attempt = 0;
let lastErr;
while (attempt <= retries) {
try {
return await fn(...args);
} catch (err) {
lastErr = err;
if (attempt === retries || !shouldRetry(err)) break;
const delay = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
const sleep = jitter ? Math.random() * delay : delay;
await new Promise(r => setTimeout(r, sleep));
attempt++;
}
}
throw lastErr;
};
}

Usage

const fetchJson = async (url) => {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
};

const fetchJsonWithRetry = withRetry(fetchJson, {
retries: 4,
shouldRetry: (err) => /HTTP 5\d\d/.test(String(err)) // only retry 5xx
});

const data = await fetchJsonWithRetry("/api/stats");

Pro tip
 Compose with logging:

import { withLogging } from "./logDecorator.js";
import { withRetry } from "./retryDecorator.js";

const safeFetch = withLogging(withRetry(fetchJson), { name: "safeFetch" });

3) Caching/Memoization Decorator (avoid duplicate work)

Problem: You call an expensive function with the same inputs (API, CPU-heavy compute). You want TTL caching with a custom cache-key.

Idea: Decorate the function so identical calls return cached results until TTL expires.

// cacheDecorator.js
export function withCache(fn, {
key = (...args) => JSON.stringify(args),
ttlMs = 5_000,
map = new Map(), // replace with LRU if needed
} = {}) {
return async function decorated(...args) {
const k = key(...args);
const entry = map.get(k);
const now = Date.now();

if (entry && (now - entry.time) < ttlMs) return entry.value;

const value = await fn(...args);
map.set(k, { value, time: now });
return value;
};
}

Usage

const getUser = async (id) => (await fetch(`/api/users/${id}`)).json();

const getUserCached = withCache(getUser, {
ttlMs: 10_000,
key: (id) => `user:${id}`
});

await getUserCached(7); // network
await getUserCached(7); // cache hit

Gotchas

  • Side effects: Don’t cache functions that must run each time (e.g., writes).
  • Staleness: Use a short TTL, or add stale-while-revalidate: return cached value immediately and refresh in the background.
  • Memory: For long-running processes, use an LRU (e.g., tiny LFU/LRU implementation).

4) Access-Control / Guard Decorator (enforce policies at the edge)

Problem: You need role-based or feature-flag checks before executing service methods. You don’t want every method to implement authorization.

Idea: Decorate the method so it guards execution, throwing or returning a controlled error when not allowed.

Functional version (works in plain JS)

// guardDecorator.js
export function withGuard(fn, {
isAllowed = () => true,
onDeny = () => { throw Object.assign(new Error("Forbidden"), { status: 403 }); }
} = {}) {
return async function decorated(...args) {
if (!await isAllowed(...args)) return onDeny(...args);
return fn(...args);
};
}

Usage

const deleteUser = async (ctx, userId) => {
// … delete in DB
return { ok: true };
};

const deleteUserGuarded = withGuard(deleteUser, {
isAllowed: async (ctx) => ctx.user?.role === "admin",
onDeny: () => ({ ok: false, error: "forbidden" })
});

// later
await deleteUserGuarded({ user: { role: "viewer" } }, 42); // { ok:false, error:"forbidden" }

TypeScript method decorator (optional syntax sugar)

Requires enabling experimental decorators in your toolchain. This is just syntactic sugar around the same idea.

function RequireRole(role: string) {
return function (_target: any, _key: string, desc: PropertyDescriptor) {
const original = desc.value;
desc.value = async function (...args: any[]) {
const ctx = args[0]; // convention: first arg is context
if (ctx?.user?.role !== role) throw Object.assign(new Error("Forbidden"), { status: 403 });
return original.apply(this, args);
};
};
}

class UserService {
@RequireRole("admin")
async deleteUser(ctx: any, userId: number) { /* ... */ }
}

Why is this a decorator
 The contract doesn’t change; consumers still call the same method. The wrapper enforces cross-cutting policy.


5) Rate-Limit / Throttle / Debounce Decorator (protect resources & UX)

Problem: You want to limit how often a function runs (API calls, button clicks, loggers), without rewriting it.

Idea: Decorate the function to control frequency: throttle (at most once per window), debounce (run after quiet period), or token-bucket (N ops per window).

Throttle

// throttleDecorator.js
export function withThrottle(fn, intervalMs = 500) {
let last = 0, pending, lastArgs;
return function decorated(...args) {
const now = Date.now();
lastArgs = args;
if (now - last >= intervalMs) {
last = now; return fn(...args);
}
clearTimeout(pending);
pending = setTimeout(() => {
last = Date.now(); fn(...lastArgs);
}, intervalMs - (now - last));
};
}

Usage (UI)

const onScroll = () => { /* expensive reflow/layout */ };
window.addEventListener("scroll", withThrottle(onScroll, 100));

Debounce

export function withDebounce(fn, waitMs = 300) {
let t;
return function decorated(...args) {
clearTimeout(t);
t = setTimeout(() => fn(...args), waitMs);
};
}

Usage (search box)

const search = (q) => fetch(`/api/search?q=${encodeURIComponent(q)}`);
input.addEventListener("input", withDebounce((e) => search(e.target.value), 300));

Token-bucket (N calls per window)

export function withRateLimit(fn, { capacity = 10, windowMs = 1000 } = {}) {
let tokens = capacity;
let last = Date.now();
return function decorated(...args) {
const now = Date.now();
if (now - last >= windowMs) { tokens = capacity; last = now; }
if (tokens <= 0) return; // drop or enqueue, as you prefer
tokens--;
return fn(...args);
};
}

Object-Level Decorator Example (API client with transparent caching)

Function decorators are great, but the original GoF pattern shines when wrapping whole objects. Let’s decorate an API client by adding caching while preserving its interface.

// base client
function createApi({ baseURL }) {
return {
async get(path) {
const res = await fetch(baseURL + path);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
};
}

// object decorator
function withApiCache(client, { ttlMs = 5_000, key = (p) => p } = {}) {
const cache = new Map();
return {
async get(path) {
const k = key(path);
const hit = cache.get(k);
const now = Date.now();
if (hit && (now - hit.time) < ttlMs) return hit.value;
const value = await client.get(path);
cache.set(k, { value, time: now });
return value;
}
};
}

// usage
const api = withApiCache(createApi({ baseURL: "/api" }), { ttlMs: 10_000 });
await api.get("/users"); // network
await api.get("/users"); // cache

Why is this clean

  • Callers don’t change.
  • You can stack more object decorators: withAuth(withRetry(withApiCache(api))).

Composition: stacking decorators like LEGO

Decorators are composed by design. Build a pipeline of cross-cutting concerns without touching business logic.

import { withLogging }   from "./logDecorator.js";
import { withRetry } from "./retryDecorator.js";
import { withCache } from "./cacheDecorator.js";

const getProduct = async (id) => (await fetch(`/api/products/${id}`)).json();

const productReader =
withLogging(
withCache(
withRetry(getProduct, { retries: 3 }),
{ ttlMs: 8_000, key: (id) => `product:${id}` }
),
{ name: "getProduct" }
);

await productReader(123);

Order matters (e.g., cache outside retry to avoid caching failures).


Testing decorators (don’t test the world)

Strategy

  • Test the decorator in isolation with a stubbed function.
  • In service tests, assume a working decorator and focus on business logic.

Example test (logging + retry mini-spec)

import { withRetry } from "./retryDecorator.js";

test("retries then succeeds", async () => {
let calls = 0;
const sometimes = jest.fn(async () => {
calls++;
if (calls < 3) throw new Error("boom");
return "ok";
});

const wrapped = withRetry(sometimes, { retries: 5, jitter: false, baseDelayMs: 1 });
await expect(wrapped()).resolves.toBe("ok");
expect(calls).toBe(3);
});

Pitfalls & guardrails

  • Don’t change the signature: A decorator should keep the same args/return contract (except for documented enhancements like caching).
  • Beware of this: If you decorate methods that use this, bind correctly: descriptor.value = function (...args) { return original.call(this, ...args); }. For HOFs, prefer arrow functions that capture lexical this if needed.
  • Error semantics: A retry decorator should rethrow the original error when retries are exhausted, not swallow it.
  • Idempotency: Only retry idempotent operations (GET or safe PUT).
  • Caching hazards: Don’t cache non-deterministic outputs unless you really mean it.
  • Performance: Each layer adds tiny overhead. In practice, it’s negligible vs IO, but avoid decorating hot inner loops.

Quick reference (copy/paste)

  • withLogging(fn, opts) → logs inputs/outputs/duration, handles errors.
  • withRetry(fn, opts) → retries async failures with backoff.
  • withCache(fn, { ttlMs, key }) → memoizes results by key.
  • withGuard(fn, { isAllowed, onDeny }) → enforces authorization or feature flags.
  • withThrottle/withDebounce/withRateLimit → controls call frequency.
  • Object wrappers like withApiCache(client) → GoF decorator style for whole interfaces.

Conclusion

The Decorator Pattern lets you keep business code clean while layering cross-cutting concerns, such as logging, retries, caching, auth, and rate-limits from the outside. In JavaScript, that means simple, composable wrappers for functions or objects, and (optionally) decorator syntax if you use TypeScript.

Once you have these utilities in your toolkit, you can standardize behavior across a codebase with zero invasive refactors.


Call to Action

Which decorator will save you the most time this week: logging, retry, or caching? Tell me what you’re building and I’ll help tailor a decorator snippet for it 👇

📤 Share with a teammate who keeps adding logging ‘just this once’.
🔖 Bookmark this as your decorator cheat sheet for future projects.

Leave a Reply

Your email address will not be published. Required fields are marked *