Adding Racket Contracts to Module Boundaries for Runtime Type Safety
Learn how to add Racket contracts to module boundaries using contract-out and -> combinators, catch type violations at runtime, and handle performance trade‑offs.
16 Jul 2025, 03:49 UTC

Desired Outcome
Detect invalid arguments and return values across module boundaries using Racket's contract system without changing core logic. Contracts act as guarded borders: when a function is called with a value that doesn't match its contract, Racket raises exn:fail:contract at the boundary, pinpointing the caller or callee responsible.
Prerequisites
- Racket installed (tested with Racket 8.12+; older 8.x versions behave similarly).
- A project with at least two modules: one that exports functions (the provider) and one that imports them (the consumer).
- Familiarity with
require,provide, and basic module structure.
Procedure
1. Define the provider module with contracts
In math-utils.rkt, wrap exported functions with contract-out and use the -> contract combinator to specify argument and result contracts.
#lang racket
(provide (contract-out
[add (-> integer? integer? integer?)]
[concat (-> string? string? string?)]
[sum-list (-> (listof integer?) integer?)]))
(define (add x y) (+ x y))
(define (concat a b) (string-append a b))
(define (sum-list lst) (apply + lst))contract-out replaces a plain provide. Each entry pairs an identifier with a contract expression. The -> form builds a function contract: the first n arguments are domain contracts, the last is the range contract.
2. Import the module normally in the consumer
In main.rkt, require the provider as usual. No extra syntax is needed; contracts are enforced automatically on every call.
#lang racket
(require "math-utils.rkt")
(displayln (add 3 4)) ; valid
(displayln (concat "hi" "there")) ; valid
(displayln (sum-list '(1 2 3))) ; valid
;; The following calls will raise exn:fail:contract
; (add "oops" 5)
; (concat 1 2)
; (sum-list '(1 2 "three"))3. Run the consumer to see contract violations
Execute racket main.rkt. Valid calls print results. Uncomment an invalid call to observe the exception:
racket main.rkt
; Output for valid calls:
; 7
; hithere
; 6
;
; If you uncomment (add "oops" 5):
; add: contract violation
; expected: integer?
; given: "oops"
; in: the 1st argument of
; (-> integer? integer? integer?)
; contract from: "math-utils.rkt"
; blaming: top-level
; (assuming the contract is correct)
; at: main.rkt:8.2Expected Checks
- Call each exported function with valid arguments; confirm normal return values and no contract exceptions.
- Call each exported function with deliberately wrong types (e.g., a string where an integer is expected); confirm
exn:fail:contractis raised and the error message identifies the violating argument and the module boundary. - Inspect the stack trace: the
blamingline indicates whether the caller (consumer) or the callee (provider) is at fault. For argument contracts, the caller is blamed; for result contracts, the provider is blamed.
Recovery Options
Disable contracts for a test build
If contracts add too much overhead in a performance‑critical test run, export the raw functions alongside the contracted ones:
(provide (contract-out
[add (-> integer? integer? integer?)]
[concat (-> string? string? string?)]
[sum-list (-> (listof integer?) integer?)])
;; raw exports for internal use
add concat sum-list)Internal modules can (require (only-in "math-utils.rkt" add concat sum-list)) to get the unchecked versions.
Catch and log boundary violations
Wrap calls in with-handlers to convert contract exceptions into logged data without crashing:
(with-handlers ([exn:fail:contract?
(lambda (e)
(log-error "Contract violation" (exn-message e))
#f)]) ; return a sentinel
(add user-input 10))Limitations and Performance Considerations
- Runtime overhead: Every boundary call checks contracts. Avoid wrapping hot inner‑loop functions unless profiling shows acceptable cost. For tight loops, consider moving the loop inside the contracted module so only the loop entry is checked.
- Blame direction: Argument contracts blame the caller; result contracts blame the callee. Misreading the blame can lead to fixing the wrong side. Always read the
blaming:line in the error. - Higher‑order and recursive contracts: Contracts like
(-> (-> integer? integer?) integer?)or recursive structures ((listof (listof integer?))) produce verbose, sometimes confusing messages. Start with first‑order data contracts (integer?,string?,(listof integer?)) and add complexity only when needed.
Verification Steps
- Create a test script
test-contracts.rktthat imports the contracted module and runs both valid and invalid calls. - Run
raco test test-contracts.rktorracket test-contracts.rkt. The test should pass for valid calls and fail withexn:fail:contractfor invalid ones. - Confirm the REPL stack trace shows the contract violation at the expected module boundary (e.g.,
math-utils.rktvsmain.rkt).
If all three checks succeed, contracts are correctly enforcing your module interfaces.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.