DECORATOR / QUICK REFERENCE
Same interface.
Added responsibility.
Wrap an object to add behavior around its existing operation.
- What changes?
- Optional behavior around an existing object.
- What stays the same?
- The interface the client calls: send(message).
- How do extras combine?
- Each decorator wraps another Sender and delegates to it.
The roles
- Component · Sender
- The common send(message) interface used by clients, core objects and wrappers.
- Concrete component · EmailSender
- The core job: accept a local message and return a receipt.
- Decorators · LoggingSender / TrackingSender
- Implement Sender, hold another Sender, and add one policy around a delegated call.
- Client · notify
- Uses the interface without inspecting the wrapper chain. Setup code selects and composes the objects.
No abstract decorator base class is required here. Both wrappers implement the interface directly and use composition.
The shape in TypeScript
1. The shared interface · TypeScript
The core object and every wrapper can be passed to the same client.
export type Receipt = Readonly<{ id: string; status: "accepted" }>;
export interface Sender {
send(message: string): Receipt;
}2. The unchanged client · TypeScript
The client knows send(), not the configured extras.
export function notify(sender: Sender, message: string): Receipt {
return sender.send(message);
}3. The concrete component · TypeScript
The local stand-in creates an acceptance receipt. It does not send a real email.
// Offline stand-in: acceptance is not a delivery or read confirmation.
export class EmailSender implements Sender {
private record: RecordEvent;
private fail: boolean;
private sequence = 0;
constructor(record: RecordEvent, fail = false) {
this.record = record;
this.fail = fail;
}
send(message: string): Receipt {
this.record({
actor: "email",
phase: "call",
text: "Email sender receives the message.",
});
if (!message.trim() || message.length > 160)
throw new Error("Use a message of 1–160 characters.");
if (this.fail) {
this.record({
actor: "email",
phase: "error",
text: "Email sender fails. No receipt is created.",
});
throw new Error("Local email service unavailable.");
}
const receipt = Object.freeze({
id: `local-${++this.sequence}`,
status: "accepted" as const,
});
this.record({
actor: "email",
phase: "return",
text: `Email sender accepts the message: ${receipt.id}.`,
});
return receipt;
}
}4. A wrapper around another Sender · TypeScript
Delegate once, preserve the receipt and rethrow the same error.
export class LoggingSender implements Sender {
private inner: Sender;
private record: RecordEvent;
constructor(inner: Sender, record: RecordEvent) {
this.inner = inner;
this.record = record;
}
send(message: string): Receipt {
this.record({
actor: "logging",
phase: "call",
text: "Logging records the attempt, then calls the wrapped sender.",
});
try {
const receipt = this.inner.send(message);
this.record({
actor: "logging",
phase: "return",
text: "Logging records success, then returns the same receipt.",
});
return receipt;
} catch (error) {
this.record({
actor: "logging",
phase: "error",
text: "Logging records failure, then rethrows the same error.",
});
throw error;
}
}
}5. Another compatible wrapper · TypeScript
Record acceptance only after the inner call succeeds.
export class TrackingSender implements Sender {
private inner: Sender;
private record: RecordEvent;
constructor(inner: Sender, record: RecordEvent) {
this.inner = inner;
this.record = record;
}
send(message: string): Receipt {
this.record({
actor: "tracking",
phase: "call",
text: "Tracking asks the wrapped sender for a receipt.",
});
try {
const receipt = this.inner.send(message);
this.record({
actor: "tracking",
phase: "return",
text: `Tracking records acceptance ${receipt.id}, then returns that receipt.`,
});
return receipt;
} catch (error) {
this.record({
actor: "tracking",
phase: "error",
text: "Tracking has no receipt to record. The error continues outward.",
});
throw error;
}
}
}6. Compose from the inside out · TypeScript
The final wrapper receives the call first. Flags choose the chain here; the existing wrappers do not branch on them.
export function decorate(
email: Sender,
options: Options,
record: RecordEvent,
): Sender {
let sender = email;
// Build from the inside out; the final wrapper receives the call first.
if (options.loggingOutside) {
if (options.tracking) sender = new TrackingSender(sender, record);
if (options.logging) sender = new LoggingSender(sender, record);
} else {
if (options.logging) sender = new LoggingSender(sender, record);
if (options.tracking) sender = new TrackingSender(sender, record);
}
return sender;
}Excerpts come from the TypeScript model used by the live demo.
Calls enter. Results unwind.
- Order: Logging(Tracking(Email)) starts logging before tracking. Tracking records acceptance before logging records success. Reversing the wrappers reverses those side-effect positions.
- One send: each wrapper delegates once with the same message. Both return the exact receipt from the wrapped sender. They do not modify it or invent another send.
- Failures: the same error propagates outward. Tracking records no acceptance; logging records a failed attempt. No retry or fallback is implied.
- Acceptance only: “accepted” is a local simulator result, not proof of delivery or reading. Real delivery tracking needs later provider events and an explicit contract.
- Side-effect policy: the lesson’s in-memory recorder does not throw. Real logging or tracking can fail; decide explicitly whether those failures may prevent a send or mask its result.
- Lifecycle: each demo run creates a fresh sender/chain and bounded event trace. Messages contain 1–160 characters. Receipt IDs increment per EmailSender instance. Reset/unmount cancel visual reveals; reduced motion skips delays.
- Scope: synchronous local objects only. Real network sending requires asynchronous results, privacy, timeouts and idempotency decisions. Decorator itself supplies none of these guarantees.
Use it when extras need to combine.
A good fit
Optional, independently owned responsibilities around an existing interface, especially when subclass combinations or feature branches are growing.
Keep simpler alternatives in view
A function wrapper or a few stable options may be enough. Every added layer has a navigation and debugging cost; composition order still needs tests.
Common mistakes
- Calling the inner sender twice and accidentally duplicating the core operation.
- Swallowing errors or recording success before the inner call returns.
- Assuming wrappers commute or eliminate combination testing.
- Confusing this object pattern with TypeScript’s @decorator syntax.
- Confusing Adapter (a different interface made compatible) with Decorator (extra behavior through the same interface), or Strategy (a choice among alternative behaviors).