8 Deep, Shallow, and Optional Semantics
#lang typed/racket/deep | package: typed-racket-lib |
#lang typed/racket/base/deep | package: typed-racket-lib |
#lang typed/racket/shallow | package: typed-racket-lib |
#lang typed/racket/base/shallow | package: typed-racket-lib |
#lang typed/racket/optional | package: typed-racket-lib |
#lang typed/racket/base/optional | package: typed-racket-lib |
See also: Typed-Untyped Interaction in the Typed Racket Guide.
Allowing typed/untyped combinations raises questions about whether and how types should constrain the behavior of untyped code. On one hand, strong type constraints are useful because they can detect when a typed-untyped interaction goes wrong. On the other hand, constraints must be enforced with run-time checks, which affect run-time performance. Stronger constraints generally impose a higher performance cost.
By default, Typed Racket provides Deep types that strictly constrain the behavior of untyped code. But because these constraints can be expensive, Typed Racket offers two alternatives: Shallow and Optional types. All three use the same static types and static checks, but they progressively weaken the run-time behavior of types.
Deep types enforce strong, compositional guarantees. If a value is annotated with a Deep type, then all of its interactions with other code must match the type. For example, a value with the type (Listof String) must be a list that contains only strings; otherwise, Typed Racket raises an error.
Available in: typed/racket, typed/racket/base, typed/racket/deep, and typed/racket/base/deep.
Shallow types enforce the outer shape of values. For example, the Shallow type (Listof String) checks only for lists —
it does not check whether the list elements are strings. This enforcement may seem weak at first glance, but Shallow types can work together to provide a decent safety net. If Shallow-typed code gets an element from a list and expects a String, then another check will make sure the element is really a string. Available in: typed/racket/shallow, and typed/racket/base/shallow.
Optional types enforce nothing and add zero run-time cost. These types are useful for finding bugs in typed code at compile-time, but they cannot detect interaction errors at run-time.
Available in: typed/racket/optional, and typed/racket/base/optional.
8.1 Example Interactions
The examples below show how Deep, Shallow, and Optional change the run-time behavior (or, the semantics) of types.
8.1.1 Checking Immutable Data: Importing a List
Deep types check each element of the list at the boundary to untyped code;
Shallow types check for a list, and check elements when they are accessed; and
Optional types check nothing.
The following examples import the function string->list, which returns a list of characters, and use an incorrect type that expects a list of strings. Both Deep and Shallow types catch the error at some point. Optional types do not catch the error.
Deep types prevent a list of characters from entering typed code with the type (Listof String):
#lang typed/racket ; or #lang typed/racket/deep (require/typed racket/base [string->list (-> String (Listof String))])
(string->list "racket") string->list: broke its own contract
promised: string?
produced: #\r
in: an element of
the range of
(-> any/c (listof string?))
contract from: (interface for string->list)
blaming: (interface for string->list)
(assuming the contract is correct)
at: eval:1:0
Shallow types allow a list of characters to have the type (Listof String), but detect an error if typed code reads an element from the list:
#lang typed/racket/shallow (require/typed racket/base [string->list (-> String (Listof String))]) (define lst (string->list "racket"))
(first lst) shape-check: value does not match expected type
value: #\r
type: String
lang: 'typed/racket/shallow
src: '(eval 3 0 3 1)
Optional types do not detect any error in this example:
#lang typed/racket/optional (require/typed racket/base [string->list (-> String (Listof String))]) (define lst (string->list "racket"))
(first lst) - : String
#\r
8.1.2 Checking Mutable Data: Importing a Vector
Deep types wrap the vector in a contract that checks future reads and writes;
Shallow types check for a vector at the boundary, and check elements on demand (same as for lists); and
Optional types check nothing.
The following example imports make-vector with an incorrect type that expects a vector of strings as its output. When make-vector returns a vector of numbers instead, both Deep and Shallow types catch the error when reading from the vector. Optional types do not catch the error.
#lang typed/racket ; or #lang typed/racket/deep (require/typed racket/base [make-vector (-> Integer (Vectorof String))]) (define vec (make-vector 10))
(vector-ref vec 0) make-vector: broke its own contract
promised: string?
produced: 0
in: an element of
the range of
(-> any/c (vectorof string?))
contract from: (interface for make-vector)
blaming: (interface for make-vector)
(assuming the contract is correct)
at: eval:3:0
#lang typed/racket/shallow (require/typed racket/base [make-vector (-> Integer (Vectorof String))]) (define vec (make-vector 10))
(vector-ref vec 0) shape-check: value does not match expected type
value: 0
type: String
lang: 'typed/racket/shallow
src: '(eval 6 0 6 1)
#lang typed/racket/optional (require/typed racket/base [make-vector (-> Integer (Vectorof String))]) (define vec (make-vector 10))
(vector-ref vec 0) - : String
0
8.1.3 Checking Functions that Cross Multiple Boundaries
Deep types can detect some errors that Shallow types miss, especially when a program contains several modules. This is because every module in a program can trust that every Deep type is a true claim, but only the one module that defines a Shallow type can depend on the type. In short, Deep types are permanent whereas Shallow types are temporary.
The following example uses three modules to create a situation where Deep types catch an error that Shallow types miss. First, the untyped module racket/base provides the standard string-length function. Second, a typed interface module imports string-length with an incorrect type and reprovides with a new name: strlen. Third, a typed client module imports strlen with a correct type and calls it on a string.
Deep types raise an error when strlen is called because of the incorrect type in the interface:
#lang typed/racket ; or #lang typed/racket/deep (module interface typed/racket (require/typed racket/base [string-length (-> String Void)]) (define strlen string-length) (provide strlen)) (require/typed 'interface [strlen (-> String Natural)])
(strlen "racket") string-length: broke its own contract
promised: void?
produced: 6
in: (-> any/c void?)
contract from: (interface for string-length)
blaming: (interface for string-length)
(assuming the contract is correct)
at: eval:6:0
Shallow types do not raise an error because the interface type is not enforced for the outer client module:
#lang typed/racket/shallow (module interface typed/racket/shallow (require/typed racket/base [string-length (-> String Void)]) (define strlen string-length) (provide strlen)) (require/typed 'interface [strlen (-> String Natural)])
(strlen "racket") - : Integer [more precisely: Nonnegative-Integer]
6
Optional types do not raise an error either:
#lang typed/racket/optional (module interface typed/racket/optional (require/typed racket/base [string-length (-> String Void)]) (define strlen string-length) (provide strlen)) (require/typed 'interface [strlen (-> String Natural)])
(strlen "racket") - : Integer [more precisely: Nonnegative-Integer]
6
8.2 Forms that Depend on the Behavior of Types
The following Typed Racket forms use types to create run-time checks. Consequently, their behavior changes depending on whether types are Deep, Shallow, or Optional.
Across these forms, the changes are roughly the same. Deep types get enforced as (higher-order) contracts, Shallow types get enforced as shape checks, and Optional types get enforced with nothing. The key point to understand is which types get enforced at run-time.
require/typed imports bindings from another module and attaches types to the bindings. The attached types get enforced.
cast assigns a type to an expression. The assigned type gets enforced.
with-type creates a typed region in untyped code. Types at the boundary between this region and untyped code get enforced.
The following forms modify the contracts that Deep Typed Racket generates. Uses of these forms may need to change to accommodate Shallow and Optional clients.
require/untyped-contract brings an identifier from Deep-typed code to untyped code using a subtype of its actual type. If the required identifier travels from untyped code to a Shallow or Optional client, this client must work with the subtype. A Deep client would be able to use the normal type.
define-typed/untyped-identifier accepts four identifiers to fine-tune its behavior for Deep, untyped, Shallow, and Optional clients.
8.2.1 Example: Casts in Deep, Shallow, and Optional
To give one example of a form that depends on the behavior of types, cast checks full types in Deep mode, checks shapes in Shallow mode, and checks nothing in Optional mode.
; #lang typed/racket ; or #lang typed/racket/deep > (cast (list 42) (Listof String)) (cast for #f): broke its own contract
promised: string?
produced: 42
in: an element of
(listof string?)
contract from: cast
blaming: cast
(assuming the contract is correct)
at: eval:9:0
; #lang typed/racket/shallow > (cast (list 42) (Listof String)) - : (Listof String)
'(42)
> (cast (list 42) Number) shape-check: value does not match expected type
value: '(42)
type: Number
lang: 'typed/racket/shallow
src: '(eval 11 0 11 1)
; #lang typed/racket/optional > (cast (list 42) (Listof String)) - : (Listof String)
'(42)
> (cast (list 42) Number) - : Number
'(42)
8.3 How to Choose Between Deep, Shallow, and Optional
Deep, Shallow, and Optional types have complementary strengths and weaknesses. Deep types give strong type guarantees and enable full type-directed optimizations, but may pay a high cost at boundaries. In particular, the costs for higher-order types are high. Examples include HashTable, ->*, and Object. Shallow types give weak guarantees, but come at a lower cost. The cost is constant-time for many types, including HashTable and ->*, and linear-time for a few others such as U and Object. Optional types give no guarantees, but come at no cost.
Based on these tradeoffs, this section offers some advice about when to choose one style over the others.
8.3.1 When to Use Deep Types
Deep types are best in the following situations:
For large blocks of typed code, to take full advantage of type-directed optimizations within each block.
For tightly-connected groups of typed modules, because Deep types pay no cost to interact with one another.
For modules in which you want the types to be fully enforced, perhaps for predicting the behavior of typed-untyped interactions or for debugging.
8.3.2 When to Use Shallow Types
Shallow types are best in the following situations:
For typed code that frequently interacts with untyped code, especially when it sends large immutable values or higher-order values (vectors, functions, etc.) across boundaries.
For large blocks of typed code that primarily uses basic values (numbers, strings, etc.) or monomorphic data structures. In such cases, Shallow types get the full benefit of type-directed optimizations and few run-time costs.
For boundaries where Deep enforcement (via contracts) is too restrictive. For example, Deep code can never call a function that has the type Procedure, but Shallow can after a cast.
For boundaries where Deep cannot convert the types to contracts, such as for a higher-order syntax object such as (Syntaxof (Boxof Real)).
8.3.3 When to Use Optional Types
Optional types are best in the following situations:
For typed-to-untyped migrations where performance needs to be predictable, because an Optionally-typed program behaves just like a Racket program that ignores all the types.
For boundaries that neither Deep nor Shallow can express. For example, only Optional can use occurrence types at a boundary.
For prototyping; that is, for testing whether an idea can type-check without testing whether it interacts well with untyped code.
8.3.4 General Tips
Deep, Shallow, and Optional use the same compile-time type checks, so switching a module from one style to another is usually a one-line change (to the #lang line).
When converting a Racket program to Typed Racket, try Deep types at first and change to Shallow if run-time performance becomes a bottleneck (or, if contract wrappers raise a correctness issue).
8.4 Related Gradual Typing Work
Shallow Typed Racket implements the Transient semantics for gradual languages [Programming-2022, PLDI-2022], which was invented by Michael M. Vitousek [RP:DLS-2014, RP:POPL-2017, RP:Vitousek-2019, RP:DLS-2019]. Transient protects typed code by rewriting it to defensively check the shape of values whenever it calls a function, reads from a data structure, or otherwise receives input that may have come from an untyped source. Because of the rewriting, Transient is able to enforce type soundness without higher-order contracts.
Deep Typed Racket implements the standard semantics for gradual languages, which is known variously as Guarded [RP:POPL-2017], Natural [TOPLAS-2009], and Behavioral [KafKa-2018]. This Guarded semantics eagerly checks untyped values when possible and otherwise creates wrappers to defer checks.
Typed Racket uses the names “Shallow” and “Deep” rather than “Transient” and “Guarded” to emphasize the guarantees that such types provide instead than the method used to implement these guarantees. Shallow types provide a type soundness guarantee; Deep types provide type soundness and complete monitoring [OOPSLA-2019].
Optional types are a widely-used approach to gradual typing, despite their unsound support for typed-untyped interactions. Optionally-typed languages include the following: TypeScript, Flow, mypy, and Typed Clojure [ESOP-2016, Bonnaire-Sergeant-2019].