Wrappers: adapter, decorator, proxy, facade
Ask what a wrapper does to the interface: changes it, keeps it and adds, keeps it and guards, or simplifies; @ changes the class.
Predict first
Before you read: predict
A payments ledger's post carries a @stamped decorator, and the audit team wraps the US ledger in Audited:
What does main.ts print?
// stamp.ts (a standard decorator: TypeScript 6.0.3 with experimentalDecorators off)import type { Ledger } from './ledger';export const stamps: string[] = [];export function stamped(post: (this: Ledger, cents: number) => number, _ctx: ClassMethodDecoratorContext<Ledger>) { return function (this: Ledger, cents: number) { stamps.push(this.id); return post.call(this, cents); };} // ledger.tsimport { stamped } from './stamp';export interface Poster { post(cents: number): number }export class Ledger implements Poster { constructor(public id: string) {} @stamped post(cents: number) { return cents; }} // audited.tsimport type { Poster } from './ledger';export class Audited implements Poster { calls = 0; constructor(private inner: Poster) {} post(cents: number) { this.calls++; return this.inner.post(cents); }} // main.tsimport { Ledger } from './ledger';import { Audited } from './audited';import { stamps } from './stamp';const eu = new Ledger('eu');const us = new Ledger('us');const audited = new Audited(us);eu.post(100);audited.post(250);us.post(75);console.log(`stamps: ${stamps.join(', ')}`);The 30-second version
Four wrappers, told apart by what they do to the interface. An adapter changes it into the one its client expects; a decorator keeps it and adds behaviour; a proxy keeps it and controls access; a facade offers a new, simpler one. TypeScript's @ decorator runs when the class is defined, and replaces the method for every instance.
Ask what a wrapper does to the interface: changes it, keeps it and adds, keeps it and guards, or simplifies; @ changes the class.
How it works
Step 1 of 14
Analogy: One smartphone and its accessories (consumer electronics)
A phone owner fits a few accessories to her phone; each one sits between her and something else.
| With the phone | In code |
|---|---|
| a USB-C to 3.5 mm dongle lets her old wired headphones work with a phone that has no headphone jack; the headphones never change | an adapter: the client keeps its interface (step 2) |
| a case keeps every button and port where it was and adds grip; a wallet case over it adds card slots | decorators, stacked in the order she fits them (steps 4–5) |
| her case leaves her partner's identical phone bare | a decorator object wraps one instance (step 6) |
| the lock screen looks like the phone and lets her through only after the PIN | a proxy controls access (step 7) |
| the car dashboard's phone mode shows four big buttons; to change a setting she picks up the phone itself | a facade offers less and seals nothing (steps 9–10) |
| the maker's engraving on the back of every phone of that model, done at the factory before any is sold | an @ decorator: applied once, to the class (steps 11–12) |
In real terms: a wrapper is an object the caller holds in place of another; whose interface it implements, and whether every call passes through, tells you which one it is.
Breaks down when: a case makes the phone too thick for the old car cradle. A decorator in code keeps the interface exactly; any caller of the original takes it.
Contrasting case: a phone used bare, because nothing needs converting, adding or guarding: when an object already has the interface you need, wrap nothing.
Examples, run for real
4.1 ExamHall EXH-2640: the proctoring vendor's SDK behind VideoSession
ExamHall's ProctorRoom is written against its own VideoSession. The proctoring vendor's SDK speaks another interface:
// video-session.ts (ExamHall's own interface; ProctorRoom calls only this)
export interface VideoSession { start(seat: string, minutes: number): void; isLive(seat: string): boolean }
// proctorcam.ts (the vendor's SDK, as its declarations show it)
export declare class ProctorCam {
openFeed(opts: { userRef: string; maxSeconds: number }): void;
feedState(userRef: string): 'LIVE' | 'ENDED';
}
// video-vendor-adapter.ts
import type { VideoSession } from './video-session';
import type { ProctorCam } from './proctorcam';
export class VideoVendorAdapter implements VideoSession {
constructor(private cam: ProctorCam) {}
start(seat: string, minutes: number) { this.cam.openFeed({ userRef: seat, maxSeconds: minutes * 60 }); }
isLive(seat: string) { return this.cam.feedState(seat) === 'LIVE'; }
}
4.1 EXH-2640 new ProctorRoom(cam) with the vendor's ProctorCam as it is: tsc TS2345 main.ts:4
4.1 new ProctorRoom(new VideoVendorAdapter(cam)): tsc no diagnostics · a 90-minute seat opens a feed of 5400 s · isLive true · room.ts unchanged
The SDK object does not fit VideoSession, so tsc refuses it. The adapter implements VideoSession, turns minutes into seconds and the feed state into a yes or no, and ProctorRoom stays as it was (step 2).
4.2 ExamHall EXH-2641 and MR !2940: a caching proxy for the question bank
Every candidate's screen read each question through RemoteBank, one fetch per question:
// question-bank.ts
export interface QuestionBank { question(id: string): string }
export class RemoteBank implements QuestionBank {
static fetches = 0;
question(id: string) { RemoteBank.fetches++; return `text of ${id}`; } // one HTTP fetch each
}
// cached-bank.ts (MR !2940)
import { RemoteBank, type QuestionBank } from './question-bank';
export class CachedBank implements QuestionBank {
private remote = new RemoteBank();
private texts = new Map<string, string>();
question(id: string) {
if (!this.texts.has(id)) this.texts.set(id, this.remote.question(id));
return this.texts.get(id)!;
}
}
4.2 EXH-2641 120 candidates open the same 3-question paper through RemoteBank: fetches 360
4.2 MR !2940 the same through one CachedBank, passed as a QuestionBank: tsc no diagnostics · fetches 3 · each candidate's texts the same
MR !2940 passes one CachedBank wherever a QuestionBank was expected. The screens did not change and read the same questions, but only the first request for each question reaches the remote bank (steps 7–8). How long a cached text may live is cache policy, owned by the cache-* packs.
4.3 When TypeScript runs a decorator
ExamHall's grader tags grade with two decorator factories. The run records each step:
// tags.ts (standard decorators: TypeScript 6.0.3 with experimentalDecorators off)
import type { Grader } from './grader';
export const order: string[] = [];
export function tag(name: string) {
order.push(`factory ${name}`);
return (method: (this: Grader) => void, _ctx: ClassMethodDecoratorContext<Grader>) => { order.push(`apply ${name}`); return method; };
}
// grader.ts
import { tag, order } from './tags';
export class Grader {
@tag('outer') @tag('inner') grade(): void {}
}
order.push('class defined');
// main.ts
import { Grader } from './grader';
import { order } from './tags';
order.push('main starts');
new Grader();
new Grader();
order.push('two graders made');
console.log(order.join(' · '));
4.3 standard decorators: factory outer · factory inner · apply inner · apply outer · class defined · main starts · two graders made
4.3 legacy decorators (experimentalDecorators, (target, key, descriptor) signatures): the same order
Both factories, then both decorators, ran while grader.ts was being loaded, before main.ts made a grader, and making two graders ran none of them again (step 11). The legacy kind, compiled with experimentalDecorators, gives the same order (step 14).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 5
A box office looks up each order's contact, for the support desk and for the nightly audit:
After both lookups, which log holds a buyer's full phone number?
// contact.tsexport interface Contact { forOrder(orderId: string): string }export class Orders implements Contact { forOrder(orderId: string): string { return orderId === 'o-1' ? 'Ana Silva 07700900123' : 'Ben Okafor 07700900456'; }}export class Masked implements Contact { constructor(private inner: Contact) {} forOrder(orderId: string): string { return this.inner.forOrder(orderId).replace(/\d{7}(\d{4})/, '*******$1'); }}export class Logged implements Contact { constructor(private inner: Contact, private log: string[]) {} forOrder(orderId: string): string { const c = this.inner.forOrder(orderId); this.log.push(c); return c; }} // main.tsimport { Orders, Masked, Logged } from './contact';export const support: string[] = [];export const audit: string[] = [];const desk = new Logged(new Masked(new Orders()), support);const nightly = new Masked(new Logged(new Orders(), audit));desk.forOrder('o-1');nightly.forOrder('o-2');Question 2 of 5
A shop's product pages each take an ImageStore. Hi-res images come from HiResImages, whose constructor opens a session with the image CDN:
The team will add a LazyImages class to images.ts and pass a factory for it as store.
Which version opens a CDN session only for a page with a zoomed product, never two for one page, and returns the images HiResImages returns?
// images.tsexport interface ImageStore { load(sku: string): string }export class HiResImages implements ImageStore { static sessions = 0; constructor() { HiResImages.sessions++; } // opens a CDN session load(sku: string): string { return `cdn/${sku}@2x.jpg`; }} // listing.tsimport type { ImageStore } from './images';export function renderListing(skus: string[], zoomed: string[], store: () => ImageStore): string[] { const pages = skus.map((sku) => ({ sku, images: store() })); return pages.filter((p) => zoomed.includes(p.sku)).flatMap((p) => [p.images.load(p.sku), p.images.load(`${p.sku}-back`)]);}Question 3 of 5
A chat app's panel talks to its own Room interface; HubRoom connects it to the vendor's SDK:
What happens when main.ts runs room.edit(first, 'hello')?
// room.ts (the chat app's own interface; ChatPanel calls only this)export interface Room { send(text: string): string; // returns the message's id edit(messageId: string, text: string): void;} // hub.ts (the vendor's SDK)export class HubClient { private bodies = new Map<string, string>(); private n = 0; post(req: { channel: string; body: string }): { id: string } { const id = String(this.n++); this.bodies.set(id, req.body); return { id }; } patch(id: string, body: string): void { if (!this.bodies.has(id)) throw new RangeError(`unknown message ${id}`); this.bodies.set(id, body); } list(): string[] { return [...this.bodies.values()]; }} // hub-room.tsimport type { Room } from './room';import type { HubClient } from './hub';export class HubRoom implements Room { private sent = 0; constructor(private hub: HubClient, private channel: string) {} send(text: string): string { this.hub.post({ channel: this.channel, body: text }); return String(++this.sent); } edit(messageId: string, text: string): void { this.hub.patch(messageId, text); }} // main.tsimport { HubClient } from './hub';import { HubRoom } from './hub-room';const hub = new HubClient();const room = new HubRoom(hub, 'support');const first = room.send('helo');room.send('ok');room.edit(first, 'hello');console.log(hub.list().join(' / '));Question 4 of 5
A streaming home page caches each channel's thumbnails with a decorator:
The page makes a kids and a news channel, then asks each for video v1's thumbnail, and the news row shows kids/v1.jpg.
Which change to memo.ts gives each channel its own thumbnail, renders a channel's v1 once however often it is asked for, and keeps @memo on thumbnail?
// memo.ts (a standard decorator: TypeScript 6.0.3 with experimentalDecorators off)import type { Channel } from './channel';export function memo(render: (this: Channel, videoId: string) => string, _ctx: ClassMethodDecoratorContext<Channel>) { const seen = new Map<string, string>(); return function (this: Channel, videoId: string): string { if (!seen.has(videoId)) seen.set(videoId, render.call(this, videoId)); return seen.get(videoId)!; };} // channel.tsimport { memo } from './memo';export class Channel { renders = 0; constructor(public name: string) {} @memo thumbnail(videoId: string): string { this.renders++; return `${this.name}/${videoId}.jpg`; }}Question 5 of 5
ExamHall's media team owns the facade that six teams call to publish exam recordings:
The appeals board needs exam EX-77's recording, ex77.webm, republished at 1080 lines, stored and listed in the catalog like any other. The appeals script runs inside the media service, while the other teams keep publishing through it. The other teams' recordings stay at 480, and publish.ts is not edited.
What should the appeals script use?
// transcoder.tsexport class Transcoder { encode(file: string, opts: { lines: 480 | 1080 }): string { return file.replace(/\.webm$/, `-${opts.lines}.mp4`); }} // storage.tsexport class Storage { put(blob: string): string { return `store://exam-media/${blob}`; } }export const storage = new Storage(); // catalog.tsexport class Catalog { entries: string[] = []; add(examId: string, url: string): void { this.entries.push(`${examId} ${url}`); }}export const catalog = new Catalog(); // publish.tsimport { Transcoder } from './transcoder';import { storage } from './storage';import { catalog } from './catalog';export class Publish { private t = new Transcoder(); recording(examId: string, file: string): string { const url = storage.put(this.t.encode(file, { lines: 480 })); catalog.add(examId, url); return url; }}export const publish = new Publish();Go deeper
Pro tips
- Tell the wrappers apart with one question: does it change the interface (adapter), keep it and add behaviour (decorator), keep it and control access (proxy), or offer a new, simpler one (facade)? When: the curriculum row's typical question, "What is the difference between an adapter and a facade?" Verify: nothing to run; this is interview advice, and it keys nothing. Status: ADVICE.
- Expect no new members from a class decorator: a method it adds at run time is invisible to
tsc, so call it through a typed interface, or not at all. When: a class decorator returns a subclass with an extra method. Verify (RAN,tips.mjs):tip 2 @withReceipt adds receipt() at run time; p.receipt() on the decorated class: tsc TS2339 tip.ts:7 tip 2 the same call through a cast: tsc no diagnostics · run prints r-1
Source
A web page can change, so every claim from these pages is labelled as of 2026-10-02, the date read; every TypeScript behaviour this lesson grades is also executed on typescript 6.0.3. No source keys an item: every key comes from a run.
- TypeScript Handbook, Decorators (as of 2026-10-02), the curriculum row's second source: https://www.typescriptlang.org/docs/handbook/decorators.html (
ts-handbook-decorators.txt). It documents the legacy kind: "This document refers to an experimental stage 2 decorators implementation." Steps 11 and 14 rest on it: "The expressions for each decorator are evaluated top-to-bottom." and "The results are then called as functions from bottom-to-top." Tip 2 rests on its note that a class decorator "does not change the TypeScript type", whichtips.mjsexecutes. - TypeScript 5.0 release notes, Decorators (as of 2026-10-02), the standard kind: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-0.html (
ts-5-0-decorators.txt). - refactoring.guru, Adapter, Decorator, Proxy and Facade (as of 2026-10-02), teaching only, since the curriculum row does not name them (steps 1–10): https://refactoring.guru/design-patterns/adapter, https://refactoring.guru/design-patterns/decorator, https://refactoring.guru/design-patterns/proxy and https://refactoring.guru/design-patterns/facade (
refactoring-guru-adapter.txt,refactoring-guru-decorator.txt,refactoring-guru-proxy.txt,refactoring-guru-facade.txt). For example: "This is a special object that converts the interface of one object so that another object can understand it."; "The difference is that a Proxy usually manages the life cycle of its service object on its own, whereas the composition of Decorators is always controlled by the client."; "Facade defines a new interface for existing objects, whereas Adapter tries to make the existing interface usable."; "A facade might provide limited functionality in comparison to working with the subsystem directly." - The row's first source, Gamma, Helm, Johnson and Vlissides, Design Patterns (1994), is a book that cannot be fetched as text, and keys nothing.
Related lessons
- Read first: SOLID at a glance, with LSP and ISP in TypeScript
- Related: Composition over inheritance, and the fragile base class
- Related: Layers, ports and adapters
- Related: Dependency inversion and NestJS dependency injection
- Related: Structural typing, interfaces and declaration merging
- Related: Cache-aside and the read and write paths
- Related: When a pattern is over-engineering
Checked against the tools and docs as of 2026-10-02.