Racket Contracts: Catching Integration Bugs at the Module Boundary
Racket's contract system turns unstated assumptions between modules into runtime-checked specifications — and when one fails, blame assignment tells you exactly which side broke the deal.
18 Sept 2026, 19:51 UTC

The most expensive bugs in a growing codebase are rarely inside a function — they're between functions. A module assumes a percentage is a fraction between 0 and 1; a caller passes 25. A module promises a sorted list; a consumer gets whatever order the database felt like returning. Racket's contract system exists precisely for this seam: it lets you attach executable specifications to module exports, and when something breaks, it tells you which side broke the deal.
What a contract actually is
In Racket, a contract is an ordinary value that describes what a piece of data or a function may do. You attach contracts to exports with provide/contract or to definitions with define/contract. From then on, every time another module uses that export, Racket checks the contract at the boundary — at runtime, at the exact point where the two components meet.
This is different from a type system. Contracts check dynamically, so they work on untyped code, and they can express things most type systems can't: "a positive number," "a list of at least three strings," "a function whose result is larger than its argument." Contracts also compose. You build them from predicates and combinators like and/c, or/c, listof, and between/c.
A worked example: the discount function
Here's a small module exporting a pricing function with a contract that encodes the real business rule: prices must be positive, and percentages are fractions, not whole numbers.
#lang racket
(define/contract (discount price pct)
(-> (and/c number? positive?) ; price must be a positive number
(between/c 0 1) ; pct is a fraction, not 25 for 25%
number?) ; result is a number
(* price (- 1 pct)))
(provide discount)Now, from another module or the REPL, call it the way a confused caller eventually will:
(discount 100 25)Instead of silently returning -2400 and corrupting an invoice three functions later, Racket raises a contract error immediately. The message identifies the failing contract ((between/c 0 1)), the offending value (25), and — critically — blames the caller, not the discount function. You can run this in DrRacket or with racket on the command line; no special permissions or setup beyond a Racket installation are needed.
Blame assignment is the real feature
Every contract has two parties: the module providing the value and the module consuming it. When a check fails, Racket names the responsible party in the error (an exn:fail:contract:blame exception). If the caller passes a bad argument, the caller is blamed. If your function returns a nonsense result, your module is blamed.
This sounds like a small thing until you've debugged a generic "contract violation" or a NullPointerException-style failure in a large system, where the error surfaces far from the mistake. Blame turns "something somewhere is wrong" into "module A handed module B a value B was promised it would never see." For integration bugs — the exact class contracts target — this routinely cuts debugging time from hours to minutes.
You can verify blame behavior yourself: write the module above, call it with a violating argument from a second module, and confirm the raised exception names the caller as the responsible party. Blame wording varies slightly across Racket versions, so check against the version you deploy (run (version) in a REPL).
Higher-order contracts and lazy checking
Contracts on functions — like (-> integer? integer?) — can't be checked eagerly. There's no way to know at export time what a function will return for every possible input. So Racket wraps the function in a proxy and checks arguments and results at each call site, when the values actually exist.
This lazy checking is a deliberate design point, not a limitation to apologize for, but it has a consequence worth knowing: the error surfaces at the use of the value, which may be later than the moment the bad value was produced. For flat data contracts (numbers, strings, lists), checking is immediate. For function and mutable-data contracts, the wrapper defers judgment. When reading a blame report, keep in mind you're seeing where the violation was detected, and the blame assignment is what points you back to who actually caused it.
The trade-off: runtime cost
Contracts are runtime checks, and they cost cycles. Flat contracts on scalar values are cheap. Higher-order contracts add wrapper overhead per call. Deep structural contracts — say, (listof (listof (and/c number? positive?))) on a large matrix — re-traverse data and can get genuinely expensive on hot paths.
Practical guidance:
- Put contracts on module boundaries, not on every internal helper. The boundary is where integration bugs live and where the checking cost is paid once per crossing rather than per inner-loop iteration.
- Be suspicious of expensive contracts in tight loops. If profiling shows contract overhead, simplify the contract or move it outward.
- Benchmark your own workload rather than trusting general claims: provide the same function with and without
provide/contractand compare timings on realistic data. - Don't treat contracts as a correctness proof. Flat contracts on mutable structures can miss aliasing-related misuse, and a contract only checks what you wrote down.
Contracts and Typed Racket are complements
If you're using Typed Racket, contracts don't become redundant — they become automatic. When typed and untyped modules interact, Racket generates contracts at the boundary from the types, so untyped callers can't smuggle bad values into checked code. Contracts handle the dynamic, expressive, boundary-checking role; the type system handles static guarantees inside typed code. Many teams use contracts alone on untyped code and get most of the integration-bug benefit without a typing migration.
Where to start
Pick one module whose misuse has bitten you — a function with an unstated precondition about units, ranges, or ordering — and write that assumption as a contract with define/contract. Then deliberately violate it from a REPL and read the blame. That five-minute exercise shows you the whole value proposition: the assumption becomes executable, the failure becomes immediate, and the error message points at the guilty party. Expand from there to your most-crossed module boundaries, and keep an eye on the profiler before you contract anything in a hot loop.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.