Why should tests verify the runtime contract instead of inferred types?
Node tests can prove what a function does at runtime, but they cannot prove what TypeScript inferred. Use runtime assertions for behavior and the compiler for narrowing.
A green Node test run can be comforting, but it does not automatically prove a TypeScript narrowing claim. It proves that the JavaScript you ran produced the expected values and errors. That is useful evidence, but it is evidence about behavior, not about the compiler’s internal inference.
The practical rule is straightforward: use runtime tests to verify the contract your code actually executes, and use the TypeScript compiler to verify the static types your callers rely on. When those two checks are kept separate, the failure you see points to the right fix instead of hiding two different problems behind one passing test.
Runtime behavior and static narrowing answer different questions
A Node test answers a simple question: when this function runs, what does it return, throw, or mutate? That makes it the right tool for checking an observable contract such as “remove null and undefined from this array, preserve the original order, and leave other falsy values alone.” In the repaired example, the runtime contract is explicit. The helper accepts an array, rejects non-arrays with a `TypeError`, and returns a new array that excludes only nullish values.
That same test cannot answer a different question: will TypeScript narrow the result in a way that lets later code treat the value as non-nullable without a cast? Narrowing is a static analysis performed by the compiler and language service. It depends on the shape of the code, the declared types, and the TypeScript version. A JavaScript test runner does not execute that analysis, so a passing test cannot prove it.
This distinction is easy to blur because both concerns are about the same function. But the function’s runtime behavior and its static type story are separate claims. The runtime claim belongs in `node:test`; the static claim belongs in a `tsc` check, editor diagnostics, or another compile-time assertion. If the article says tests should verify the runtime contract instead of inferred types, that is not a dismissal of typing. It is a boundary marker: observe behavior with tests, and verify narrowing with the compiler.
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);
}
TypeScript narrows control flow; it does not run your test suite
The narrowing handbook shows TypeScript tracking branches such as `typeof`, equality checks, truthiness, and user-defined predicates. The important detail is that this happens through control flow analysis. TypeScript follows the possible paths through your code and then refines the type at a particular point. It is not observing a completed function call or inspecting the array that a test later receives.
That is why a runtime assertion like `assert.deepEqual(output, [1, 2, 0, '', false, 'ok'])` is valuable but limited. It confirms the values that came back from the function. It does not prove that the compiler will accept a downstream assignment such as `const ids: number[] = output` unless the project actually runs the TypeScript checker. The compiler and the test runner are answering different questions about the same code.
The handbook examples also explain why a naive branch can be unsafe even when it looks sensible. A truthiness check can narrow away `null` and `undefined`, but it can also discard meaningful falsy values if the domain includes them. That is exactly why the repaired runtime example uses `x != null` rather than `x => !!x`. The runtime test should defend the contract the application actually needs, not the shortest expression that happens to satisfy the compiler in one branch.
import test from 'node:test';
import assert from 'node:assert/strict';
import { filterNonNullish, note } from './main.mjs';
test('note explains runtime behavior versus compile-time narrowing', () => {
assert.match(note, /do NOT prove any TypeScript compile-time narrowing/);
});
test('success: removes null and undefined while preserving falsy values', () => {
const input = [1, null, 2, undefined, 0, '', false, 'ok'];
const output = filterNonNullish(input);
assert.deepEqual(output, [1, 2, 0, '', false, 'ok']);
assert.deepEqual(input, [1, null, 2, undefined, 0, '', false, 'ok']);
});
test('boundary: empty array returns a new empty array', () => {
const output = filterNonNullish([]);
assert.deepEqual(output, []);
assert.notStrictEqual(output, []);
});
test('boundary: all-nullish input produces an empty array', () => {
const output = filterNonNullish([null, undefined, null]);
assert.deepEqual(output, []);
});
test('failure: non-array input throws TypeError', () => {
assert.throws(() => filterNonNullish(null), {
name: 'TypeError',
message: /expects an Array/
});
});
A filter can be runtime-correct without proving the narrowing you expect
TypeScript 5.5 improved inference for some filter callbacks, which is useful but easy to overread. The release notes show that a callback like `x => x !== undefined` may now produce a more precise result type because the compiler can infer a type predicate. That makes the code nicer to consume, but it is still a static feature with specific rules, not a guarantee that every boolean-returning callback will narrow exactly how you imagine.
The release notes also show the failure mode that matters here: truthiness-based filters can hide valid data. If a list contains scores, counts, or measurements, `!!score` removes `0` even though zero may be a legitimate value. That is not merely a type-system quirk; it is a data-loss bug. A runtime test should catch it by asserting the full output array, including the preserved falsy values. The test suite in this article now does that explicitly by checking `0`, `''`, and `false`.
This is the key editorial correction in the revised version: the prose no longer claims more than the tests prove. Instead of implying that the suite covers an abstract truthiness boundary, it now matches the actual runtime contract exercised by the example. If the helper preserves `false`, the test names and assertions should say so. If the helper only promises to preserve numbers and strings, the prose should not mention booleans. The fix is always the same: align the contract text with the observed behavior.
SourcesTypeScript 5.5 Release Notes: Inferred Type Predicates (opens a new tab)
The repaired example separates observed behavior from inferred type
The example module now does one narrow job: it removes nullish entries from an array and returns a new array. It does not mutate the input. It does not promise a special static type transformation in JavaScript itself. It simply uses `Array.isArray` to reject invalid inputs and `x != null` to filter away only `null` and `undefined`. That implementation choice matters because it preserves `0`, the empty string, and `false`, which are all falsy but not nullish.
The test file matches that contract directly. One case checks a mixed array and verifies that the output contains `[1, 2, 0, '', false, 'ok']` while the original input remains unchanged. Another case checks that an empty array stays empty. A third case checks that an all-nullish array becomes empty. A final case checks that non-array input throws a `TypeError`. Those are all runtime observations, and that is exactly what the test runner is supposed to prove.
Notice what the tests do not try to prove. They do not claim to validate TypeScript’s internal narrowing algorithm. They do not pretend that runtime execution can confirm the compiler’s understanding of a later assignment in a different file. They verify the shape of the returned value and the function’s rejection path. That is enough for the runtime contract, and it is more honest than overclaiming static evidence from a JavaScript test.
Use `tsc` or editor diagnostics for the static half of the contract
If a helper is supposed to help the compiler narrow a value, that expectation belongs in a TypeScript check, not in a Node assertion. A small usage example is often enough: call the helper, then pass the result into something that only accepts the narrowed type. If the compiler accepts the call, the static part of the contract holds. If it does not, the runtime behavior may still be correct, but the type story is incomplete.
This split is useful because it keeps failures understandable. A Node failure means the actual JavaScript behavior changed. A TypeScript failure means the compiler can no longer follow the refinement you expected. Those are different repairs. Mixing them into one “test” makes it harder to know whether to change the predicate, the type signature, or the consumer code.
The article’s principle therefore becomes practical guidance: tests should verify runtime contracts, and the compiler should verify inferred types. The two checks overlap enough to be useful, but they are not interchangeable. If you only have one, you only know one half of the story. If you keep both, you can prove the observable behavior and still benefit from narrowing where it exists.
Two edge cases show why the distinction matters in practice
The first edge case is data that legitimately uses falsy values. A table of scores, counts, flags, or user-entered text can contain `0`, `false`, or `''`, and those values must often survive a filter. A truthiness-based guard silently destroys them. A runtime test that only checks array length would miss the bug. A runtime test that checks the exact output array catches it immediately. That is why the revised test suite includes all three preserved falsy values.
The second edge case is version-sensitive inference. TypeScript 5.5 can infer some type predicates for filter callbacks that previous versions could not. That is good news, but it means the static half of the contract can evolve independently of the runtime half. A test suite that tries to encode compiler inference as a JavaScript expectation will be brittle or misleading. A compiler check, on the other hand, can tell you when a callback no longer qualifies for narrowing.
The broader limit is simple: a runtime test can tell you what happened, not why the compiler accepted or rejected a later use site. If the article’s goal is to teach a reliable workflow, the workflow has to reflect that boundary. Check the executed values in tests. Check the type flow with TypeScript. Keep the two answers separate so each one stays precise.


