Compile-Time Contract Tests

The Course
•
5 min read

Runtime tests execute JavaScript after type information is erased. A compile-time contract test instead asks whether valid usage is accepted and known invalid usage is rejected. These tests protect the public type surface when implementations change.

TypeScript’s @ts-expect-error comment expects a diagnostic on the next line. If no error remains, the directive itself becomes an unused-directive error. That makes it useful for a deliberately invalid call in a compile-only test file. @ts-ignore does not provide the same unused-directive signal.

Do not use expected-error comments to patch application code or to make a lesson’s real error disappear. The project’s contracts.ts contains only intentional misuse and is checked by tsc, never executed as a program. The ordinary playground error lab below remains unsuppressed so learners can read the actual diagnostic.

The project does not enable a special framework for type tests. Its normal strict compiler includes contracts.ts. Runtime tests still verify validation, normalization and request ordering; type tests cannot replace them.

Predict, run, and explain

Write the output and trace the important state changes before checking it. Then run the program. Use Check types first; execution is allowed only when the real compiler accepts the source.

Predict, then runTypeScript
type Book = { readonly title: string };

function label(book: Book): string {
  return book.title;
}

console.log(label({ title: "Types" }));

Enable JavaScript to edit and type-check this example.

Check your prediction
Types

Read the compiler error

This is a deliberate negative test without suppression, so the real compiler error stays visible.

Find and fix the type errorTypeScript
type Book = { readonly title: string };

const book: Book = { title: "Types" };

book.title = "Changed";

Enable JavaScript to edit and type-check this example.

Check your diagnosis

Readonly prevents assignment through this typed reference. An application should create a changed copy; a compile-only test can retain the invalid line under an expected-error directive.

Protect caller relationships without executing misuse

A negative type test expects a diagnostic on a minimal invalid line. Keep unrelated mistakes off that line so the intended error cannot disappear while another error continues satisfying the directive. Positive calls are equally important: a signature rejecting every caller is not a useful success.

Compile-only invalid code must not become part of the runtime entry. A never-called function in a small playground can demonstrate checking, while the real project uses a separate contract file. A type test cannot prove trimming, error paths or request order.

Protect caller relationships without executing misuse: traceTypeScript
type Book = { readonly title: string };

function label(book: Book): string {
  return book.title;
}

function negative(): void {
  // @ts-expect-error the caller must provide a string title
  label({ title: 25 });
}

console.log(label({ title: "Types" }));

Enable JavaScript to edit and type-check this example.

Check your prediction
Types

The invalid call is checked but never executed. If label becomes too permissive, the directive must become unused and fail the checker.

Protect caller relationships without executing misuse: contrastTypeScript
type Book = { readonly title: string };

function negative(book: Book): void {
  // @ts-expect-error readonly is part of the caller contract
  book.title = "Changed";
}

const book: Book = { title: "Types" };

console.log(book.title);

Enable JavaScript to edit and type-check this example.

Check your prediction
Types

The example protects assignment permissions separately from runtime freezing. No invalid mutation is executed just to demonstrate the compiler constraint.

Try the rule on new inputs

Temporarily remove readonly and confirm the unused-directive error. Separately remove runtime normalization and confirm only the behavior suite catches it. Explain why neither test layer replaces the other.

Your turn

Exercise

Compile-Time Contract Tests: practise

Create a readonly Book and a label function. Print Types for a valid call. In a local compile-only test file, add a deliberately missing title call preceded by @ts-expect-error. Then weaken the function parameter and confirm the directive becomes unused.

Your workspaceTypeScript
// Keep the runnable example valid. Negative test files are checked, not executed.

Enable JavaScript to edit and type-check this example.

Hint 1: the idea

The negative test should fail if the public signature accidentally accepts an invalid call.

Hint 2: a first step

Use the actual invalid line in the compile-only file, not an unrelated error elsewhere.

Show one solution
One possible solutionTypeScript
type Book = { readonly title: string };

function label(book: Book): string {
  return book.title;
}

console.log(label({ title: "Types" }));

Enable JavaScript to edit and type-check this example.

Types

Explain why the implementation meets the stated contract. Which part is enforced by the language or checker, and which part needs behavioral evidence?

Change it and test it

Inspect the downloaded contracts.ts. Identify its four expected failures. Remove a readonly modifier temporarily and confirm checking fails because its expected error disappeared, then restore it. Never run dist/contracts.js: its invalid operations are not application examples.

Was this article helpful?

Your response is saved on this device.