1.7 Datatypes🔗ℹ

So far, we have only seen built-in types like Number and (Listof String). Sometimes, it’s useful to define your own name as a shorthand for a type, such as defining Groceries to be equivalent to (Listof String):

(define-type-alias Groceries (Listof String))
(define shopping-list : Groceries '("milk" "cookies"))

Note that, by convention, all type names are capitalized. Plait is case-sensitive.

But what if the data that you need to represent is not easily encoded in existing types, such as when you need to keep track of a tiger’s color and stripe count (which doesn’t work as a list, since a list can’t have a number and a string)? And what if your type, say Animal, has values of different shapes: tigers that have color and stripe counts, plus snakes that have a color, weight, and favorite food?

The define-type form handles those generalizations. The general form is

(define-type Type
  (variant-name_1 [field-name_1 : Type_1]
                  [field-name_2 : Type_2]
                  ...)
  (variant-name_2 [field-name_3 : Type_3]
                  [field-name_4 : Type_4]
                  ...)
  ...)

with any number of variants and where each variant has any number of typed fields. If you’re used to Java-style classes, you can think of Type as an interface, and each variant is a class that implements the interface. Unlike Java classes, a variant name doesn’t work as a type name; it only works to create an instance of the variant.

For example, the following definition is suitable for representing animals that can be either tigers or snakes:

(define-type Animal
  (tiger [color : Symbol]
         [stripe-count : Number])
  (snake [color : Symbol]
         [weight : Number]
         [food : String]))

After this definition, Animal can be used as a type, while tiger and snake work as functions to create Animals:

> (tiger 'orange 12)

- Animal

(tiger 'orange 12)

> (snake 'green 10 "rats")

- Animal

(snake 'green 10 "rats")

The definition of Animal creates several additional functions:

The name tiger? was formed by adding a ? to the end of the variant name tiger, tiger-color is formed by adding a - between the variant name and field name, and so on.

> (define tony (tiger 'orange 12))
> (define slimey (snake 'green 10 "rats"))
> (tiger? tony)

- Boolean

#t

> (tiger? slimey)

- Boolean

#f

> (tiger-color tony)

- Symbol

'orange

> (snake-food slimey)

- String

"rats"

> (tiger-color slimey)

- Symbol

tiger-color: contract violation

  expected: tiger?

  given: (snake 'green 10 "rats")

  in: the 1st argument of

      (->

       tiger?

       (or/c

        undefined?

        ...pkgs/plait/main.rkt:1013:41))

  contract from: tiger-color

  blaming: use

   (assuming the contract is correct)

  at: eval:132:0

Note that the type of (tiger-color slimey) printed before an error was reported. That’s because (tiger-color slimey) is well-typed as far as Plait can tell, since tiger-color wants an Animal and slimey has type Animal. We’ll see that type-case provides an alterntive to selectors like tiger-color that is less dangerous than the selector.

Using Animal as a type and the tiger? and snake? predicates, we can write a function that extracts the color of any animal:

> (define (animal-color [a : Animal]) : Symbol
    (cond
      [(tiger? a) (tiger-color a)]
      [(snake? a) (snake-color a)]))
> (animal-color tony)

- Symbol

'orange

> (animal-color slimey)

- Symbol

'green

When writing animal-color, what if we forget the snake? case? What if we get snake-color and tiger-color backwards? Unfortunately, the type checker cannot help us detect those problems. If we use type-case, however, the type checker can help more.

The general form of a type-case expresison is

(type-case Type value-expression
  [(variant-name_1 field-var_1 field-var_2 ...)
   result-expression_1]
  [(variant-name_2 field-var_3 field-var_4 ...)
   result-expression_2]
  ...)

The value-expression must produce a value matching Type. Every variant of Type must be represented by a clause with a matching variant-name. For that clause, the number of field-vars must match the declared number of fields for the variant. The type checker can check all of those requirements.

To produce a value, type-case determines the variant that is instanited by the result of value-expression. For the clause matching that variant (by name), type-case makes each field-var stand for the corresponding field (by position) within the value, and then evaluates the corresponding result-expression. Here’s animal-color rewritten with type-case:

> (define (animal-color [a : Animal]) : Symbol
    (type-case Animal a
      [(tiger col sc) col]
      [(snake col wgt f) col]))
> (animal-color tony)

- Symbol

'orange

> (animal-color slimey)

- Symbol

'green

Put the definitions of Anmal and animal-color in DrRacket’s definitions area. Then, you can mouse over a in animal-color to confirm that it means the a that is passed as an argument. Mouse over col to see that it means one of the variant-specific fields. Try changing the body of animal-color to leave out a clause or a field variable and see what error is reported when you hit Run.

You should think of type-case as a pattern-matching form. It matches a value like (tiger 'orange 12) to the pattern (tiger col sc) so that col stands for 'orange and sc stands for 12. A value like (snake 'green 10 "rats") does not match the pattern (tiger col sc), but it matches the pattern (snake col wgt f).

At the end of Lists, we saw a got-milk? function that uses cond, similar to the way the dangerous version of animal-color uses cond. The type-case form works on list types with empty and (cons fst rst) patterns, so here’s an improved got-milk?:

> (define (got-milk? [items : (Listof String)])
    (type-case (Listof String) items
      [empty #f]
      [(cons item rst-items) (or (string=? item "milk")
                                 (got-milk? rst-items))]))
> (got-milk? empty)

- Boolean

#f

> (got-milk? '("cookies" "milk"))

- Boolean

#t

Note that there are no parentheses around empty in got-milk?. That’s because empty is never called as a constructor function—it’s simply a constant value—so the pattern form doesn’t have parentheses, either. The empty pattern is a special case in type-case; all other variant names in a type-case form will have parentheses, since they will always be used a constrcutor functions, even if the variant has no fields.

> (define-type Grade
    (letter [alpha : Symbol])
    (pass-fail [pass? : Boolean])
    (incomplete))
> (letter 'A)

- Grade

(letter 'A)

> (pass-fail #t)

- Grade

(pass-fail #t)

> (incomplete)

- Grade

(incomplete)

> (define (passed-course? [g : Grade]) : Boolean
    (type-case Grade g
      [(letter a) (not (eq? a 'F))]
      [(pass-fail p?) p?]
      [(incomplete) #f]))
> (passed-course? (letter 'B))

- Boolean

#t

> (passed-course? (incomplete))

- Boolean

#f

You can also use else for a final clause in type-case to catch any variants that are not already covered.

> (define (high-pass? [g : Grade]) : Boolean
    (type-case Grade g
      [(letter a) (eq? a 'A)]
      [else #f]))
> (high-pass? (letter 'A))

- Boolean

#t

> (high-pass? (incomplete))

- Boolean

#f

When you use else, however, the type checker is less helpful for making sure that you’ve considered all cases.