DEV Community

Cover image for Why tests should verify runtime contracts, not inferred types
Chris Rodriguez
Chris Rodriguez

Posted on Originally published at chriseugenerodriguez.com

Why tests should verify runtime contracts, not inferred types

If your Node tests are green but callers still need casts, you’re mixing two different guarantees. A Node test proves what the function did when executed: the values returned, whether the input was mutated, and which inputs throw. It does not and cannot prove how the TypeScript compiler will narrow that result at a later use site.

Implementation summary

  • Keep the runtime contract explicit: the helper should validate inputs (e.g., reject non-arrays) and return a new array that removes only null and undefined (use a nullish check rather than truthiness). The server-inserted example shows a small function that performs exactly that behavior and does not mutate the input.

Expected behavior to test

  • Mixed-array case: the output contains the original non-nullish items in order and preserves falsy-but-valid values like 0, '', and false.
  • Empty input: returns a new empty array (not the same reference).
  • All-nullish input: returns an empty array.
  • Invalid input: throws a TypeError for non-array arguments.

Where static checking belongs

  • If you need the helper to provide compiler-level narrowing (so consumers can assign without casts), verify that with the TypeScript compiler or editor diagnostics. A minimal compile-time check is to write a small use-site that only accepts the narrowed type and run tsc to ensure it typechecks.

Limitations and pitfalls

  • Compiler inference changes across TypeScript versions (e.g., TypeScript 5.5 adds inferred predicates in some filter callbacks). Relying on runtime tests to encode compiler behavior makes tests brittle or misleading.
  • Truthiness-based filters (e.g., !!x) can silently drop legitimate falsy values — runtime tests must check exact outputs, not just lengths.

Practical rule

  • Tests: prove the runtime contract you actually depend on. Compiler: prove the static narrowing your consumers rely on. Keep them separate so failures point to the right fix: change JS behavior, adjust types, or update consumers.

The article includes a repaired example and concrete test cases that illustrate this split and avoid overclaiming what a JavaScript test can prove. The server will append the full code example and the article URL.

Working example

export const note = `This module demonstrates runtime removal of null and undefined from arrays.
Runtime tests (node:test) validate behavior at execution time but do NOT prove any TypeScript compile-time narrowing. To check static narrowing you must run the TypeScript compiler (tsc) or use editor/IDE type checks.`;

/**
 * Remove null and undefined from an array while preserving order and other falsy
 * values such as 0, '', and false.
 *
 * Runtime contract: returns a new array containing only elements that are not
 * null and not undefined. Throws a TypeError if the argument is not an Array.
 *
 * Note: This demonstrates runtime behavior only. TypeScript's ability to
 * narrow types (e.g. from (T | null | undefined)[] to T[]) is a compile-time
 * property checked by tsc / the language service and is not proven by these
 * runtime tests.
 */
export function filterNonNullish(arr) {
  if (!Array.isArray(arr)) {
    throw new TypeError('filterNonNullish expects an Array');
  }

  return arr.filter((x) => x != null);
}
Enter fullscreen mode Exit fullscreen mode

Read the full article: https://chriseugenerodriguez.com/blog/tests-should-verify-runtime-contract-not-inferred-types

Top comments (0)