Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

22. Opaque types

A user id and an email address can both live in a string, and a plain string will let you pass one where the other belongs. An opaque type gives a value of an existing type its own name, and the checker then keeps the two apart everywhere. You declare one with opaque type, wrap with the constructor of the same name, and unwrap with a single-case match.

opaque type UserId = string
opaque type Email = string

let uid = UserId "u-1001"
let contact = Email "ana@example.org"

let describe u =
  match u:
    case UserId s: s

uid |> describe |> print
u-1001

UserId and Email are distinct types built on the same underlying string. The constructor UserId : string -> UserId wraps a value, and the pattern case UserId s: binds the underlying string back out. That one case makes the match exhaustive, because the type has exactly one shape.

Mixing the two up is where the checker steps in. Passing an Email to a function that takes a UserId is rejected before any Python is produced:

opaque type UserId = string
opaque type Email = string

let describe u =
  match u:
    case UserId s: s

let bad = describe (Email "ana@example.org")
error: type mismatch: expected UserId, found Email

The distinction exists only during type checking. Like units of measure, an opaque type erases at lowering, so the emitted Python is the plain underlying value with no wrapper class and no allocation:

uid = "u-1001"
contact = "ana@example.org"
def describe(u):
    match u:
        case s:
            return s
print(describe(uid))

The wrap compiled to nothing, and the pattern became a plain capture. This zero-cost story pays off at the Python boundary: because the running value is the underlying string, an extern can carry the opaque type in its signature, and the Python side receives exactly the str it expects.

opaque type UserId = string

extern pure shout: UserId -> string = str.upper

let uid = UserId "u-1001"
let loud = uid |> shout
print loud
U-1001

The signature enforces the domain distinction on the Pyfun side, and str.upper runs on a plain string at runtime. An opaque type can also take parameters (opaque type Tag a = List a) and wrap any type, including lists and tuples. For numeric quantities with arithmetic, units of measure (lesson 14) remain the sharper tool, since they combine algebraically; opaque types cover ids, tokens, sanitized text, and every other value whose meaning outgrows its representation.

Exercise

The program below reads an order id as a plain string, then hands it straight to a function that takes an OrderId. Run pyfun check to see the mismatch, then wrap the string at the call site so the program type-checks.

opaque type OrderId = string

let orderLabel o =
  match o:
    case OrderId s: String.concat "order " s

# The id arrives as a plain string from the outside world.
let raw = "o-9"

# This line does not type-check yet: orderLabel wants an OrderId.
let label = orderLabel raw

print label

The checker reports:

error: type mismatch: expected OrderId, found string
  --> 11:13
   |
11 | let label = orderLabel raw
   |             ^^^^^^^^^^^^^^

Expected output:

order o-9

Open in the playground

Show solution
opaque type OrderId = string

let orderLabel o =
  match o:
    case OrderId s: String.concat "order " s

# The id arrives as a plain string from the outside world.
let raw = "o-9"

let label = orderLabel (OrderId raw)

print label

Wrapping the string with OrderId at the boundary is the whole fix, and the whole idiom: raw data enters, gets named once, and every function past that point can trust what it holds. The wrap costs nothing at runtime.