Course outline

TypeScript / Intermediate / Lesson 12

Testing types and runtime behavior

Test static contracts and actual JavaScript outcomes separately, then preserve small reproducible fixtures for configuration and compiler changes.

About 5 min readContent reviewed
On this page

Lesson goals

  • Separate compile acceptance, expected diagnostics, and runtime assertions.
  • Build boundary-oriented tests without reproducing implementation.
  • Verify public entry points and emitted artifacts as consumers.

A type test is not a runtime test

A compile-only test checks whether a call or assignment is accepted under specific compiler options. It cannot show that a validator rejects bad data or that a package loads in Node. A runtime test exercises emitted JavaScript and can detect actual exceptions, mutation, serialization, and boundary behavior. Both are needed when an API promises a type relationship and an implementation result. Keep the compiler version and configuration in test evidence. A passing editor check with different options is not interchangeable with the project check used by continuous integration. Separate intentional rejection examples from independently runnable programs.

Check boundaries without mirroring the formula

The first complete harness checks a batch calculation through observable results. Its runtime guard accepts nonnegative safe integer counts and positive safe integer widths. Quotient and remainder avoid an unnecessary count + width - 1 expression that can lose integer precision near the numeric limit. Expected invalid input returns undefined instead of throwing accidentally during arithmetic. The small harness throws only when a test expectation fails and prints success after all cases pass. A production suite can use an approved test runner, but this standard-runtime example needs no extra package or global configuration.

typescript A complete dependency-free contract harness
export {};
function batches(count: number, width: number): number | undefined {
  if (!Number.isSafeInteger(count) || count < 0 || !Number.isSafeInteger(width) || width <= 0) { return undefined; }
  return Math.floor(count / width) + (count % width === 0 ? 0 : 1);
}
function expect(condition: boolean): void { if (!condition) { throw new Error("test failed"); } }
expect(batches(0, 4) === 0);
expect(batches(8, 4) === 2);
expect(batches(9, 4) === 3);
expect(batches(2, 0) === undefined);
console.log("4 cases passed");
Expected output
4 cases passed

The checks cover empty work, exact division, rounding up, and rejection. A failed case prevents the success line.

Test rejected values as deliberately as accepted ones

The second harness tests a runtime guard with values whose static type is unknown. It checks a valid integer, zero, and text containing digits. This catches the mistake of assuming a number-looking string satisfies a numeric domain. Add malformed objects and extreme values for more complex validators. Testing only valid fixtures can leave an always-true predicate undetected. A type predicate tells the checker what the implementation claims; only implementation review and suitable runtime tests establish whether that claim is justified. Keep the accepted-domain requirement separate from incidental JSON formatting or console output.

typescript Unknown inputs with expected acceptance outcomes
export {};
function positiveInteger(value: unknown): value is number {
  return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
}
const cases: readonly (readonly [unknown, boolean])[] = [[1, true], [0, false], ["1", false]];
for (const [input, wanted] of cases) {
  if (positiveInteger(input) !== wanted) { throw new Error("guard mismatch"); }
}
console.log("3 guards passed");
Expected output
3 guards passed

Only the numeric positive integer is accepted. The test never assumes that the static unknown shape is already valid.

Keep intentional errors narrow and checked

An expected diagnostic fixture should identify the invalid relationship being tested and the exact compiler baseline. @ts-expect-error can protect an intentional rejected line by reporting when it no longer produces an error, but it does not by itself guarantee that the diagnostic was the intended one. Keep one small cause per fixture and inspect diagnostic codes where the test harness supports it. @ts-ignore merely suppresses and can hide that a defect disappeared. Neither directive belongs in a runtime validation fix. Complete published examples should compile cleanly; intentionally rejected snippets must be labeled non-runnable and never assigned fabricated output.

Test the artifact and configuration people actually use

Package tests should exercise emitted JavaScript and published declarations from a separate consumer, including the selected ESM or CommonJS entry. A source-relative import can bypass exports and conceal a broken release package. Configuration regressions need a small fixture with the same tsconfig, package metadata, files, and commands. Run focused checks after a repair and broaden only when a new concern warrants it. Test false positives and false negatives in validators, not only compiler acceptance. Keep untrusted input synthetic and avoid external services in these labs, so failures remain reproducible and cannot affect configured production resources.

Common errors and fixes

A type-only suite misses a broken runtime parser.

Why it happens
Compile acceptance was treated as value validation.
How to fix it
Add malformed-input runtime cases.
Prevent it next time
Maintain both static and runtime evidence.

A suppression hides the wrong diagnostic.

Why it happens
The fixture contains several independent errors.
How to fix it
Reduce to one cause and inspect expected codes.
Prevent it next time
Keep rejection tests isolated and versioned.

A package works from source but not after publication.

Why it happens
Tests bypassed emitted paths or package exports.
How to fix it
Test a packed artifact through a separate consumer.
Prevent it next time
Include entry-point loading in release checks.

Check your understanding

1. Add batches(1, 4) and batches(2.5, 4). State their expected results and the distinct defects each catches.

Show worked solution

The first returns one and catches an implementation that always rounds down. The second returns undefined and catches missing integer validation. Neither test needs to duplicate the implementation formula. Add a maximum accepted count case when the application supports large values, and verify its result is still a safe integer under the chosen arithmetic policy.

2. A custom validator always returns true but has a convincing type predicate signature. Explain a minimal test set that reveals the defect.

Show worked solution

Test at least one valid value and several invalid categories such as null, a wrong primitive, a missing required property, and an invalid field value. Assert rejection for the negative cases. The type predicate signature does not execute those checks or prove their completeness, so inspecting only inferred types cannot reveal the always-true implementation.

Key takeaways

  • Type tests and runtime tests protect different promises.
  • Use negative fixtures and exact configuration.
  • Verify artifacts from the consumer boundary.

Official references