6 Typed-Untyped Interaction
In the previous sections, all of the examples have consisted of programs that are entirely typed. One of the key features of Typed Racket is that it allows the combination of both typed and untyped code in a single program.
From a static typing perspective, combining typed and untyped code is straightforward. Typed code must declare types for its untyped imports to let the type checker validate their use (Using Untyped Code in Typed Code). Untyped code can freely import bindings from typed code (Using Typed Code in Untyped Code).
At run-time, combining typed and untyped code is complicated because there is a tradeoff between strong type guarantees and the performance cost of checking that untyped code matches the types. Typed Racket provides strong Deep type guarantees by default, but offers two weaker options as well: Shallow and Optional types (Protecting Typed-Untyped Interaction).
6.1 Using Untyped Code in Typed Code
Suppose that we write the untyped module from Quick Start again:
#lang racket (provide (struct-out pt) distance) (struct pt (x y)) ; distance : pt pt -> real (define (distance p1 p2) (sqrt (+ (sqr (- (pt-x p2) (pt-x p1))) (sqr (- (pt-y p2) (pt-y p1))))))
If we want to use the distance function defined in the above module from a typed module, we need to use the require/typed form to import it. Since the untyped module did not specify any types, we need to annotate the imports with types (just like how the example in Quick Start had additional type annotations with :):
Note that a typed module should not use require/typed to import from another typed module. The require form will work in such cases.
#lang typed/racket (require/typed "distance.rkt" [#:struct pt ([x : Real] [y : Real])] [distance (-> pt pt Real)]) (distance (pt 3 5) (pt 7 0))
The require/typed form has several kinds of clauses. The #:struct clause specifies the import of a structure type and allows us to use the structure type as if it were defined with Typed Racket’s struct.
The second clause in the example above specifies that a given binding distance has the given type (-> pt pt Real).
Note that the require/typed form can import bindings from any module, including those that are part of the Racket standard library. For example,
#lang typed/racket (require/typed racket/base [add1 (-> Integer Integer)])
is a valid use of the require/typed form and imports add1 from the racket/base library.
6.1.1 Opaque Types
The #:opaque clause of require/typed defines a new type using a predicate from untyped code. Suppose we have an untyped distance function that uses pairs of numbers as points:
#lang racket (provide point? distance) ; A Point is a (cons real real) (define (point? x) (and (pair? x) (real? (car x)) (real? (cdr x)))) ; distance : Point Point -> real (define (distance p1 p2) (sqrt (+ (sqr (- (car p2) (car p1))) (sqr (- (cdr p2) (cdr p1))))))
A typed module can use #:opaque to define a Point type as all values that the point? predicate returns #t for:
#lang typed/racket (require/typed "distance2.rkt" [#:opaque Point point?] [distance (-> Point Point Real)]) (define p0 : Point (assert (cons 3 5) point?)) (define p1 : Point (assert (cons 7 0) point?)) (distance p0 p1)
6.2 Using Typed Code in Untyped Code
In the previous subsection, we saw that the use of untyped code from typed code requires the use of require/typed. However, the use of code in the other direction (i.e., the use of typed code from untyped code) requires no additional work.
If an untyped module requires a typed module, it will be able to use the bindings defined in the typed module as expected. The major exception to this rule is that macros defined in typed modules may not be used in untyped modules.
6.3 Protecting Typed-Untyped Interaction
One might wonder if the interactions described in the first two subsections are actually safe. After all, untyped code might be able to ignore the errors that Typed Racket’s type system will catch at compile-time.
For example, suppose that we write an untyped module that implements an increment function:
> (module increment racket (provide increment) ; increment : exact-integer? -> exact-integer? (define (increment x) "this is broken"))
and a typed module that uses it:
> (module client typed/racket (require/typed 'increment [increment (-> Integer Integer)]) (increment 5))
This combined program has a problem. All uses of increment in Typed Racket are correct under the assumption that the increment function upholds the (-> Integer Integer) type. Unfortunately, our increment implementation does not actually uphold this assumption, because the function actually produces strings.
By default, Typed Racket establishes contracts wherever typed and untyped code interact to ensure strong types. These contracts can, however, have a non-trivial performance impact. For programs in which these costs are problematic, Typed Racket provides two alternatives. All together, the three options are Deep, Shallow, and Optional types.
Deep types get enforced with rigorous contract checks.
Shallow types get checked in typed code with lightweight assertions called shape checks.
Optional types do not get enforced in any way. They do not ensure safe typed-untyped interactions.
See also: Deep, Shallow, and Optional Semantics in the Typed Racket Reference.
6.3.1 Deep Types: Completely Reliable
When the client program above is run, standard Typed Racket (aka. Deep Typed Racket) enforces the require/typed interface with a contract. This contract detects a failed type assumption when the client calls the untyped increment function:
> (require 'client)
increment: broke its own contract
produced: "this is broken"
in: (-> any/c exact-integer?)
contract from: (interface for increment)
blaming: (interface for increment)
(assuming the contract is correct)
Because the implementation in the untyped module broke the contract by returning a string instead of an integer, the error message blames it.
For general information on Racket’s contract system, see Contracts.
Important caveat: contracts such as the Integer check from above are performant. However, contracts in general can have a non-trivial performance impact, especially with the use of first-class functions or other higher-order data such as vectors.
Note that no contract overhead is ever incurred for uses of typed values from another Deep-typed module.
6.3.2 Shallow Types: Sound Types, Low-Cost Interactions
Changing the module language of the client program from typed/racket to typed/racket/shallow changes the way in which typed-untyped interactions are protected. Instead of contracts, Typed Racket uses shape checks to enforce these Shallow types.
With Shallow types, the client program from above still detects an error when an untyped function returns a string instead of an integer:
> (module client typed/racket/shallow (require/typed 'increment [increment (-> Integer Integer)]) (increment 5)) > (require 'client)
shape-check: value does not match expected type
value: "this is broken"
src: '(eval 2 0 2 1)
The compiled client module has two shape checks in total:
A shape check at the require/typed boundary confirms that increment is a function that expects one argument.
A shape check after the call (increment 5) looks for an integer. This check fails.
Such checks work together within one typed module to enforce the assumptions that it makes about untyped code.
A design guideline for a shape checks is to ensure that a value matches the
top-level constructor of a type.
Shape checks are always yes-or-no predicates (unlike contracts, which may wrap a
value) and typically run in constant time.
Because they ensure the validity of type constructors, shape checks allow Typed
Racket to safely optimize some programs—
Important caveats: (1) The number of shape checks in a module grows in
proportion to its size. For example, every function call in Shallow-typed code
6.3.3 Optional Types: It’s Just Racket
A third option for the client program is to use Optional types, which are provided by the language typed/racket/optional:
> (module client typed/racket/optional (require/typed 'increment [increment (-> Integer Integer)]) (increment 5))
Optional types do not ensure safe typed-untyped interactions. In fact, they do nothing to check types at run-time. A call to the increment function does not raise an error:
> (require 'client)
Optional types cannot detect incorrect type assumptions and therefore do not enable type-driven optimizations. But, they also add no costs to slow a program down. The run-time behavior is very similar to untyped Racket and typed/racket/no-check.
6.3.4 When to Use Deep, Shallow, or Optional?
Deep types maximize the benefits of static checking and type-driven optimizations. Use them for tightly-connected groups of typed modules. Avoid them when untyped, higher-order values frequently cross boundaries into typed code. Expensive boundary types include Vectorof, ->, and Object.
Shallow types are best for small typed modules that frequently interact with untyped code. This is because Shallow shape checks run quickly: constant-time for most types, and linear time (in the size of the type, not the value) for a few exceptions such as U and case->. Avoid Shallow types in large typed modules that frequently call functions or access data structures because these operations may incur shape checks and their net cost may be significant.
Optional types enable the typechecker and nothing else. Use them when you do not want types enforced at run-time.