Structural Design Patterns in React and TypeScript
How Decorator, Adapter, Facade, Composite, and Proxy patterns evolved in React and TypeScript: when HOCs give way to hooks and how adapters isolate third-party APIs.
Structural patterns organize relationships between objects and classes. The Gang of Four documented Decorator, Adapter, Facade, Composite, and Proxy in 1994 for C++ and Smalltalk. Modern TypeScript and React absorbed all five into framework conventions, hooks, and type-safe wrappers.
The practical question in a React codebase is where a wrapper belongs. Composition stays the default inside code you own: hooks for cross-cutting behavior, plain components for hierarchy. Wrapping earns its place at the edges you do not control, such as third-party APIs, SDK setup, and access rules.
Three Meanings of Decorator in TypeScript#
The term “decorator” means three different things in TypeScript ecosystems:
- Gang of Four Decorator Pattern: Adding behavior to objects dynamically
- React Higher-Order Components (HOCs): Enhancing components with additional functionality
- TypeScript Decorator Syntax: Stage 3 proposal for class/method decorators
Each solves a different problem, and they are not interchangeable.
Decorator via React HOCs#
Higher-order components enhance components by wrapping them with additional functionality:
// Authentication HOC
function withAuth<P extends object>(
Component: React.ComponentType<P>
): React.FC<P> {
return (props: P) => {
const { user, loading } = useAuth();
if (loading) {
return <div className="spinner">Loading...</div>;
}
if (!user) {
return <Navigate to="/login" replace />;
}
return <Component {...props} />;
};
}
// Usage
const Dashboard = ({ data }: DashboardProps) => {
return <div>Welcome to dashboard</div>;
};
export default withAuth(Dashboard);
The wrapper brings its own costs:
Wrapper Hell: Stacking multiple HOCs creates deeply nested component trees, which makes debugging harder and complicates performance monitoring:
export default withAuth(
withTracking(
withErrorBoundary(
withLoading(
Dashboard
)
)
)
);
Props Collision: Multiple HOCs might inject props with the same names, causing conflicts.
Ref Forwarding Complexity: Passing refs through HOC layers requires explicit forwarding. React 19 lets function components take ref as a regular prop, which simplifies this. forwardRef still works, but it is on the deprecation path.
Unclear Data Flow: Props injected by HOCs aren’t visible in component signature.
Custom Hooks Replace Most HOC Use Cases#
Hooks provide the same functionality with cleaner composition: wrapper hell disappears, data flow stays explicit, and each hook’s return values show what it does.
function Dashboard({ data }: DashboardProps) {
// Each hook adds specific functionality
const { user, loading } = useAuth();
const tracking = usePageTracking('dashboard_view');
const errorBoundary = useErrorBoundary();
if (loading) {
return <div className="spinner">Loading...</div>;
}
if (!user) {
return <Navigate to="/login" replace />;
}
return <div>Welcome to dashboard</div>;
}
The same trade-off shows up when adding analytics to several components. The HOC approach gets verbose fast:
const DashboardWithTracking = withTracking(Dashboard, 'dashboard_view');
const ProfileWithTracking = withTracking(Profile, 'profile_view');
const SettingsWithTracking = withTracking(Settings, 'settings_view');
A hook stays close to the component it touches:
function Dashboard() {
usePageTracking('dashboard_view');
// component logic
}
function usePageTracking(pageName: string) {
useEffect(() => {
analytics.track('page_view', { page: pageName });
return () => {
// Cleanup if needed
};
}, [pageName]);
}
Where HOCs Still Earn Their Keep#
Hooks do not cover everything. Library code sometimes has to wrap a component without touching its internals, and a migration away from class components leans on HOCs as a bridge to hooks. The clearest surviving case is a wrapper that only adds markup:
function withCard<P extends object>(
Component: React.ComponentType<P>
): React.FC<P> {
return (props: P) => (
<div className="card">
<div className="card-body">
<Component {...props} />
</div>
</div>
);
}
TypeScript Decorator Syntax#
TypeScript decorators (Stage 3 proposal, supported in TypeScript 5.0+) enable declarative metadata and behavior modification:
// Method decorator for logging
function log(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = async function(...args: any[]) {
console.log(`[${propertyKey}] Called with:`, args);
const start = Date.now();
try {
const result = await originalMethod.apply(this, args);
const duration = Date.now() - start;
console.log(`[${propertyKey}] Completed in ${duration}ms`);
return result;
} catch (error) {
console.error(`[${propertyKey}] Failed:`, error);
throw error;
}
};
return descriptor;
}
class ApiClient {
@log
async fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
@log
async updateUser(id: string, data: Partial<User>): Promise<User> {
const response = await fetch(`/api/users/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
return response.json();
}
}
The fit is cross-cutting work that repeats across methods: logging and monitoring, validation and authorization, caching and memoization, performance tracking.
Adapters at the Vendor Boundary#
Adapters translate one interface into another. In TypeScript, that isolation makes testing simpler and future migrations easier.
From StripeCustomer to Customer#
External libraries often have interfaces that don’t match your domain model:
// Stripe's API shape (can't control)
interface StripeCustomer {
id: string;
email: string;
metadata: Record<string, string>;
created: number; // Unix timestamp
description: string | null;
}
// Your domain model
interface Customer {
customerId: string;
email: string;
organizationId: string;
createdAt: Date;
notes?: string;
}
Stripe’s API is optimized for their backend, your domain model for your business logic. An adapter translates between the two:
class StripeCustomerAdapter {
static toDomain(stripeCustomer: StripeCustomer): Customer {
return {
customerId: stripeCustomer.id,
email: stripeCustomer.email,
organizationId: stripeCustomer.metadata.organizationId,
createdAt: new Date(stripeCustomer.created * 1000),
notes: stripeCustomer.description || undefined,
};
}
static toStripe(customer: Customer): Partial<StripeCustomer> {
return {
email: customer.email,
metadata: {
organizationId: customer.organizationId,
},
description: customer.notes || null,
};
}
}
// Usage in service layer
class CustomerService {
constructor(private stripe: Stripe) {}
async getCustomer(id: string): Promise<Customer> {
const stripeCustomer = await this.stripe.customers.retrieve(id);
return StripeCustomerAdapter.toDomain(stripeCustomer);
}
async createCustomer(customer: Customer): Promise<Customer> {
const stripeData = StripeCustomerAdapter.toStripe(customer);
const created = await this.stripe.customers.create(stripeData);
return StripeCustomerAdapter.toDomain(created);
}
}
Compile-Time Adapters With Mapped Types#
TypeScript’s type system enables compile-time adapters using mapped types:
// Generic adapter for API response wrappers
type ApiResponse<T> = {
data: T;
status: number;
message: string;
metadata: {
timestamp: number;
requestId: string;
};
};
// Unwrap API response type
type UnwrapApiResponse<T> = T extends ApiResponse<infer U> ? U : T;
// Automatically extract data type
type UserData = UnwrapApiResponse<
ApiResponse<{ id: string; name: string }>
>;
// Result: { id: string; name: string }
// Runtime adapter function
function unwrapApiResponse<T>(response: ApiResponse<T>): T {
if (response.status >= 400) {
throw new Error(`API Error: ${response.message}`);
}
return response.data;
}
Swapping the Button Library#
Adapters help integrate third-party UI libraries into your design system, so swapping one library for another only touches the adapter component:
// Internal design system button interface
interface InternalButtonProps {
label: string;
variant: 'primary' | 'secondary' | 'danger';
onClick: () => void;
disabled?: boolean;
}
// Adapter component for Material-UI
function InternalButton({
label,
variant,
onClick,
disabled,
}: InternalButtonProps) {
// Adapt internal variant to Material-UI color
const muiColor = {
primary: 'primary',
secondary: 'secondary',
danger: 'error',
}[variant] as 'primary' | 'secondary' | 'error';
return (
<MuiButton
variant="contained"
color={muiColor}
onClick={onClick}
disabled={disabled}
>
{label}
</MuiButton>
);
}
// Usage with internal API
<InternalButton
label="Delete"
variant="danger"
onClick={handleDelete}
/>
An adapter earns its place around a third-party service you might replace, such as a payment processor or a cloud provider. It also pays off against an API with poor TypeScript support, against a service that needs a complex domain transformation, and against a library that ships frequent breaking changes. Skip it for stable, well-typed libraries like lodash and date-fns, for internal utilities under your control, and for one-to-one mappings that a plain function handles.
What a Facade Hides#
Facades provide a simplified interface to a complex subsystem, hiding initialization complexity, coordinating multiple services, and cutting coupling to implementation details.
AWS SDK Calls Behind One Class#
The AWS SDK v3 requires specific configuration for each service, command objects, and careful error handling:
// Without facade - scattered complexity
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb';
import { marshall } from '@aws-sdk/util-dynamodb';
import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs';
// Setup scattered across codebase
const s3 = new S3Client({ region: 'us-east-1' });
const dynamodb = new DynamoDBClient({ region: 'us-east-1' });
const sqs = new SQSClient({ region: 'us-east-1' });
// Usage requires understanding AWS SDK specifics
await s3.send(new PutObjectCommand({
Bucket: 'my-bucket',
Key: 'file.txt',
Body: buffer,
}));
await dynamodb.send(new PutItemCommand({
TableName: 'my-table',
Item: marshall({ id: '123', data: 'value' }),
}));
await sqs.send(new SendMessageCommand({
QueueUrl: process.env.QUEUE_URL,
MessageBody: JSON.stringify({ task: 'process' }),
}));
A facade collects that setup and exposes domain operations instead:
class CloudStorage {
private s3: S3Client;
private dynamodb: DynamoDBClient;
private sqs: SQSClient;
constructor(private region: string) {
this.s3 = new S3Client({ region });
this.dynamodb = new DynamoDBClient({ region });
this.sqs = new SQSClient({ region });
}
async uploadFile(
bucket: string,
key: string,
data: Buffer
): Promise<void> {
await this.s3.send(new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: data,
}));
}
async saveMetadata(
table: string,
item: Record<string, any>
): Promise<void> {
await this.dynamodb.send(new PutItemCommand({
TableName: table,
Item: marshall(item),
}));
}
async enqueueTask(
queueUrl: string,
task: Record<string, any>
): Promise<void> {
await this.sqs.send(new SendMessageCommand({
QueueUrl: queueUrl,
MessageBody: JSON.stringify(task),
}));
}
}
// Simple usage
const storage = new CloudStorage('us-east-1');
await storage.uploadFile('my-bucket', 'file.txt', buffer);
await storage.saveMetadata('my-table', { id: '123', data: 'value' });
await storage.enqueueTask(queueUrl, { task: 'process' });
The Registration Form Facade#
Forms often involve validation, submission, error handling, and analytics, and a facade can coordinate all of it behind one method call:
interface FormFacadeOptions {
formik: FormikHelpers<any>;
analytics: AnalyticsService;
api: ApiClient;
}
class RegistrationFormFacade {
constructor(private options: FormFacadeOptions) {}
async submitRegistration(values: RegistrationFormValues): Promise<User> {
const { formik, analytics, api } = this.options;
// Track attempt
analytics.track('registration_attempt', {
referrer: values.referrer,
});
try {
// Validate
await this.validateEmail(values.email);
// Create user
const user = await api.createUser({
email: values.email,
name: values.name,
password: values.password,
});
// Send verification email
await api.sendVerificationEmail(user.email);
// Track success
analytics.track('registration_success', {
userId: user.id,
});
return user;
} catch (error) {
// Track error
analytics.track('registration_error', {
error: error.message,
});
// Map API errors to form errors
const formErrors = this.mapApiErrorsToFormErrors(error);
formik.setErrors(formErrors);
throw error;
}
}
private async validateEmail(email: string): Promise<void> {
const available = await this.options.api.checkEmailAvailability(email);
if (!available) {
throw new Error('Email already registered');
}
}
private mapApiErrorsToFormErrors(
error: ApiError
): FormikErrors<RegistrationFormValues> {
// Complex error mapping logic
if (error.code === 'EMAIL_TAKEN') {
return { email: 'This email is already registered' };
}
if (error.code === 'WEAK_PASSWORD') {
return { password: 'Password must be stronger' };
}
return { _form: 'Registration failed. Please try again.' };
}
}
// Usage in component
function RegistrationForm() {
const formik = useFormik({ /* ... */ });
const analytics = useAnalytics();
const api = useApiClient();
const facade = useMemo(
() => new RegistrationFormFacade({ formik, analytics, api }),
[formik, analytics, api]
);
const handleSubmit = async (values: RegistrationFormValues) => {
try {
const user = await facade.submitRegistration(values);
navigate(`/welcome/${user.id}`);
} catch (error) {
// Error already handled by facade
}
};
return <form onSubmit={formik.handleSubmit(handleSubmit)}>
{/* Form fields */}
</form>;
}
Barrel Exports as Facades#
Barrel exports (index.ts files) create public facades for modules:
// lib/index.ts - public API facade
export { User, type UserRole } from './models/user';
export { createClient } from './client';
export { authenticate, type AuthOptions } from './auth';
export { ApiError } from './errors';
// Consumers see clean interface
import { User, createClient, authenticate } from 'my-lib';
This hides internal structure, letting you refactor internals without breaking consumers.
However: barrel exports cost build time as a codebase grows. Atlassian reported a 75% reduction in build minutes consumed for the Jira frontend after removing barrel files. On a smaller codebase, Dominik Dorfmeister measured a Next.js page loading over 11k modules through barrels and brought it down to about 3.5k (68%) by deleting internal barrels.
Composite Without the Class Hierarchy#
The Composite pattern treats individual objects and compositions uniformly. React’s component model is inherently composite: components can contain other components, and both are treated the same way.
The File-Tree Example#
The textbook example involves hierarchical structures like file systems:
// Component interface
interface FileSystemNode {
name: string;
size: number;
render(): JSX.Element;
}
// Leaf - File
class File implements FileSystemNode {
constructor(
public name: string,
public size: number,
public type: string
) {}
render() {
return (
<div className="file">
<FileIcon type={this.type} />
<span>{this.name}</span>
<span>{formatSize(this.size)}</span>
</div>
);
}
}
// Composite - Folder
class Folder implements FileSystemNode {
constructor(
public name: string,
private children: FileSystemNode[]
) {}
get size(): number {
return this.children.reduce((sum, child) => sum + child.size, 0);
}
render() {
return (
<div className="folder">
<FolderIcon />
<span>{this.name}</span>
<div className="children">
{this.children.map((child, i) => (
<div key={i}>{child.render()}</div>
))}
</div>
</div>
);
}
}
// Uniform interface for files and folders
const root = new Folder('root', [
new File('document.txt', 1024, 'text'),
new Folder('images', [
new File('photo1.jpg', 2048, 'image'),
new File('photo2.jpg', 3072, 'image'),
]),
new File('README.md', 512, 'markdown'),
]);
The pattern is sound (treating individual items and collections uniformly), yet the class ceremony ignores React’s natural composition. The same logic in idiomatic React:
interface FileSystemNodeData {
name: string;
type: 'file' | 'folder';
size?: number;
mimeType?: string;
children?: FileSystemNodeData[];
}
function FileSystemNode({ node }: { node: FileSystemNodeData }) {
if (node.type === 'file') {
return (
<div className="file">
<FileIcon type={node.mimeType!} />
<span>{node.name}</span>
<span>{formatSize(node.size!)}</span>
</div>
);
}
const totalSize = node.children?.reduce(
(sum, child) => sum + (child.size || 0),
0
) || 0;
return (
<div className="folder">
<FolderIcon />
<span>{node.name}</span>
<span>{formatSize(totalSize)}</span>
<div className="children">
{node.children?.map((child, i) => (
<FileSystemNode key={i} node={child} />
))}
</div>
</div>
);
}
// Usage with data structure
const fileSystem: FileSystemNodeData = {
name: 'root',
type: 'folder',
children: [
{ name: 'document.txt', type: 'file', size: 1024, mimeType: 'text' },
{
name: 'images',
type: 'folder',
children: [
{ name: 'photo1.jpg', type: 'file', size: 2048, mimeType: 'image' },
{ name: 'photo2.jpg', type: 'file', size: 3072, mimeType: 'image' },
],
},
{ name: 'README.md', type: 'file', size: 512, mimeType: 'markdown' },
],
};
<FileSystemNode node={fileSystem} />
Compound Components Pattern#
The compound component pattern, used by libraries like Radix UI, Headless UI, and Reach UI, provides flexible APIs with implicit state sharing:
interface SelectContextValue {
value: string | null;
onChange: (value: string) => void;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
}
const SelectContext = createContext<SelectContextValue | null>(null);
function Select({ children, value, onChange }: SelectProps) {
const [isOpen, setIsOpen] = useState(false);
return (
<SelectContext.Provider value={{ value, onChange, isOpen, setIsOpen }}>
<div className="select">{children}</div>
</SelectContext.Provider>
);
}
function SelectTrigger({ children }: { children: ReactNode }) {
const context = useContext(SelectContext);
if (!context) throw new Error('SelectTrigger must be used within Select');
return (
<button
onClick={() => context.setIsOpen(!context.isOpen)}
className="select-trigger"
>
{context.value || children}
</button>
);
}
function SelectContent({ children }: { children: ReactNode }) {
const context = useContext(SelectContext);
if (!context) throw new Error('SelectContent must be used within Select');
if (!context.isOpen) return null;
return <div className="select-content">{children}</div>;
}
function SelectOption({ value, children }: OptionProps) {
const context = useContext(SelectContext);
if (!context) throw new Error('SelectOption must be used within Select');
return (
<div
className={context.value === value ? 'selected' : ''}
onClick={() => {
context.onChange(value);
context.setIsOpen(false);
}}
>
{children}
</div>
);
}
// Namespace for ergonomic usage
Select.Trigger = SelectTrigger;
Select.Content = SelectContent;
Select.Option = SelectOption;
// Flexible composition
<Select value={selected} onChange={setSelected}>
<Select.Trigger>Choose option</Select.Trigger>
<Select.Content>
<Select.Option value="1">Option 1</Select.Option>
<Select.Option value="2">Option 2</Select.Option>
<Select.Option value="3">Option 3</Select.Option>
</Select.Content>
</Select>
Proxies That Callers Never See#
Proxies control access to objects, adding behavior like lazy loading, caching, validation, or access control; TypeScript reaches this with class-based proxies or with JavaScript’s own Proxy API for runtime interception.
React.lazy and Suspense#
React’s built-in lazy loading is a proxy. It intercepts component rendering to load code on demand, and the Suspense boundary shows a loading state until the code arrives:
// Proxy for code-splitting
const Dashboard = React.lazy(() => import('./Dashboard'));
const Settings = React.lazy(() => import('./Settings'));
const Profile = React.lazy(() => import('./Profile'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/profile" element={<Profile />} />
</Routes>
</Suspense>
);
}
A Caching Client With the Same Interface#
Proxies add caching layers without changing calling code:
interface ApiClient {
fetchUser(id: string): Promise<User>;
fetchPosts(userId: string): Promise<Post[]>;
}
class CachedApiClient implements ApiClient {
private cache = new Map<string, {
data: any;
timestamp: number;
}>();
private ttl = 60000; // 1 minute
constructor(private realClient: ApiClient) {}
async fetchUser(id: string): Promise<User> {
const cacheKey = `user:${id}`;
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.ttl) {
console.log('[Cache] Hit:', cacheKey);
return cached.data;
}
console.log('[Cache] Miss:', cacheKey);
const data = await this.realClient.fetchUser(id);
this.cache.set(cacheKey, {
data,
timestamp: Date.now(),
});
return data;
}
async fetchPosts(userId: string): Promise<Post[]> {
const cacheKey = `posts:${userId}`;
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.ttl) {
console.log('[Cache] Hit:', cacheKey);
return cached.data;
}
console.log('[Cache] Miss:', cacheKey);
const data = await this.realClient.fetchPosts(userId);
this.cache.set(cacheKey, {
data,
timestamp: Date.now(),
});
return data;
}
}
// Transparent proxy - same interface as real client
const realClient = new RealApiClient();
const cachedClient = new CachedApiClient(realClient);
// Usage unchanged
const user = await cachedClient.fetchUser('123');
const posts = await cachedClient.fetchPosts('123');
Runtime Validation With Proxy Traps#
JavaScript’s Proxy API enables runtime interception, useful for configuration objects or domain entities that must enforce invariants on every change:
import { z } from 'zod';
const userSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
age: z.number().positive().int(),
});
function createValidatedProxy<T extends object>(
target: T,
schema: z.ZodSchema<T>
): T {
return new Proxy(target, {
set(obj, prop, value) {
// Validate entire object after change
const updated = { ...obj, [prop]: value };
const result = schema.safeParse(updated);
if (!result.success) {
throw new Error(
`Validation failed for ${String(prop)}: ${result.error.message}`
);
}
obj[prop as keyof T] = value;
return true;
},
get(obj, prop) {
const value = obj[prop as keyof T];
console.log(`[Access] ${String(prop)}:`, value);
return value;
},
});
}
// Usage
const user = createValidatedProxy(
{ name: '', email: '', age: 0 },
userSchema
);
user.name = 'John'; // OK
user.email = 'john@example.com'; // OK
user.age = 30; // OK
// user.age = -5; // Throws validation error
// user.email = 'invalid'; // Throws validation error
The Proxy Inside React Query#
React Query acts as a proxy for data fetching, managing caching, loading states, and refetching:
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
function UserProfile({ userId }: { userId: string }) {
// React Query proxies the fetch operation
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 60000, // Cache for 1 minute
gcTime: 300000, // Garbage collect after 5 minutes
});
const queryClient = useQueryClient();
const updateMutation = useMutation({
mutationFn: (updates: Partial<User>) => updateUser(userId, updates),
onSuccess: () => {
// Invalidate cache after successful mutation
queryClient.invalidateQueries({ queryKey: ['user', userId] });
},
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h2>{data.name}</h2>
<button onClick={() => updateMutation.mutate({ name: 'New Name' })}>
Update Name
</button>
</div>
);
}
React Query intercepts the fetch. On top of it come automatic caching with a configurable TTL, loading and error states, background refetching, cache invalidation, and request deduplication.
Authorization Around Admin Actions#
Proxies can enforce authorization and logging without touching the real implementation:
interface AdminActions {
deleteUser(id: string): Promise<void>;
modifyPermissions(userId: string, permissions: string[]): Promise<void>;
accessAuditLogs(): Promise<AuditLog[]>;
}
class AuthorizedAdminProxy implements AdminActions {
constructor(
private realAdmin: AdminActions,
private currentUser: User
) {}
private checkAuthorization(action: string): void {
if (!this.currentUser.roles.includes('admin')) {
throw new Error(`Unauthorized: ${action} requires admin role`);
}
}
async deleteUser(id: string): Promise<void> {
this.checkAuthorization('deleteUser');
console.log(`[Audit] User ${this.currentUser.id} deleted user ${id}`);
await this.realAdmin.deleteUser(id);
}
async modifyPermissions(
userId: string,
permissions: string[]
): Promise<void> {
this.checkAuthorization('modifyPermissions');
console.log(
`[Audit] User ${this.currentUser.id} modified permissions for ${userId}`
);
await this.realAdmin.modifyPermissions(userId, permissions);
}
async accessAuditLogs(): Promise<AuditLog[]> {
this.checkAuthorization('accessAuditLogs');
console.log(`[Audit] User ${this.currentUser.id} accessed audit logs`);
return await this.realAdmin.accessAuditLogs();
}
}
// Usage
const adminActions = new RealAdminActions();
const authorizedProxy = new AuthorizedAdminProxy(adminActions, currentUser);
// Proxy checks authorization before each action
await authorizedProxy.deleteUser('user-123');
Where These Patterns Break Down#
Leaky Facades#
Facades that expose implementation details defeat their purpose.
Leaky:
class CloudStorage {
// Leaky - exposes S3-specific details
async upload(command: PutObjectCommand): Promise<void> {
await this.s3.send(command);
}
}
Contained:
class CloudStorage {
// Proper abstraction - consumers don't see S3
async upload(bucket: string, key: string, data: Buffer): Promise<void> {
const command = new PutObjectCommand({ Bucket: bucket, Key: key, Body: data });
await this.s3.send(command);
}
}
Adapter Proliferation#
Creating an adapter for every external dependency adds maintenance burden without a matching benefit. An adapter pays for itself when the dependency is likely to change or when its shape fights your domain model; everywhere else, it is one more file to keep in sync with two moving interfaces.
Composite Overengineering#
Implementing the full composite pattern for a simple component hierarchy is more machinery than the job needs:
interface Component {
render(): JSX.Element;
getSize(): number;
add(child: Component): void;
remove(child: Component): void;
}
The idiomatic React version skips the ceremony:
// Let React handle composition naturally
function List({ items }: { items: Item[] }) {
return (
<ul>
{items.map(item => <ListItem key={item.id} item={item} />)}
</ul>
);
}
Barrel Export Performance#
Using barrel exports (index.ts files) for internal modules harms build performance: every barrel import pulls in the module graph behind it, so bundlers, type checkers, and test runners load files the code never uses.
Use barrel exports only for public library APIs. For internal modules, import directly:
// Slow with barrels
import { Button, Input, Select } from '@/components';
// Fast with direct imports
import { Button } from '@/components/button';
import { Input } from '@/components/input';
import { Select } from '@/components/select';
When to Keep the Wrapper#
Keep an adapter when a third-party shape leaks into your domain model or when you expect to swap the vendor. A facade earns its place once a single call has to coordinate several services, and a proxy earns its place when caching, lazy loading, or access checks must stay invisible to callers. Hooks cannot reach class components or library internals you cannot edit, so a wrapper stays the answer there, as it does when the wrapping is purely visual. If a wrapper only forwards its arguments to one method, delete it and call the library directly.
References#
- Structural Design Patterns - Refactoring.Guru (opens in new tab) - GoF structural patterns (Adapter, Facade, Composite, Proxy, Decorator) with intent and structure
- Design Patterns in TypeScript - Refactoring.Guru (opens in new tab) - TypeScript code examples for each structural pattern
- Composition vs Inheritance - React Documentation (opens in new tab) - React’s recommendation to prefer composition, with children prop and specialization patterns
- TypeScript Handbook - Creating Types from Types (opens in new tab) - Mapped types, conditional types, and template literals that enhance structural patterns
- The Catalog of Design Patterns - Refactoring.Guru (opens in new tab) - Full pattern catalog for cross-referencing structural patterns with behavioral counterparts
- How We Achieved 75% Faster Builds by Removing Barrel Files - Atlassian (opens in new tab) - Atlassian’s account of removing barrel files from the Jira frontend and the build-minute savings that followed
- Please Stop Using Barrel Files - TkDodo (opens in new tab) - Module-count measurements from a Next.js codebase before and after internal barrel files were removed
Modern Perspective on Classic Design Patterns
A comprehensive series examining how classic Gang of Four design patterns have evolved in modern TypeScript, React, and functional programming contexts. Learn when classic patterns still apply, when they've been superseded, and how to recognize underlying principles in modern codebases.
All posts in this series
Related posts
How SOLID principles apply to modern JavaScript: practical examples with TypeScript, React hooks, and functional patterns, plus when they're overkill.
typescript · javascript · react +4
How Singleton, Factory, Builder, and Prototype patterns evolved in TypeScript: when ES modules replace singletons and when factory functions beat classes.
typescript · design-patterns · architecture +1
A comprehensive introduction to Domain-Driven Design: core concepts, building blocks, strategic patterns, and when and how to apply DDD in practice.
domain-driven-design · architecture · design-patterns +2
A lifecycle test for CDK stack layout: give a resource its own long-lived stack when it outlives any single deployer, then reach it by a well-known name.
aws-cdk · infrastructure-as-code · typescript +3
Match architecture weight to each runtime's init-amortization: lean handlers on single-purpose Lambda, more on a Lambdalith, full OOP/DI only on long-lived runtimes.
architecture · lambda · serverless +3