Loading learning content...
Theory becomes valuable when applied to real problems. This page walks through comprehensive examples of the Null Object Pattern in production contexts—from logging frameworks to testing infrastructure to domain modeling.
Each example demonstrates not just the pattern structure, but the engineering rationale behind choosing Null Object over alternatives, the implementation nuances that make it work well, and the pitfalls to avoid.
You'll see the Null Object Pattern applied in logging systems, caching infrastructure, plugin architectures, testing doubles, configuration systems, and domain modeling. Each example is production-grade, with careful attention to the details that distinguish robust implementations from naive ones.
Logging is perhaps the canonical use case for Null Objects. Every function might log, but logging configurations vary by environment. In production, you want comprehensive logs. In tests, you often want silence. In development, you might want verbose debugging.
The Null Object Pattern enables this flexibility without littering business logic with conditional checks.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
// ============================================// Comprehensive logging interface// ============================================interface Logger { debug(message: string, context?: LogContext): void; info(message: string, context?: LogContext): void; warn(message: string, context?: LogContext): void; error(message: string, error?: Error, context?: LogContext): void; // Child logger creation for hierarchical logging child(context: LogContext): Logger; // Utility methods isLevelEnabled(level: LogLevel): boolean; getLevel(): LogLevel;} type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent'; interface LogContext { [key: string]: string | number | boolean | undefined;} // ============================================// Real implementation with structured logging// ============================================class StructuredLogger implements Logger { constructor( private level: LogLevel, private baseContext: LogContext = {}, private output: WritableStream = process.stdout ) {} debug(message: string, context?: LogContext): void { if (this.isLevelEnabled('debug')) { this.write('debug', message, context); } } info(message: string, context?: LogContext): void { if (this.isLevelEnabled('info')) { this.write('info', message, context); } } warn(message: string, context?: LogContext): void { if (this.isLevelEnabled('warn')) { this.write('warn', message, context); } } error(message: string, error?: Error, context?: LogContext): void { if (this.isLevelEnabled('error')) { this.write('error', message, { ...context, errorMessage: error?.message, stack: error?.stack }); } } child(context: LogContext): Logger { return new StructuredLogger( this.level, { ...this.baseContext, ...context }, this.output ); } isLevelEnabled(level: LogLevel): boolean { const levels: LogLevel[] = ['debug', 'info', 'warn', 'error', 'silent']; return levels.indexOf(level) >= levels.indexOf(this.level); } getLevel(): LogLevel { return this.level; } private write(level: string, message: string, context?: LogContext): void { const entry = { timestamp: new Date().toISOString(), level, message, ...this.baseContext, ...context, }; console.log(JSON.stringify(entry)); }} // ============================================// Null Logger - complete silence// ============================================class NullLogger implements Logger { debug(message: string, context?: LogContext): void { } info(message: string, context?: LogContext): void { } warn(message: string, context?: LogContext): void { } error(message: string, error?: Error, context?: LogContext): void { } child(context: LogContext): Logger { return this; // Return same null logger—no hierarchy needed } isLevelEnabled(level: LogLevel): boolean { return false; // Nothing is enabled } getLevel(): LogLevel { return 'silent'; }} // ============================================// Factory with environment-based selection// ============================================class LoggerFactory { static create(config: AppConfig): Logger { if (config.environment === 'test' && !config.logging.enableInTests) { return new NullLogger(); } return new StructuredLogger( config.logging.level, { service: config.serviceName, version: config.version, environment: config.environment, } ); }} // ============================================// Usage: Business logic unaware of logging implementation// ============================================class UserService { private logger: Logger; constructor(baseLogger: Logger) { this.logger = baseLogger.child({ component: 'UserService' }); } async createUser(data: CreateUserData): Promise<User> { this.logger.info('Creating user', { email: data.email }); try { const user = await this.repository.create(data); this.logger.info('User created successfully', { userId: user.id }); return user; } catch (error) { this.logger.error('Failed to create user', error as Error, { email: data.email }); throw error; } }}Note how NullLogger.child() returns itself. Since all null loggers behave identically (doing nothing), there's no need to create child instances. This optimization reduces object creation in systems that heavily use hierarchical logging.
Caching is frequently optional or environment-dependent. In production, you might use Redis. In development, an in-memory cache. In some test scenarios, no caching at all. The Null Cache pattern enables transparent cache bypass.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
// ============================================// Comprehensive cache interface// ============================================interface Cache { get<T>(key: string): Promise<T | undefined>; set<T>(key: string, value: T, options?: CacheOptions): Promise<void>; delete(key: string): Promise<boolean>; has(key: string): Promise<boolean>; clear(): Promise<void>; // Multi-key operations mget<T>(keys: string[]): Promise<Map<string, T>>; mset<T>(entries: Map<string, T>, options?: CacheOptions): Promise<void>; // Utility getStats(): CacheStats;} interface CacheOptions { ttlSeconds?: number; tags?: string[];} interface CacheStats { hits: number; misses: number; size: number;} // ============================================// Redis implementation (production)// ============================================class RedisCache implements Cache { private hits = 0; private misses = 0; constructor(private client: RedisClient) {} async get<T>(key: string): Promise<T | undefined> { const value = await this.client.get(key); if (value !== null) { this.hits++; return JSON.parse(value) as T; } this.misses++; return undefined; } async set<T>(key: string, value: T, options?: CacheOptions): Promise<void> { const serialized = JSON.stringify(value); if (options?.ttlSeconds) { await this.client.setex(key, options.ttlSeconds, serialized); } else { await this.client.set(key, serialized); } } async delete(key: string): Promise<boolean> { const result = await this.client.del(key); return result > 0; } async has(key: string): Promise<boolean> { return await this.client.exists(key) === 1; } async clear(): Promise<void> { await this.client.flushdb(); } async mget<T>(keys: string[]): Promise<Map<string, T>> { const values = await this.client.mget(keys); const result = new Map<string, T>(); keys.forEach((key, i) => { if (values[i] !== null) { result.set(key, JSON.parse(values[i]) as T); this.hits++; } else { this.misses++; } }); return result; } async mset<T>(entries: Map<string, T>, options?: CacheOptions): Promise<void> { const pipeline = this.client.pipeline(); entries.forEach((value, key) => { const serialized = JSON.stringify(value); if (options?.ttlSeconds) { pipeline.setex(key, options.ttlSeconds, serialized); } else { pipeline.set(key, serialized); } }); await pipeline.exec(); } getStats(): CacheStats { return { hits: this.hits, misses: this.misses, size: -1 }; // Size requires DBSIZE }} // ============================================// Null Cache - transparent bypass// ============================================class NullCache implements Cache { private readonly stats: CacheStats = { hits: 0, misses: 0, size: 0 }; async get<T>(key: string): Promise<T | undefined> { this.stats.misses++; // Track that cache was attempted return undefined; // Always miss—nothing is cached } async set<T>(key: string, value: T, options?: CacheOptions): Promise<void> { // Intentionally empty—nothing is stored } async delete(key: string): Promise<boolean> { return false; // Nothing to delete } async has(key: string): Promise<boolean> { return false; // Nothing exists } async clear(): Promise<void> { // Already empty } async mget<T>(keys: string[]): Promise<Map<string, T>> { this.stats.misses += keys.length; return new Map(); // All misses } async mset<T>(entries: Map<string, T>, options?: CacheOptions): Promise<void> { // Intentionally empty } getStats(): CacheStats { return { ...this.stats }; // Return copy }} // ============================================// Cache-aside pattern with Null Cache support// ============================================class ProductRepository { constructor( private database: Database, private cache: Cache // Never null—injected as NullCache when disabled ) {} async findById(id: string): Promise<Product | null> { // Try cache first const cached = await this.cache.get<Product>(`product:${id}`); if (cached !== undefined) { return cached; } // Cache miss—load from database const product = await this.database.query<Product>( 'SELECT * FROM products WHERE id = ?', [id] ); // Store in cache for next time if (product !== null) { await this.cache.set(`product:${id}`, product, { ttlSeconds: 3600 }); } return product; }} // When NullCache is injected:// - get() always returns undefined (cache miss)// - set() does nothing (no caching)// - Business logic works correctly, just without cachingThe NullCache tracks misses even though it never caches. This preserves observability—you can see how many cache lookups occurred and verify the cache-aside logic is being invoked, even when caching is disabled.
Plugin architectures benefit enormously from Null Objects. When a plugin isn't loaded or is disabled, a Null Plugin allows the host application to call plugin methods without checking whether each plugin exists.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
// ============================================// Plugin interface with lifecycle hooks// ============================================interface Plugin { readonly name: string; readonly version: string; // Lifecycle hooks onApplicationStart(): Promise<void>; onApplicationStop(): Promise<void>; onRequest(context: RequestContext): Promise<void>; onResponse(context: ResponseContext): Promise<void>; onError(error: Error, context: ErrorContext): Promise<void>; // Plugin status isEnabled(): boolean; getHealthStatus(): HealthStatus;} interface HealthStatus { healthy: boolean; message: string; lastCheck: Date;} // ============================================// Real plugin example: Authentication// ============================================class AuthenticationPlugin implements Plugin { readonly name = 'authentication'; readonly version = '2.1.0'; private healthy = true; constructor(private config: AuthConfig) {} async onApplicationStart(): Promise<void> { await this.initializeTokenVerification(); console.log('[Auth Plugin] Started'); } async onApplicationStop(): Promise<void> { await this.cleanup(); console.log('[Auth Plugin] Stopped'); } async onRequest(context: RequestContext): Promise<void> { const token = context.headers['authorization']; if (token) { context.user = await this.verifyToken(token); } } async onResponse(context: ResponseContext): Promise<void> { // Add security headers context.headers['X-Auth-Plugin'] = this.version; } async onError(error: Error, context: ErrorContext): Promise<void> { if (error instanceof AuthenticationError) { context.statusCode = 401; context.body = { error: 'Unauthorized' }; } } isEnabled(): boolean { return true; } getHealthStatus(): HealthStatus { return { healthy: this.healthy, message: this.healthy ? 'OK' : 'Token verification unavailable', lastCheck: new Date(), }; } private async initializeTokenVerification(): Promise<void> { /* ... */ } private async verifyToken(token: string): Promise<User | null> { /* ... */ } private async cleanup(): Promise<void> { /* ... */ }} // ============================================// Null Plugin for disabled/missing plugins// ============================================class NullPlugin implements Plugin { readonly name: string; readonly version = '0.0.0'; constructor(pluginName: string) { this.name = `null:${pluginName}`; } async onApplicationStart(): Promise<void> { } async onApplicationStop(): Promise<void> { } async onRequest(context: RequestContext): Promise<void> { } async onResponse(context: ResponseContext): Promise<void> { } async onError(error: Error, context: ErrorContext): Promise<void> { } isEnabled(): boolean { return false; } getHealthStatus(): HealthStatus { return { healthy: true, // Null plugin is always "healthy" (nothing to fail) message: 'Plugin disabled', lastCheck: new Date(), }; }} // ============================================// Plugin manager using null objects// ============================================class PluginManager { private plugins: Map<string, Plugin> = new Map(); register(plugin: Plugin): void { this.plugins.set(plugin.name, plugin); } // Get plugin by name—returns null object if not found getPlugin(name: string): Plugin { return this.plugins.get(name) ?? new NullPlugin(name); } // Get all enabled plugins getEnabledPlugins(): Plugin[] { return Array.from(this.plugins.values()).filter(p => p.isEnabled()); } // Lifecycle methods call all plugins uniformly async startAll(): Promise<void> { for (const plugin of this.plugins.values()) { await plugin.onApplicationStart(); // No null checks needed } } async stopAll(): Promise<void> { for (const plugin of this.plugins.values()) { await plugin.onApplicationStop(); } } async processRequest(context: RequestContext): Promise<void> { for (const plugin of this.plugins.values()) { await plugin.onRequest(context); } } async processResponse(context: ResponseContext): Promise<void> { for (const plugin of this.plugins.values()) { await plugin.onResponse(context); } } getHealth(): Map<string, HealthStatus> { const health = new Map<string, HealthStatus>(); for (const [name, plugin] of this.plugins) { health.set(name, plugin.getHealthStatus()); } return health; }} // ============================================// Application using plugin manager// ============================================class Application { constructor(private plugins: PluginManager) {} async handleRequest(request: HttpRequest): Promise<HttpResponse> { const context = new RequestContext(request); // Plugins process request—disabled ones do nothing await this.plugins.processRequest(context); // Main application logic const result = await this.processBusinessLogic(context); const responseContext = new ResponseContext(result); await this.plugins.processResponse(responseContext); return responseContext.toResponse(); } // Get specific plugin functionality async getAuthenticatedUser(request: HttpRequest): Promise<User | null> { const authPlugin = this.plugins.getPlugin('authentication') as AuthenticationPlugin; // If plugin not registered, returns NullPlugin which has no-op methods // Safe to call even when auth is disabled return null; // Would extract from context after plugin processing }}Null Objects serve as excellent test doubles when you need dependencies that do nothing. They're simpler than full mocks when you don't need to verify calls or stub return values—you just need the dependency to exist without side effects.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
// ============================================// Interface for external service// ============================================interface EmailService { send(to: string, subject: string, body: string): Promise<EmailResult>; sendBatch(emails: Email[]): Promise<BatchResult>; getDeliveryStatus(messageId: string): Promise<DeliveryStatus>;} interface Email { to: string; subject: string; body: string; attachments?: Attachment[];} // ============================================// Null Email Service for testing// ============================================class NullEmailService implements EmailService { async send(to: string, subject: string, body: string): Promise<EmailResult> { return { success: true, messageId: `null-message-${Date.now()}`, sentAt: new Date(), }; } async sendBatch(emails: Email[]): Promise<BatchResult> { return { success: true, sent: emails.length, failed: 0, errors: [], }; } async getDeliveryStatus(messageId: string): Promise<DeliveryStatus> { return { status: 'delivered', deliveredAt: new Date(), }; }} // ============================================// Tests using null objects// ============================================describe('UserRegistrationService', () => { // Use null objects for dependencies we don't care about in this test const nullEmailService = new NullEmailService(); const nullLogger = new NullLogger(); const nullMetrics = new NullMetrics(); // Real mock only for what we're actually testing const mockUserRepository = createMock<UserRepository>(); const service = new UserRegistrationService( mockUserRepository, nullEmailService, // Don't care about email in these tests nullLogger, // Don't want log noise in test output nullMetrics // Don't need metrics verification ); it('should create user with valid data', async () => { // Arrange mockUserRepository.create.mockResolvedValue({ id: '123', email: 'test@example.com' }); // Act const result = await service.register({ email: 'test@example.com', password: 'secure123', }); // Assert only what this test cares about expect(result.user.id).toBe('123'); expect(mockUserRepository.create).toHaveBeenCalledOnce(); // Email service was called but we don't verify—null object handled it // Logger was called but we don't see output—null object absorbed it }); it('should handle duplicate email gracefully', async () => { // Arrange mockUserRepository.create.mockRejectedValue(new DuplicateEmailError()); // Act & Assert await expect(service.register({ email: 'existing@example.com', password: 'secure123', })).rejects.toThrow('Email already registered'); });}); // ============================================// Spy Null Object for verification when needed// ============================================class SpyEmailService implements EmailService { private callLog: Array<{ method: string; args: unknown[] }> = []; async send(to: string, subject: string, body: string): Promise<EmailResult> { this.callLog.push({ method: 'send', args: [to, subject, body] }); return { success: true, messageId: `spy-${Date.now()}`, sentAt: new Date() }; } async sendBatch(emails: Email[]): Promise<BatchResult> { this.callLog.push({ method: 'sendBatch', args: [emails] }); return { success: true, sent: emails.length, failed: 0, errors: [] }; } async getDeliveryStatus(messageId: string): Promise<DeliveryStatus> { this.callLog.push({ method: 'getDeliveryStatus', args: [messageId] }); return { status: 'delivered', deliveredAt: new Date() }; } // Test utilities getCalls(): Array<{ method: string; args: unknown[] }> { return [...this.callLog]; } wasMethodCalled(method: string): boolean { return this.callLog.some(call => call.method === method); } clearCalls(): void { this.callLog = []; }}Use Null Objects when you don't need to verify interactions or stub specific returns—just silencing dependencies. Use Mocks when you need to assert specific calls were made or control return values. Spy variants of Null Objects give you both: default behavior with optional call tracking.
Feature flags often toggle functionality on and off. Rather than checking flags at every usage site, Null Objects allow you to inject the appropriate implementation based on the flag state.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
// ============================================// Feature interface// ============================================interface RecommendationEngine { getRecommendations(userId: string, context: RecommendationContext): Promise<Recommendation[]>; recordInteraction(userId: string, itemId: string, action: InteractionType): Promise<void>; trainModel(): Promise<TrainingResult>;} interface Recommendation { itemId: string; score: number; reason: string;} // ============================================// Real ML-based recommendation engine// ============================================class MLRecommendationEngine implements RecommendationEngine { constructor(private modelService: ModelService) {} async getRecommendations(userId: string, context: RecommendationContext): Promise<Recommendation[]> { const userFeatures = await this.extractUserFeatures(userId); const predictions = await this.modelService.predict(userFeatures, context); return predictions.map(p => ({ itemId: p.id, score: p.confidence, reason: this.generateExplanation(p), })); } async recordInteraction(userId: string, itemId: string, action: InteractionType): Promise<void> { await this.modelService.recordFeedback({ userId, itemId, action }); } async trainModel(): Promise<TrainingResult> { return await this.modelService.retrain(); } private async extractUserFeatures(userId: string): Promise<Features> { /* ... */ } private generateExplanation(prediction: Prediction): string { /* ... */ }} // ============================================// Fallback: simple popularity-based recommendations// ============================================class SimpleRecommendationEngine implements RecommendationEngine { constructor(private popularItemsService: PopularItemsService) {} async getRecommendations(userId: string, context: RecommendationContext): Promise<Recommendation[]> { // Fallback: just return popular items const popular = await this.popularItemsService.getTopItems(context.category, 10); return popular.map((item, index) => ({ itemId: item.id, score: 1 - (index * 0.1), // Decreasing score reason: 'Popular in this category', })); } async recordInteraction(userId: string, itemId: string, action: InteractionType): Promise<void> { // No-op—simple engine doesn't learn from interactions } async trainModel(): Promise<TrainingResult> { return { success: true, message: 'Simple engine does not require training' }; }} // ============================================// Null: no recommendations at all (feature disabled)// ============================================class NullRecommendationEngine implements RecommendationEngine { async getRecommendations(userId: string, context: RecommendationContext): Promise<Recommendation[]> { return []; // No recommendations } async recordInteraction(userId: string, itemId: string, action: InteractionType): Promise<void> { // No-op } async trainModel(): Promise<TrainingResult> { return { success: true, message: 'Recommendations disabled' }; }} // ============================================// Factory with feature flag integration// ============================================class RecommendationEngineFactory { constructor( private featureFlags: FeatureFlagService, private modelService: ModelService, private popularItemsService: PopularItemsService ) {} create(): RecommendationEngine { // Check feature flags to determine which implementation if (!this.featureFlags.isEnabled('recommendations')) { return new NullRecommendationEngine(); } if (this.featureFlags.isEnabled('ml-recommendations')) { return new MLRecommendationEngine(this.modelService); } return new SimpleRecommendationEngine(this.popularItemsService); } // For user-specific feature flags createForUser(userId: string): RecommendationEngine { // Could be A/B testing different engines per user segment if (!this.featureFlags.isEnabledForUser('recommendations', userId)) { return new NullRecommendationEngine(); } if (this.featureFlags.isEnabledForUser('ml-recommendations', userId)) { return new MLRecommendationEngine(this.modelService); } return new SimpleRecommendationEngine(this.popularItemsService); }} // ============================================// Usage: Business logic doesn't know about flags// ============================================class ProductPageController { constructor(private recommendationEngine: RecommendationEngine) {} async getProductPage(productId: string, userId: string): Promise<ProductPageData> { const product = await this.loadProduct(productId); // Always call—engine might be real or null const recommendations = await this.recommendationEngine.getRecommendations( userId, { category: product.category, currentItem: productId } ); return { product, recommendations, // Empty array if null engine, populated otherwise }; }}In domain modeling, Null Objects represent legitimate domain concepts—not just absent implementations. A GuestCustomer, UnassignedEmployee, or DefaultPolicy can embody specific business rules for the "absent" case.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
// ============================================// Customer domain with Guest special case// ============================================interface Customer { getId(): string; getName(): string; getEmail(): string; getTier(): CustomerTier; getDiscount(): number; canAccessPremiumFeatures(): boolean; getPaymentMethods(): PaymentMethod[]; getOrderHistory(): Order[];} type CustomerTier = 'bronze' | 'silver' | 'gold' | 'platinum' | 'guest'; // ============================================// Real customer implementation// ============================================class RegisteredCustomer implements Customer { constructor( private id: string, private name: string, private email: string, private tier: CustomerTier, private paymentMethods: PaymentMethod[], private orderHistory: Order[] ) {} getId(): string { return this.id; } getName(): string { return this.name; } getEmail(): string { return this.email; } getTier(): CustomerTier { return this.tier; } getDiscount(): number { const discounts: Record<CustomerTier, number> = { bronze: 0, silver: 0.05, gold: 0.10, platinum: 0.15, guest: 0, }; return discounts[this.tier]; } canAccessPremiumFeatures(): boolean { return this.tier === 'gold' || this.tier === 'platinum'; } getPaymentMethods(): PaymentMethod[] { return this.paymentMethods; } getOrderHistory(): Order[] { return this.orderHistory; }} // ============================================// Guest Customer - special case for anonymous users// ============================================class GuestCustomer implements Customer { private static instance: GuestCustomer; static getInstance(): GuestCustomer { if (!this.instance) { this.instance = new GuestCustomer(); } return this.instance; } // Guest has a special ID for analytics/tracking getId(): string { return 'guest'; } getName(): string { return 'Guest'; } getEmail(): string { return ''; } // No email getTier(): CustomerTier { return 'guest'; } // No discount for guests getDiscount(): number { return 0; } // Guests cannot access premium features canAccessPremiumFeatures(): boolean { return false; } // Guests have no saved payment methods getPaymentMethods(): PaymentMethod[] { return []; } // Guests have no order history (they're not logged in) getOrderHistory(): Order[] { return []; }} // ============================================// Employee domain with Unassigned special case// ============================================interface Employee { getId(): string; getName(): string; getManager(): Employee; getDepartment(): string; canApprove(amount: number): boolean; getApprovalLimit(): number;} class RegularEmployee implements Employee { constructor( private id: string, private name: string, private manager: Employee, private department: string, private approvalLimit: number ) {} getId(): string { return this.id; } getName(): string { return this.name; } getManager(): Employee { return this.manager; } getDepartment(): string { return this.department; } canApprove(amount: number): boolean { return amount <= this.approvalLimit; } getApprovalLimit(): number { return this.approvalLimit; }} // Unassigned Employee - for open positions, pending assignmentsclass UnassignedEmployee implements Employee { static readonly INSTANCE = new UnassignedEmployee(); getId(): string { return 'unassigned'; } getName(): string { return 'Unassigned'; } // Unassigned employees have no manager—return self to break chain getManager(): Employee { return this; } getDepartment(): string { return 'Unassigned'; } // Cannot approve anything canApprove(amount: number): boolean { return false; } getApprovalLimit(): number { return 0; }} // ============================================// Usage: Business logic handles all cases uniformly// ============================================class PricingService { calculatePrice(product: Product, customer: Customer): PriceBreakdown { const basePrice = product.getPrice(); const discount = customer.getDiscount(); // 0 for guests const discountAmount = basePrice * discount; return { basePrice, discountPercentage: discount * 100, discountAmount, finalPrice: basePrice - discountAmount, tier: customer.getTier(), // 'guest' for guests }; }} class CheckoutService { canCheckout(customer: Customer, cart: Cart): CheckoutEligibility { const paymentMethods = customer.getPaymentMethods(); // Guest customers have empty payment methods—must add during checkout if (paymentMethods.length === 0) { return { eligible: true, requiresPaymentMethodEntry: true, // Guest flow }; } return { eligible: true, requiresPaymentMethodEntry: false, // Registered user flow savedPaymentMethods: paymentMethods, }; }} // Works seamlessly:const guest = GuestCustomer.getInstance();const registered = new RegisteredCustomer(/* ... */); pricingService.calculatePrice(product, guest); // Works: 0% discountpricingService.calculatePrice(product, registered); // Works: tier-based discount checkoutService.canCheckout(guest, cart); // Works: requires payment entrycheckoutService.canCheckout(registered, cart); // Works: uses saved methodsGuestCustomer and UnassignedEmployee are 'Special Case' objects—a generalization of Null Object. While Null Objects do nothing, Special Case objects do domain-specific things. GuestCustomer returns 0% discount (not just 'nothing'), and that's meaningful business logic.
Null Objects combine naturally with other patterns. Here's how they work with Composite, Decorator, and Strategy patterns.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
// ============================================// Composite Validator with Null Object leaf// ============================================interface Validator { validate(data: unknown): ValidationResult;} interface ValidationResult { valid: boolean; errors: ValidationError[];} // Composite: combines multiple validatorsclass CompositeValidator implements Validator { private validators: Validator[] = []; add(validator: Validator): void { this.validators.push(validator); } validate(data: unknown): ValidationResult { const allErrors: ValidationError[] = []; for (const validator of this.validators) { const result = validator.validate(data); allErrors.push(...result.errors); } return { valid: allErrors.length === 0, errors: allErrors, }; }} // Null Validator: always passes (used when validation disabled)class NullValidator implements Validator { validate(data: unknown): ValidationResult { return { valid: true, errors: [] }; // Always valid }} // Usage: conditionally add validators, or use nullfunction buildValidator(config: ValidationConfig): Validator { if (!config.enabled) { return new NullValidator(); } const composite = new CompositeValidator(); if (config.requireEmail) { composite.add(new EmailValidator()); } if (config.requirePhone) { composite.add(new PhoneValidator()); } if (config.customRules) { composite.add(new CustomRuleValidator(config.customRules)); } // If no validators added, composite behaves like null (no errors) return composite;}12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
// ============================================// Retry Strategy with Null (no retry) option// ============================================interface RetryStrategy { shouldRetry(attempt: number, error: Error): boolean; getDelay(attempt: number): number;} class ExponentialBackoffRetry implements RetryStrategy { constructor( private maxAttempts: number = 3, private baseDelayMs: number = 1000 ) {} shouldRetry(attempt: number, error: Error): boolean { return attempt < this.maxAttempts && this.isRetryable(error); } getDelay(attempt: number): number { return this.baseDelayMs * Math.pow(2, attempt); } private isRetryable(error: Error): boolean { return error instanceof NetworkError || error instanceof TimeoutError; }} class NullRetryStrategy implements RetryStrategy { // Never retry shouldRetry(attempt: number, error: Error): boolean { return false; } getDelay(attempt: number): number { return 0; }} // HTTP Client using retry strategyclass HttpClient { constructor(private retryStrategy: RetryStrategy) {} async request(url: string, options: RequestOptions): Promise<Response> { let attempt = 0; while (true) { try { return await this.executeRequest(url, options); } catch (error) { if (this.retryStrategy.shouldRetry(attempt, error as Error)) { const delay = this.retryStrategy.getDelay(attempt); await this.sleep(delay); attempt++; } else { throw error; // No more retries } } } }} // Usage:const resilientClient = new HttpClient(new ExponentialBackoffRetry(5, 500));const noRetryClient = new HttpClient(new NullRetryStrategy());We've explored comprehensive real-world applications of the Null Object Pattern:
| Domain | Null Object | Neutral Behavior |
|---|---|---|
| Logging | NullLogger | All log methods are no-ops |
| Caching | NullCache | get() returns undefined, set() is no-op |
| Plugins | NullPlugin | All lifecycle hooks are no-ops |
| NullEmailService | Returns success without sending | |
| Recommendations | NullRecommendationEngine | Returns empty recommendation list |
| Customers | GuestCustomer | Returns 0% discount, no saved data |
| Validation | NullValidator | Always returns valid |
| Retry | NullRetryStrategy | Never retries |
Module Complete:
You've now mastered the Null Object Pattern—from understanding the problems with null references, through the pattern's structure and implementation, the tradeoffs to consider, and comprehensive real-world applications. Apply this pattern where optional behavior needs clean handling without defensive conditionals.
You've completed the Null Object Pattern module. You understand when null references create problems, how null objects provide elegant solutions, the benefits and tradeoffs to consider, and how to apply the pattern in real-world scenarios. Use this knowledge to write cleaner, more maintainable code.