Skip to article
ALGORITHMICSPatterns
Patterns6 min read

Decorator

Adding behaviour by wrapping rather than subclassing — and why the order of the wrappers is a real decision.


You have a FileStream that writes bytes. You would also like the option of compressing them, encrypting them, and batching the writes.

What people write first

A subclass per combination.

class FileStream { write(data: string) {} }
class GzipFileStream extends FileStream {}
class EncryptedFileStream extends FileStream {}
class GzipEncryptedFileStream extends FileStream {}
class BufferedGzipEncryptedFileStream extends FileStream {}
// …

Three optional behaviours is eight classes. Four is sixteen. And the ordering matters — compressing then encrypting is not the same as encrypting then compressing — so it is really permutations, not combinations.

This is the class explosion, and it is the specific problem Decorator exists to solve.

The pattern

A decorator implements the same interface as the thing it wraps, and holds one of them. Those two properties together are what make it composable.

interface Stream {
write(data: string): void;
}
class FileStream implements Stream {
write(data: string) { /* … */ }
}
class GzipStream implements Stream {
constructor(private readonly inner: Stream) {} // ← holds a Stream
write(data: string) {
this.inner.write(gzip(data)); // ← is a Stream
}
}
class EncryptedStream implements Stream {
constructor(private readonly inner: Stream) {}
write(data: string) { this.inner.write(encrypt(data)); }
}

Now the combinations are built at runtime, and there are no new classes:

const plain = new FileStream();
const secure = new EncryptedStream(new FileStream());
const both = new EncryptedStream(new GzipStream(new FileStream()));

Toggle the layers and watch the composition change:

encrypted
gzipped
FileStream

enc(gz(payload))

2 wrappers, each holding the one beneath it.

Order is a decision, not a detail

new EncryptedStream(new GzipStream(file)) compresses first, then encrypts. Reverse them and you compress ciphertext.

That is not a stylistic preference — encrypted data is statistically random, and random data does not compress. Get the order backwards and your archive is the same size as the original, with no error to tell you.

In this language, it is often a function

Same observation as Strategy: the class is scaffolding around an interface with one method.

type Write = (data: string) => void;
const withGzip = (inner: Write): Write => (data) => inner(gzip(data));
const withEncryption = (inner: Write): Write => (data) => inner(encrypt(data));
const write = withEncryption(withGzip(writeToFile));

This is the same idea as an HTTP middleware chain, a higher-order React component, or a Python @decorator — all of which are Decorator with the ceremony removed.

What it costs

Debugging depth. A stack trace through five decorators is five nearly identical frames, and finding which layer misbehaved means reading all of them.

Identity is lost. wrapped instanceof FileStream is false. Anything doing type checks, reference equality, or reflection on the concrete class will not see through the wrapper.

No random access to the middle. You cannot ask a stack “is encryption enabled?” without walking it or tracking that separately.

Decorator, Proxy, Adapter

All three wrap an object and forward calls. The difference is intent, and it is worth being able to say which one you are writing:

InterfacePurpose
Decoratorsame as the wrapped objectadd behaviour, stackably
Proxysame as the wrapped objectcontrol access — lazy loading, caching, permissions
Adapterdifferent from the wrapped objectmake an incompatible thing fit

Decorator and Proxy are structurally identical; only the reason differs. Adapter is distinguishable from the outside, because the interface changes.