Managing API Boundaries with Racket Contracts
Learn how to use Racket contracts at module boundaries to enforce API shapes, assign blame accurately, and build more reliable libraries using provide/contract.
21 Aug 2026, 11:54 UTC

You ship a library, and a user calls your function with the wrong argument type. The resulting error surfaces three calls deep inside your implementation, producing a stack trace that points at your internal logic rather than the user's mistake. This is the "boundary problem": the failure to distinguish between a bug in the provider and a misuse by the consumer.
The solution is to treat contracts as the public interface of a module rather than as inline assertions. Racket's contract system allows you to publish a checked API using provide/contract and contract-out, ensuring that checks live at the module boundary. This assigns "blame" to the party that broke the promise, making debugging significantly faster.
Defining Boundaries with provide/contract
Racket distinguishes between flat contracts, which check a value once, and higher-order contracts, which wrap functions to check arguments and results on every call. By integrating these into the module system, you can enforce specifications without cluttering your core logic.
The provide/contract form is used in the defining module to export a name alongside a contract. The consumer interacts only with the wrapped value. If the client provides an invalid argument, the contract wrapper intercepts the call and assigns blame to the client. If the function returns a value that violates the contract, blame is assigned to the provider.
A critical engineering detail is the placement of the contract. Placing a contract on the wrong side of the provide/require boundary can invert blame, leading the user to believe your library is broken when they are actually passing invalid data.
Composing Precise API Shapes
Contracts are composable, allowing you to build complex specifications from simple predicates. Common building blocks include:
->: Defines a function's domain (inputs) and range (output).->*: Handles functions with variable arity.and/candor/c: Intersect or union multiple predicates.struct/c: Validates the shape of a structure.
While higher-order contracts provide safety, they incur a runtime cost due to the allocation of wrappers and the overhead of checking every call. For performance-sensitive paths, such as tight numeric loops, these checks can be a bottleneck. In production environments, developers may use selective elision or run with racket --no-contracts to recover performance, though this removes the safety net.
Worked Example: A Parser API
Consider a parser that must receive a non-empty string and return a list of symbols. The following implementation ensures both the input shape and the output result are validated at the boundary.
#lang racket
(require racket/contract)
;; Define a predicate for non-empty strings
(define (non-empty-string? s)
(and (string? s) (not (string-empty? s))))
(provide/contract
[parse (-> (and/c string? non-empty-string?) (listof symbol?))])
;; Internal implementation remains unchecked for performance
(define (parse s)
(map string->symbol (string-split s)))In this example, (and/c string? non-empty-string?) ensures the input is both a string and not empty. If a client passes a number or an empty string, the contract system raises a blame error pointing to the client before parse is even executed. If the internal map logic were to accidentally return a list of numbers, the contract would catch the violation and blame the provider.
To verify this, run the code in a Racket REPL. Call (parse "") to see a client-side blame error, and call (parse 123) to see a type-mismatch blame error. No special permissions are required. The primary risk is wrapping the same function multiple times, which can lead to nested blame reports that are difficult to parse.
Trade-offs and Limitations
Contracts validate shapes and types, not deep semantics. A contract can ensure a function returns a (listof symbol?), but it cannot prove that the symbols are the correct symbols for a specific input. For logical invariants, property-based testing is still required.
Additionally, because contracts wrap functions, they can occasionally obscure the original stack trace. To mitigate this, keep contracts strictly at the module boundary. A practical way to check your implementation is to create a small test suite that deliberately violates the domain (input) and range (output) to confirm that the blame is assigned to the correct party.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.