Racket Contracts: Catching Boundary Bugs Where They Actually Happen
Racket's contract system checks values at module boundaries and blames the guilty module when something breaks. Here's how to use contracts well — and where to leave them out.
15 Dec 2025, 02:57 UTC

Your Racket program is a dozen modules, and something deep in the pipeline just crashed with car: contract violation; expected: pair?. The actual mistake — a client module passing a string where an integer was expected — happened three calls ago. You now get to spelunk backward through stack frames to find it. This is the exact problem Racket's contract system was built to solve, and the takeaway is simple: attach contracts at module boundaries, and Racket will tell you which module broke the agreement, the moment it happens.
Contracts live at the boundary, not inside the module
The key design decision in racket/contract is that checks fire when a value crosses a module boundary, not on every internal call. You attach a contract in the provide line:
#lang racket
(provide (contract-out
[discount (-> integer? integer?)]))
(define (discount cents)
(quotient (* cents 90) 100))Inside this module, discount runs unguarded. Only callers in other modules trigger the check. That's a deliberate engineering trade: you pay checking cost exactly where trust is lowest — the public API — and nowhere else.
Blame is the whole point
A contract like (-> integer? integer?) is higher-order: you can't know whether a function honors it until someone calls it. So Racket wraps the function and watches each application. When a violation surfaces, the error message names the responsible party — the module that supplied the bad value or returned the bad result.
Concretely, if a client module does this:
#lang racket
(require "pricing.rkt")
(discount "not-a-number")Racket reports a contract violation blaming the client module, identifying discount and the offending argument. Compare that to the untyped alternative, where quotient fails deep inside pricing.rkt and the blame trail points at the wrong file. For a team, this turns "who broke this?" archaeology into a one-line answer. (Exact blame message formatting varies by Racket version; verify against your installed docs.)
Composing contracts for real data shapes
Contracts compose, which is where they become genuinely useful as executable documentation:
(provide (contract-out
[lookup-all (-> string? (listof integer?))]
[config (hash/c symbol? string?)]))listof and hash/c check structured data; simple predicates like integer? are flat contracts checked immediately. For invariants predicates can't express — say, "output is no larger than input" — dependent contracts let the result contract reference the arguments:
[clamp (->i ([lo real?] [hi real?] [x real?])
#:pre (lo hi) (<= lo hi)
[result (lo hi) (and/c real? (>=/c lo) (<=/c hi))])]Anyone reading the provide line now knows the intended shape of the API without opening the implementation, and DrRacket surfaces the same information.
Contracts vs. types vs. tests
These three tools overlap less than people assume. Typed Racket proves properties statically, but at the boundary with untyped code it can't — so Typed Racket itself generates contracts to protect typed modules from untyped callers. Contracts are the enforcement mechanism for values in motion; types are proofs about values at rest. Tests, meanwhile, check behavior (does the algorithm compute the right thing?), while contracts check shape (did the right kind of thing cross the boundary?). A function can satisfy (-> integer? integer?) and still return the wrong integer.
The cost, and where to draw the line
Contract checking is real runtime work. Flat contracts are cheap; higher-order wrappers add indirection per call; recursive contracts like (listof integer?) over large lists walk the whole structure. The standard engineering decision — and the one I'd recommend as a default — is:
- Contract every
provided function and exported struct. - Leave internal helpers and hot loops uncontracted.
- If a boundary contract shows up in a profile, consider checking a cheaper approximation (e.g.,
list?instead of(listof integer?)) and documenting the compromise.
You can measure the overhead yourself with Racket's time form: run a contracted and uncontracted version of a function over a large list and compare. Don't assume the cost is negligible — or that it's prohibitive — without measuring on your workload.
Try it this week
Pick one module whose consumers keep misusing it, add contract-out to its provide line, and run your test suite. Two things tend to happen: a latent bug surfaces with a blame message pointing at the guilty module, and the provide line becomes the best documentation the module ever had. Start with flat and simple higher-order contracts, reach for listof/hash/c when shapes matter, and keep the hot paths clean.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.