Harness-engineering gof-null-object

GOF Null Object

install
source · Clone the upstream repo
git clone https://github.com/Intense-Visions/harness-engineering
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/Intense-Visions/harness-engineering "$T" && mkdir -p ~/.claude/skills && cp -r "$T/agents/skills/codex/gof-null-object" ~/.claude/skills/intense-visions-harness-engineering-gof-null-object-8915c3 && rm -rf "$T"
manifest: agents/skills/codex/gof-null-object/SKILL.md
source content

GOF Null Object

Eliminate null checks by providing default no-op implementations of interfaces.

When to Use

  • You have many
    if (thing !== null)
    guards before calling methods on an optional dependency
  • You want a "do nothing" default when an optional feature is absent (e.g., optional logger, optional cache, optional analytics)
  • You're wiring up optional dependencies that are configured at startup
  • You want tests to work without providing real implementations of optional dependencies

Instructions

Basic null object for an optional logger:

interface Logger {
  info(message: string, context?: object): void;
  warn(message: string, context?: object): void;
  error(message: string, error?: Error): void;
}

// Real implementation
class ConsoleLogger implements Logger {
  info(message: string, context?: object): void {
    console.log('[INFO]', message, context ?? '');
  }
  warn(message: string, context?: object): void {
    console.warn('[WARN]', message, context ?? '');
  }
  error(message: string, error?: Error): void {
    console.error('[ERROR]', message, error);
  }
}

// Null object — safe no-ops
class NullLogger implements Logger {
  info(_message: string, _context?: object): void {}
  warn(_message: string, _context?: object): void {}
  error(_message: string, _error?: Error): void {}
}

// Consumer — never needs to check if logger is null
class OrderService {
  constructor(
    private readonly repo: OrderRepository,
    private readonly logger: Logger = new NullLogger() // optional, defaults to null object
  ) {}

  async createOrder(data: CreateOrderInput): Promise<Order> {
    this.logger.info('Creating order', { userId: data.userId });
    const order = await this.repo.create(data);
    this.logger.info('Order created', { orderId: order.id });
    return order;
  }
}

// Production
const service = new OrderService(repo, new ConsoleLogger());

// Test / minimal setup — no logging noise
const testService = new OrderService(repo); // uses NullLogger by default

Null object for an optional cache:

interface Cache {
  get<T>(key: string): Promise<T | null>;
  set<T>(key: string, value: T, ttlMs?: number): Promise<void>;
  delete(key: string): Promise<void>;
}

class RedisCache implements Cache {
  constructor(private readonly client: RedisClient) {}

  async get<T>(key: string): Promise<T | null> {
    const val = await this.client.get(key);
    return val ? JSON.parse(val) : null;
  }

  async set<T>(key: string, value: T, ttlMs = 60_000): Promise<void> {
    await this.client.set(key, JSON.stringify(value), 'PX', ttlMs);
  }

  async delete(key: string): Promise<void> {
    await this.client.del(key);
  }
}

// Null object — always miss, never store
class NullCache implements Cache {
  async get<T>(_key: string): Promise<T | null> {
    return null;
  } // always cache miss
  async set<T>(_key: string, _value: T, _ttlMs?: number): Promise<void> {} // no-op
  async delete(_key: string): Promise<void> {} // no-op
}

// Service uses cache without null checks
class UserService {
  constructor(
    private readonly db: UserRepository,
    private readonly cache: Cache = new NullCache()
  ) {}

  async findUser(id: string): Promise<User | null> {
    const cached = await this.cache.get<User>(`user:${id}`);
    if (cached) return cached;

    const user = await this.db.findById(id);
    if (user) await this.cache.set(`user:${id}`, user, 300_000);
    return user;
  }
}

Typed null object factory:

// Create a null object automatically from an interface (advanced)
function createNullObject<T extends object>(methods: (keyof T)[]): T {
  const obj = {} as T;
  for (const method of methods) {
    (obj as Record<string | symbol, unknown>)[method as string] = () => {};
  }
  return obj;
}

Details

Null Object vs. Optional chaining: Optional chaining (

obj?.method()
) is fine for occasional null checks. Null Object is better when a dependency is consistently optional across many methods — it removes ALL null checks at once, not one at a time.

Null Object vs. TypeScript optional types:

Logger | undefined
as a parameter type forces callers to guard.
Logger
(defaulting to
NullLogger
) is simpler — callers provide a real logger or nothing; the service always has a logger.

Null Object is not the same as

undefined
: A
NullLogger
is a real object that does nothing.
undefined
throws when you call methods on it. The Null Object makes the absence of a feature safe and explicit.

Anti-patterns:

  • Null object that returns null/undefined from methods — the calling code then needs null checks again; return safe defaults (empty arrays, zero, empty string)
  • Null object that tracks calls for assertions in tests — use a mock/spy instead; null objects should be passive
  • Using null objects to mask missing required dependencies — null objects should be for genuinely optional features, not required ones that haven't been wired yet

For analytics / telemetry (common use case):

interface Analytics {
  track(event: string, properties?: object): void;
  identify(userId: string, traits?: object): void;
}

class NullAnalytics implements Analytics {
  track(_event: string, _properties?: object): void {}
  identify(_userId: string, _traits?: object): void {}
}

Source

refactoring.guru/introduce-null-object

Process

  1. Read the instructions and examples in this document.
  2. Apply the patterns to your implementation, adapting to your specific context.
  3. Verify your implementation against the details and edge cases listed above.

Harness Integration

  • Type: knowledge — this skill is a reference document, not a procedural workflow.
  • No tools or state — consumed as context by other skills and agents.

Success Criteria

  • The patterns described in this document are applied correctly in the implementation.
  • Edge cases and anti-patterns listed in this document are avoided.