Test Doubles in Production: The Case for Exhaustive Type Safety Over Mocks

By Sven Lindqvist · 5 August 20265,873 views
Test Doubles in Production: The Case for Exhaustive Type Safety Over Mocks

The Illusion of Safety: Why Mocks Often Hide Runtime Failures

In the ecosystem of modern TypeScript development, we are often seduced by the convenience of mocking. We want to isolate our components, test them in a vacuum, and move on to the next feature. But as a compiler enthusiast and a developer who has spent years watching production logs filled with undefined errors and unhandled null states, I have come to view many common testing patterns—especially deep mocking—as a fundamental betrayal of type safety.

When we mock an external API response or a complex domain service, we are essentially asserting a contract that we hope remains true. If that contract drifts—if an API schema adds a new field or changes a status string—our mocks often remain unchanged, passing tests while the actual integration fails in production. The problem isn't just the testing strategy; it’s that we aren't using our most powerful tool, the TypeScript compiler, to enforce the constraints that matter.

Discriminated unions are the backbone of robust TypeScript development. They allow us to describe the states of our application with surgical precision. However, a discriminated union is only as strong as its exhaustiveness check. If you have a union type representing four distinct states, but your logic only handles three, you haven't just written an incomplete feature—you’ve introduced a time bomb. When the fourth state arrives, your code will fail silently, or worse, execute the default case, which is often the source of those dreaded 'impossible' production bugs.

The Failure of Default Cases

Most developers rely on switch statements or if-else blocks to handle union types. We often default to a default case that logs an error or returns a fallback value. While this prevents a crash at runtime, it actively circumvents the compiler's ability to help you. By providing a default catch-all, you are telling the compiler, "I don't care if a new case is added to this union later."

This is the classic scenario that leads to the 'six-month decay' problem. You write a handler for three states. Six months later, a teammate adds a fourth member to the union. The compiler remains silent because your default branch swallows the new state. If that state required a specific UI update or a database write, you have now introduced a silent state regression.

True type safety demands that we eliminate these default branches. Instead, we should rely on the never type. By assigning an unhandled case to a variable of type never, we force the compiler to verify that every single possible inhabitant of our union type has been exhausted. If a new member is added, the code will fail to compile. This is the difference between a test that passes in CI because you mocked the dependency, and a build that refuses to compile because you haven't handled the new domain state.

type PaymentStatus = 'pending' | 'success' | 'failed' | 'refunded';

function handlePayment(status: PaymentStatus) {
  switch (status) {
    case 'pending':
      return 'Loading...';
    case 'success':
      return 'Paid';
    case 'failed':
      return 'Error';
    case 'refunded':
      return 'Returned';
    default:
      // This is the bug-prone pattern. We should use exhaustiveness checking instead.
      const _exhaustiveCheck: never = status;
      throw new Error(`Unhandled status: ${_exhaustiveCheck}`);
  }
}

Implementing Exhaustiveness at the Compiler Level

To move beyond simple pattern matching, we must treat exhaustiveness as a first-class citizen of our architecture. My work on the ESLint plugin for exhaustive union handling was born out of this frustration. I wanted to prevent developers from having to manually write never checks everywhere. The plugin acts as a gatekeeper, ensuring that any switch statement operating on a union type must account for every member of that union.

This approach fundamentally changes how you view testing. Instead of writing complex mocks to test how your function handles an unexpected state, you structure your code so that the 'unexpected state' is a compiler error. This is a much stronger guarantee than any mock can provide. If you have a system where you are 'testing' for edge cases, you are likely doing it wrong; you should be 'encoding' those edge cases into your type system so they become impossible to reach without a corresponding handler.

Consider the ergonomic benefit: when you add a new member to a union type, your IDE will immediately highlight every single place in your codebase that needs an update. You no longer need to hunt through your tests to see which mocks need updating; the compiler provides the exhaustive list of missing implementations. This is the definition of developer ergonomics in a type-safe environment.

The Distinctions Between Mocks and Reality

There is a subtle but profound distinction between mocking an external boundary and mocking an internal domain model. When we mock external APIs, we are dealing with external state—state that we don't control. Here, testing is necessary, but it should be focused on contract verification (using tools like Zod or type-safe API schemas) rather than just manual mocks.

However, when we mock internal application logic—like business logic branches or state transitions—we are often covering up the fact that our types aren't descriptive enough. If you have to mock five different states of a user object, perhaps those states should be explicitly represented as a discriminated union instead of an ambiguous object with optional properties.

When you use typescript effectively, you replace the need for complex mocks with simple, structural types. If your code is tightly coupled to the shape of the data, the compiler can tell you exactly where you need to change. If you rely on mocks, you are decoupling your code from the actual data structure, which creates a false sense of security. I see this often in large-scale projects: thousands of lines of test code that are basically just 'reinventing' the type system in a more fragile, harder-to-maintain format.

Moving Toward Architecture-Driven Testing

To build software that lasts, we need to shift our focus from 'testing that the code works' to 'architecting the code so that it cannot fail in ways we haven't defined.' This means:

  1. Strict Type Definitions: Use discriminated unions to define every possible state. Never allow 'unreachable' to be reachable.
  2. Eliminate Mocks: Where possible, replace mocks with real objects that have been constrained by your type definitions. If you must mock, use runtime schema validation to ensure the mock matches the expected interface.
  3. Enforce Exhaustiveness: Configure your ESLint rules to require every switch or match statement to handle all union members. Do not allow default branches that swallow errors.
  4. Compiler-First Design: If you find yourself writing a test to verify a new state, stop. Add the state to your type definition, let the compiler break the build, and then resolve the exhaustiveness errors. This is the fastest, safest way to develop.

When we adopt this mindset, testing becomes about verification of intent, not just patching gaps in logic. We stop worrying about whether we've 'covered' the new edge case in our mocks because the compiler has already forced us to handle it in our implementation. This is the transition from 'testing for bugs' to 'engineering out the possibility of bugs.'

As someone who maintains tools that developers use daily, I see the difference between teams that leverage the TypeScript compiler and those that fight it. The teams that use exhaustiveness checks have cleaner code, fewer runtime issues, and—most importantly—much less anxiety when it comes time to ship. They understand that the compiler is not an obstacle; it is the most reliable member of the development team. If you aren't using the compiler to exhaustively define your state transitions, you are leaving your application's reliability to chance. And in production, chance is not a strategy—it is a liability waiting to be triggered by the next minor change in your domain model. Embrace the constraint, let the compiler do the heavy lifting, and stop letting mocks hide your technical debt.

Comments

No comments yet. Be the first!

Sign in to leave a comment.