Typescript

Using Service Interfaces and Null Objects for Resilient TypeScript Scripts

How to combine service interfaces, factory functions, and the Null Object pattern to build TypeScript scripts that run safely without credentials or thi...

I recently needed a script that pulled metrics from several third-party APIs and printed a weekly report to stdout. The problem was not the APIs themselves, it was the environment. On a fresh clone, or in CI, none of the API keys would be present. I did not want the script to crash with a missing credential error. I wanted it to run, print zeros for the missing sources, and exit cleanly.

That requirement pushed me toward a pattern I had used in larger applications but never in a standalone script: service interfaces backed by Null Object implementations. The result was a small architecture that stays testable and degrades gracefully when the outside world is not fully configured.

The Interface as a Contract

The first step was to stop thinking about Stripe, Beehiiv, or any specific vendor, and start thinking about capabilities. What does the script actually need? It needs to know how many people signed up in the last seven days, what the current MRR is, and how the subscriber count is trending.

I modelled each capability as a TypeScript interface:

export interface MrrResult {
  current: number;
  sevenDayDelta: number;
}

export interface BillingServiceInterface {
  getNewSignups(days: number): Promise<number>;
  getTrialToPayRate(): Promise<number>;
  getMrr(): Promise<MrrResult>;
}

The interface is the contract. The script does not know or care whether the implementation talks to Stripe, a local JSON file, or a hardcoded stub. It only cares that the object it receives satisfies BillingServiceInterface.

I repeated this for every data source: newsletter reach, social follower growth, and trial feedback. Each got its own narrow interface with exactly the methods the report needed.

The Null Object Pattern

With the contracts in place, I needed a way to keep the script running when an API key was missing. The Null Object pattern is perfect for this. Instead of returning null from a factory and forcing every call site to handle it, you return a real object that implements the same interface but returns safe default values.

export class NullBillingService implements BillingServiceInterface {
  async getNewSignups(_days: number): Promise<number> {
    return 0;
  }

  async getTrialToPayRate(): Promise<number> {
    return 0;
  }

  async getMrr(): Promise<MrrResult> {
    return { current: 0, sevenDayDelta: 0 };
  }
}

The Null implementation is not a hack. It is a deliberate design decision. It says: "when this capability is unavailable, the correct behaviour is to report zero." The calling code stays linear and free of null checks.

Factories that Decide

The next question was how to choose between the real implementation and the Null one. I used a simple factory function that inspects the environment:

export function createBillingService(): BillingServiceInterface {
  const apiKey = process.env.STRIPE_SECRET_KEY?.trim();
  if (!apiKey) return new NullBillingService();
  return new StripeBillingService(apiKey);
}

The factory centralises the decision. The rest of the application never checks for environment variables. It just calls the factory and uses whatever it gets back. This also makes the code easier to read: if you want to know why a Null service was created, you look in one place.

I used the same shape for every service. Some factories check a single key, others check two, but the pattern is identical. Consistency matters when you are wiring up several external sources in one script.

Injecting Dependencies into the Script

The script itself is just an async function that receives its dependencies as an object:

export interface WeeklyServices {
  billing: BillingServiceInterface;
  newsletter: NewsletterServiceInterface;
  socialReach: SocialReachServiceInterface;
  feedback: FeedbackServiceInterface;
}

export async function runMarketingWeekly(
  services: WeeklyServices,
  output: (line: string) => void = console.log
): Promise<void> {
  const [signups, trialToPayRate, mrr, subscriberCount, subscriberGrowth, followerGrowth, feedbackCount] =
    await Promise.all([
      services.billing.getNewSignups(7),
      services.billing.getTrialToPayRate(),
      services.billing.getMrr(),
      services.newsletter.getSubscriberCount(),
      services.newsletter.getSubscriberGrowth(7),
      services.socialReach.getFollowerGrowth(7),
      services.feedback.getTrialFeedbackCount(7),
    ]);

  output("=== Weekly Report ===");
  output(`Signups (7d):  ${signups}`);
  output(`Trial-to-paid: ${trialToPayRate.toFixed(1)}%`);
  output(`MRR:           $${mrr.current.toFixed(2)} (${formatDelta(mrr.sevenDayDelta)} vs 7d ago)`);
  // ...
}

Passing services in as a parameter rather than importing them directly is the difference between a script that is easy to test and one that is not. In production, the entry point passes the real factories:

runMarketingWeekly({
  billing: createBillingService(),
  newsletter: createNewsletterService(),
  socialReach: createSocialReachService(),
  feedback: createFeedbackService(),
});

In tests, I pass stubs that return predictable data:

class StubBillingService implements BillingServiceInterface {
  constructor(
    private readonly mrr: MrrResult,
    private readonly signups = 0,
    private readonly trialToPayRate = 0
  ) {}

  async getNewSignups(_days: number) { return this.signups; }
  async getTrialToPayRate() { return this.trialToPayRate; }
  async getMrr() { return this.mrr; }
}

No mocking libraries, no environment variable manipulation, no network interception. Just create a stub, pass it in, and assert on the output lines.

Testing Without the Network

The test suite verifies the script with all-Null services to confirm it prints the report without crashing. It also uses a stub billing service to check edge cases like positive and negative MRR deltas:

it("formats positive and negative MRR deltas", async () => {
  const lines: string[] = [];
  const billing = new StubBillingService({ current: 5000, sevenDayDelta: -1200 });

  await runMarketingWeekly(
    { billing, newsletter: new NullNewsletterService(), socialReach: new NullSocialReachService(), feedback: new NullFeedbackService() },
    (line) => lines.push(line)
  );

  expect(lines.some((l) => l.includes("(-1200.00"))).toBe(true);
});

Because the script accepts an output callback, the test does not need to spy on console.log. It captures every printed line into an array and makes ordinary assertions. This is another small dependency-injection win that pays off immediately in tests.

Why This Pattern Works for Scripts

Scripts often start as throwaway code. You import an SDK, read an env var, and log a result. But as soon as the script needs to run in multiple environments, or as soon as you want to test it without real credentials, that direct coupling becomes painful.

Service interfaces and Null Objects add a small amount of upfront structure, but the payoff is immediate. The script runs on every machine, not just the one with production keys. You can exercise the full orchestration logic in tests without network calls. And the interface names the capability, the factory names the decision, and the Null object names the fallback, so the architecture documents itself.

Conclusion

I used to treat scripts as disposable. Import an SDK, read an env var, log a result, move on. But scripts live longer than you expect, and the pain of testing them or running them without credentials always arrives sooner than you think.

Service interfaces, Null Objects, and factory functions are not just for large applications. They work just as well in a single-file script, and they save you from the awkward dance of conditional API calls and mocked globals. Define the contract, provide a safe default, inject the dependency, and you have a script that runs anywhere.

Newer →
Type Annotations vs Type Inference: When to Write the Type

Newsletter

A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.