Skip to content

Instantly share code, notes, and snippets.

@KevinOrfas
Created April 20, 2026 12:18
Show Gist options
  • Select an option

  • Save KevinOrfas/516354826254af9f59388fce95ccd802 to your computer and use it in GitHub Desktop.

Select an option

Save KevinOrfas/516354826254af9f59388fce95ccd802 to your computer and use it in GitHub Desktop.
Nullable pattern: generic ConfigurableResponse<T> with EventEmitter and OutputTracker. From EkoLite.

Nullable pattern: generic ConfigurableResponse<T>

A typed, generic implementation of the ConfigurableResponse primitive used alongside OutputTracker and EventEmitter to build null objects that can be seeded with pre configured responses (including errors) for tests.

Forked and generified from EkoLite, the public dojo demo from ekohacks.

What it does

A null wrapper (e.g. FileStorage.createNull()) is used in tests instead of a real implementation. ConfigurableResponse<T> lets a test pre seed a queue of responses the null should return for each operation, including error throwing:

const storage = FileStorage.createNull({
  exists: [true, false, new Error('stat failed')],
});

await storage.exists('a.txt'); // true
await storage.exists('b.txt'); // false
await storage.exists('c.txt'); // throws Error('stat failed')
await storage.exists('d.txt'); // throws 'queue exhausted'

The generic parameter T is the type the null would normally return for that operation. void for methods that return nothing (save, remove), concrete types for methods that do (boolean for exists, a result shape for exec, and so on).

Why generic

Without the generic, every null signature devolves to unknown[], which lets any JS value through and makes the API lie to its readers. With ConfigurableResponse<T>, a null's signature describes exactly what it will accept, and TypeScript catches misuse at the call site.

Design notes

  • T | Error in the queue: a response is either a value of type T, or an Error to throw.
  • Default T = unknown keeps older callers compiling during migration.
  • Exhaustion throws by default. A test that runs out of configured responses will fail loudly rather than silently returning a default (which would mask missed configuration).
  • hasNext() exists for consumers that explicitly want lenient fallback behaviour. Calling it before next() gives the consumer the option to skip and use a default instead of throwing.

Usage

import { ConfigurableResponse } from './configurableResponse.ts';

class StubbedFileSystem {
  private store = new Map<string, Buffer>();
  private existsResponses?: ConfigurableResponse<boolean>;

  constructor(options: { exists?: (boolean | Error)[] } = {}) {
    if (options.exists) {
      this.existsResponses = new ConfigurableResponse(options.exists);
    }
  }

  exists(name: string): Promise<boolean> {
    if (this.existsResponses?.hasNext()) {
      return Promise.resolve(this.existsResponses.next());
    }
    return Promise.resolve(this.store.has(name));
  }
}

For a method that returns nothing, type it as ConfigurableResponse<void>:

private saveResponses?: ConfigurableResponse<void>;
// caller: save: [new Error('disk full')]
// any non Error value is a type error at compile time

For a method that returns a domain shape:

private runnerResponses = new Map<string, ConfigurableResponse<ScriptResult | string>>();
// caller: echo: ['hello', new Error('crashed')]

Travels with

ConfigurableResponse usually ships alongside two helpers:

  • EventEmitter: internal pub/sub for recording what operations happened against the null.
  • OutputTracker: reads the emitter to give tests an assertable history.

All three together are "the Nullable infrastructure". Each is independently useful.

License

MIT, do whatever you want with it.

// Nullable pattern infrastructure: generic ConfigurableResponse<T> with
// EventEmitter and OutputTracker.
//
// Use ConfigurableResponse to seed a null object's responses in tests.
// Use EventEmitter + OutputTracker to observe what operations happened.
//
// Forked and generified from EkoLite.
export class EventEmitter {
private handlers: Map<string, ((data: unknown) => void)[]> = new Map();
on(eventType: string, handler: (data: unknown) => void): void {
if (!this.handlers.has(eventType)) {
this.handlers.set(eventType, []);
}
this.handlers.get(eventType)?.push(handler);
}
emit(eventType: string, data: unknown): void {
const handlers = this.handlers.get(eventType) ?? [];
for (const handler of handlers) {
handler(data);
}
}
}
export class OutputTracker {
private _data: unknown[] = [];
constructor(emitter: EventEmitter, eventType: string) {
emitter.on(eventType, (data: unknown) => {
this._data.push(data);
});
}
get data(): unknown[] {
return [...this._data];
}
}
function isEmptyObject(value: unknown): boolean {
return (
value !== null &&
typeof value === 'object' &&
!(value instanceof Error) &&
!Array.isArray(value) &&
Object.keys(value).length === 0
);
}
/**
* A queue of pre configured responses for a null object's operations.
*
* Each response is either a value of type T (returned by next()) or an Error
* (thrown by next()). Exhausting the queue throws 'queue exhausted'.
*
* Default T = unknown keeps existing callers compiling during migration.
*/
export class ConfigurableResponse<T = unknown> {
private queue: (T | Error)[];
constructor(responses: (T | Error)[]) {
for (const response of responses) {
if (isEmptyObject(response)) {
throw new Error(
'Empty object {} is not a valid configurable response. Use [] for empty arrays or null explicitly.',
);
}
}
this.queue = [...responses];
}
hasNext(): boolean {
return this.queue.length > 0;
}
next(): T {
if (this.queue.length === 0) {
throw new Error('ConfigurableResponse queue exhausted — no more responses configured');
}
const response = this.queue.shift();
if (response instanceof Error) {
throw response;
}
return response as T;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment