The Decorator Pattern in JavaScript
Learn the Decorator pattern in JavaScript -- wrap functions and classes to add logging, caching, validation, and retry logic without modifying original code.
What you'll learn
- ✓How the Decorator pattern works in JavaScript
- ✓Building function decorators for logging, caching, and retries
- ✓Using the TC39 decorators proposal with classes
Prerequisites
- •JavaScript functions and closures
- •Basic understanding of classes
A decorator wraps a piece of functionality to extend or modify its behavior without changing the original code. In JavaScript, this pattern appears in two forms: function decorators (a well-established technique) and class decorators (a TC39 proposal now at Stage 3). Both are powerful tools for separation of concerns.
Function decorators
A function decorator is a higher-order function that takes a function, adds behavior, and returns a new function.
Basic structure
function withLogging(fn) {
return function (...args) {
console.log(`Calling ${fn.name} with`, args);
const result = fn.apply(this, args);
console.log(`${fn.name} returned`, result);
return result;
};
}
function add(a, b) {
return a + b;
}
const loggedAdd = withLogging(add);
loggedAdd(2, 3);
// "Calling add with [2, 3]"
// "add returned 5"
The original add function is unchanged. The decorator wraps it with new behavior.
Memoization decorator
Caching expensive function results is one of the most common decorator use cases.
function memoize(fn) {
const cache = new Map();
return function (...args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key);
}
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
// Without memoization: exponential time
// With memoization: linear time
fibonacci = memoize(fibonacci);
console.log(fibonacci(40)); // Instant
Retry decorator
Automatically retry a function that might fail due to transient errors.
function withRetry(fn, maxRetries = 3, delayMs = 1000) {
return async function (...args) {
let lastError;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await fn.apply(this, args);
} catch (error) {
lastError = error;
console.warn(`Attempt ${attempt} failed: ${error.message}`);
if (attempt < maxRetries) {
await new Promise((r) => setTimeout(r, delayMs * attempt));
}
}
}
throw lastError;
};
}
async function fetchUserData(userId) {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
const resilientFetch = withRetry(fetchUserData, 3, 500);
const user = await resilientFetch(123);
Debounce decorator
function debounce(fn, delayMs) {
let timeoutId;
return function (...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn.apply(this, args), delayMs);
};
}
const handleResize = debounce(() => {
console.log("Window resized to", window.innerWidth);
}, 250);
window.addEventListener("resize", handleResize);
Composing decorators
Multiple decorators can be composed. The order matters — the outermost decorator runs first.
function compose(...decorators) {
return function (fn) {
return decorators.reduceRight((wrapped, decorator) => decorator(wrapped), fn);
};
}
function withTiming(fn) {
return async function (...args) {
const start = performance.now();
const result = await fn.apply(this, args);
console.log(`${fn.name} took ${(performance.now() - start).toFixed(2)}ms`);
return result;
};
}
function withErrorBoundary(fn) {
return async function (...args) {
try {
return await fn.apply(this, args);
} catch (error) {
console.error(`Error in ${fn.name}:`, error);
return null;
}
};
}
// Apply multiple decorators
const enhance = compose(withErrorBoundary, withTiming, withRetry);
const robustFetch = enhance(fetchUserData);
Validation decorator
function validateArgs(...validators) {
return function (fn) {
return function (...args) {
for (let i = 0; i < validators.length; i++) {
if (validators[i] && !validators[i](args[i])) {
throw new TypeError(
`Argument ${i} failed validation in ${fn.name}`
);
}
}
return fn.apply(this, args);
};
};
}
const isString = (v) => typeof v === "string";
const isPositive = (v) => typeof v === "number" && v > 0;
const createUser = validateArgs(isString, isPositive)(
function createUser(name, age) {
return { name, age };
}
);
createUser("Alice", 30); // { name: "Alice", age: 30 }
createUser("Bob", -5); // TypeError: Argument 1 failed validation
TC39 class decorators
The TC39 decorators proposal (Stage 3) brings decorator syntax to JavaScript classes. While not yet available in all environments without a transpiler, the syntax is stabilizing.
Method decorators
function logged(originalMethod, context) {
const methodName = String(context.name);
return function (...args) {
console.log(`Entering ${methodName}`);
const result = originalMethod.call(this, ...args);
console.log(`Exiting ${methodName}`);
return result;
};
}
class Calculator {
@logged
add(a, b) {
return a + b;
}
@logged
multiply(a, b) {
return a * b;
}
}
const calc = new Calculator();
calc.add(2, 3);
// "Entering add"
// "Exiting add"
Class decorators
function singleton(Class, context) {
let instance;
return class extends Class {
constructor(...args) {
if (instance) return instance;
super(...args);
instance = this;
}
};
}
@singleton
class AppConfig {
constructor() {
this.settings = {};
}
}
const a = new AppConfig();
const b = new AppConfig();
console.log(a === b); // true
Field decorators
function readonly(value, context) {
return function (initialValue) {
return initialValue;
};
}
// With accessor keyword
function clamped(min, max) {
return function (value, context) {
return {
get() {
return value.get.call(this);
},
set(val) {
value.set.call(this, Math.min(max, Math.max(min, val)));
},
};
};
}
Method decorator without proposal syntax
You can apply the decorator pattern to class methods today without the Stage 3 syntax, using a utility function.
function decorateMethod(target, methodName, decorator) {
const original = target.prototype[methodName];
target.prototype[methodName] = decorator(original);
}
class UserService {
async getUser(id) {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
}
// Apply decorators without special syntax
decorateMethod(UserService, "getUser", withRetry);
decorateMethod(UserService, "getUser", withTiming);
When to use decorators
Decorators work best for cross-cutting concerns — behavior that applies across many functions or methods but is not part of their core logic:
- Logging and tracing — record function calls without cluttering business logic.
- Caching and memoization — avoid recomputation of expensive results.
- Retry and resilience — handle transient failures in network calls.
- Validation — enforce argument constraints at the boundary.
- Access control — check permissions before method execution.
- Rate limiting — throttle or debounce function calls.
Avoid over-decorating. If a function has five decorators stacked on it, the execution flow becomes hard to follow. Use decorators for genuine cross-cutting concerns, not as a substitute for clear function design.
Summary
The Decorator pattern extends functionality by wrapping rather than modifying:
- Function decorators are higher-order functions that add behavior like logging, caching, retries, and validation.
- Decorators compose naturally —
compose(withRetry, withLogging)(fn)applies both. - The TC39 class decorators proposal (Stage 3) brings
@decoratorsyntax to methods, fields, and classes. - You can apply the same pattern to class methods today without special syntax.
- Use decorators for cross-cutting concerns to keep business logic clean and focused.
Related articles
- JavaScript JavaScript Design Patterns Every Developer Should Know
Learn the most useful JavaScript design patterns with practical examples including Singleton, Observer, Factory, Strategy, and more for cleaner code.
- JavaScript JavaScript Module Federation Explained
Understand Module Federation for micro-frontends -- share dependencies at runtime, load remote modules dynamically, and compose independent apps into one.
- JavaScript The Observable Pattern in JavaScript
Implement the Observable pattern in JavaScript to build reactive event streams, compose data pipelines, and manage async flows without third-party libraries.
- JavaScript Arrow Functions and `this` in JavaScript
A practical guide to arrow functions — concise syntax, implicit returns, and the lexical this that makes them perfect for callbacks but wrong for methods and constructors.