Opaque Types (defopaque)

An opaque type is a named, nominally-distinct newtype that wraps a single representation type. At the C ABI it is the representation type and nothing more -- so the wrapper costs zero bytes and zero instructions -- but to the type checker it is a distinct type that does not unify with its representation. That is the whole point: you get type-level separation between, say, a file descriptor and the byte count you were about to pass where the fd belongs, without paying for a struct.

(defopaque Fd     :int)
(defopaque Pid    :int)
(defopaque Chan   :ptr<void> :linear)
(defopaque Future :ptr<void> :affine)

The representation type is one of:

Representation When to use
:int A small integer handle (POSIX fd, OS pid, table index, generation-counted id, branded int).
:ptr<void> A pointer to a C-allocated control block (channel, mutex, reactor, opaque library handle).
:ptr A bare pointer when you don't need to thread the pointee type through.

Both lower to int64_t at the C boundary, so an inline-C body that takes or returns an opaque writes the C signature in terms of int64_t (and casts to/from the real pointer type internally).

What opaques are for

  1. Type-safe handles to C resources. A FILE*, pthread_mutex_t*, or socket fd is just a pointer or int at the ABI. Wrapping it in a defopaque means the checker rejects passing an unrelated :int (a length, a byte count, the wrong handle) into the operations that expect it.
  2. Branded integers. Two ids of the same shape but different meaning -- UserId and RoomId, EventSourceId and TimerId -- should be distinct so a swap is a compile error, not a 3 a.m. page.
  3. Hiding representation across module boundaries. A library can export (defopaque Token :int) and never reveal whether the token is an array index, a hash, or a serial number. The consumer sees only the accessor functions you choose to export.
  4. Attaching a substructural discipline. Adding :linear or :affine makes the handle exactly-once or at-most-once -- the checker enforces that a Chan :linear is consumed by exactly one chan-free call, ruling out leaks and double-frees.

Opaques are not for:

Defining an opaque

(defopaque Name :rep-type)
(defopaque Name :rep-type :linear)
(defopaque Name :rep-type :affine)

The optional trailing keyword promotes the newtype to a substructural handle. Without it the opaque is freely copyable (is_copy = true); with :linear or :affine it becomes a resource the checker tracks for single use.

The C ABI of a substructurally-marked opaque is identical to a freely copyable one -- the handle still lowers to int64_t; only the elaborator's usage tracking changes. See substructural-types-guide.md for the discipline; see uniqueness-types-guide.md for the ^unique alternative.

A defopaque produces no constructors or accessors of its own. You provide those as ordinary defns, using the (:: expr :Type) cast form to move between the wrapper and its representation.

Constructing and unwrapping: (:: expr :Type)

The (::) form is a type ascription that doubles as the bridge between an opaque and its representation. It compiles to nothing at the C level -- only the static type of expr changes.

(defopaque Fd :int)

;; Wrap a raw int into an Fd:
(defn int->fd [n : int] : Fd (:: n :Fd))

;; Unwrap an Fd back to int:
(defn fd->int [fd : Fd] : int (:: fd :int))

;; Use either side as needed:
(defn fd-valid? [fd : Fd] : bool (>= (:: fd :int) 0))

The same pattern works for :ptr<void> opaques:

(defopaque Chan :ptr<void> :linear)

;; Inside an inline-C body the C type is int64_t; cast as you would
;; any other handle:
(defn chan-new [cap : int] : Chan
  ```c
  ChanBlock *ch = (ChanBlock *)malloc(sizeof(ChanBlock));
  /* ... init ch ... */
  return (int64_t)(intptr_t)ch;
  ```)

(defn chan-send [^borrow ch : Chan val : int] : nil
  ```c
  ChanBlock *c = (ChanBlock *)(intptr_t)ch;
  /* ... use c ... */
  return 0;
  ```)

Conventions worth following:

Inline-C and the ABI

Every opaque -- whether :int or :ptr<void> -- lowers to int64_t at the C boundary. An inline-C body always sees int64_t parameters and returns int64_t; cast to your real pointer or int type inside the block.

(defopaque Pid :int)

(defn pid-kill [p : Pid sig : int] : int
  ```c
  return kill((pid_t)p, (int)sig);
  ```)
(defopaque Reactor :ptr<void> :linear)

(defn reactor-new [] : Reactor
  ```c
  TurReactor *r = tur_reactor_new();
  return (int64_t)(intptr_t)r;
  ```)

This uniform int64_t carrier is the same mechanism described in type-erasure-guide.md; opaques are one of the three sites where the compiler collapses high-level types to the carrier.

When the constructor is fallible -- it acquires the handle in C and can fail -- hand back a typed (Result Handle E) / (Option Handle) rather than the bare opaque, built with the preamble helpers tur_ok_ptr / tur_err_int / tur_some_ptr / tur_none. See inline-c-results-guide.md for the worked pattern.

Substructural opaques

Adding :linear or :affine flips two checker bits on the underlying StructDef (is_copy = false, is_linear / is_affine = true):

Inheriting an opaque

There is no inheritance. A defopaque is a leaf node in the type lattice -- it does not unify with its representation, nor with any other opaque, even one declared with the same representation. To "convert" between two opaques you write a function that unwraps to the shared representation and wraps back:

(defopaque Celsius    :float)
(defopaque Kelvin     :float)

(defn celsius->kelvin [c : Celsius] : Kelvin
  (:: (+ (:: c :float) 273.15) :Kelvin))

Known limitations

defopaque does not currently accept type parameters -- (defopaque Box[T] :int) is rejected. The same effect can be achieved with a phantom-typed defstruct today; the open ticket is docs/reported/parameterized-defopaque.md.

Real-world examples

Module Opaque Shape
stdlib/fd.tur Fd :int -- POSIX file descriptor, -1 is the error sentinel
stdlib/process.tur Pid, ChildHandle :int (one :linear) -- OS process ids
stdlib/chan.tur Chan, AsyncChan :ptr<void> :linear -- channel control blocks
stdlib/future.tur Promise, Future :ptr<void> with :linear / :affine -- write end vs read end of the same FutureCell
stdlib/threadpool.tur WorkQueueHandle, ThreadPoolHandle, DynThreadPoolHandle, FutureHandle :ptr<void> -- static and dynamic pools have distinct block layouts and are nominally distinct
stdlib/thread.tur ThreadHandle :ptr<void> -- returned by thread-spawn-fn; thread-join / -detach / cancel-thread take it
stdlib/fiber.tur FiberHandle :ptr<void> -- consumed by fiber-resume / -free / scheduler unpark
stdlib/mutex.tur, stdlib/condvar.tur, stdlib/rwlock.tur Mutex, CondVar, RwLock :ptr<void> -- condvar-wait [c : CondVar m : Mutex] rejects transposed callers
stdlib/taskgroup.tur TaskGroup, TaskHandle :ptr<void> -- task-group-join [group : TaskGroup handle : TaskHandle]
stdlib/reactor.tur Reactor, EventSourceId mixed -- pointer for the reactor, branded :int for the source id
stdlib/atomic.tur AtomicCell :ptr<void> -- pointer to a heap-allocated atomic word
stdlib/stm.tur TVar :ptr -- transactional-variable handle, distinct from the boxed :ptr values it holds
stdlib/timer.tur TimerId :int -- branded handle returned by reactor-add-timer
stdlib/fs.tur StatInfo, TmpFile :int -- stat block vs temp-file handle; can no longer be transposed
stdlib/io.tur FileHandle, FileStream, DirListing, FileSystem :ptr<void> -- FileHandle is :linear; FileStream wraps FILE*
stdlib/ref.tur RefHandle :int -- heap pointer from ref-new, distinct from the Ref struct

Read those modules for the full pattern: a defopaque declaration immediately followed by the constructor (*-new), one or more borrow-taking operations, and a consuming *-free (when the resource is substructural).

See also