← Back to AILP Home

opaque — Opaque Handle Types

v0.54.4 — Introduced as part of the Uncovered Types Hardening series (UTH-017..021).


1. Overview

opaque struct is Nitpick's mechanism for declaring handle types that wrap C-side resources. An opaque type:

The motivating example is C's FILE*:

extern "libc" {
    opaque struct:FILE_t;
    func:fopen  = FILE_t(string:path, string:mode);
    func:fputs  = int32(string:s,     FILE_t:fp);
    func:fclose = int32(FILE_t:fp);
}

The compiler knows FILE_t is an opaque handle. It rejects copies, passes the handle as a ptr to C, and type-checks that you don't mix it with unrelated pointer types.


2. The Problem opaque Solves

The FILE* problem

In C, FILE* is an opaque pointer. You never dereference it directly — you pass it to library functions. If you accidentally copy it:

FILE *f = fopen("/tmp/out", "w");
FILE *copy = f;     // Both point to the same FILE — danger!
fclose(f);
fputs("late write", copy);  // Undefined behaviour: double-use after close

Nitpick prevents this class of bug at compile time. The compiler tracks that FILE_t is opaque and rejects the copy:

FILE_t:f    = fopen("/dev/null", "w");
FILE_t:copy = f;    // ❌ Error: Cannot copy opaque type 'FILE_t' (OPAQUE-COPY-001)

Why not just use int64?

You could wrap a FILE* in an int64 handle (like buffer and binary do), but then the type checker cannot distinguish it from any other int64. The opaque struct form gives you a distinct, named type that travels through the type system correctly.


3. Syntax

In an extern block (extern opaque)

Declare an opaque struct inside an extern block to wrap a C type:

extern "libname" {
    opaque struct:TypeName;
    func:c_function = TypeName(/* params */);
}

The opaque struct:TypeName; line tells the compiler: "TypeName is a handle type whose layout is owned by C. Treat it as an opaque pointer."

Top-level opaque struct (Nitpick-defined)

You can also declare opaque types in Nitpick code to enforce encapsulation over your own data:

opaque struct:Handle = { int64:raw; };

This is similar to a regular struct but the compiler enforces the no-copy rule.


4. What opaque Prevents

Copy by value (OPAQUE-COPY-001)

The most important restriction: you cannot copy an opaque value to a new binding.

FILE_t:f    = fopen("/dev/null", "w");
FILE_t:copy = f;    // ❌ OPAQUE-COPY-001: Cannot copy opaque type 'FILE_t'

Why: Opaque handles often represent owned resources (file descriptors, mutex objects, network connections). Copying the handle creates two bindings that both believe they own the resource, leading to double-free, double-close, and use-after-free bugs.


5. What opaque Allows

Passing to functions

Passing an opaque handle to a function is always allowed — the function receives the handle, uses it, and does not retain a copy:

FILE_t:f = fopen("/dev/null", "w");
int32:rc = fclose(f);   // ✅ OK: pass handle to C function

Returning from functions

Functions may return opaque handles. This is the only way to obtain a fresh handle:

FILE_t:f = fopen("/dev/null", "w");   // ✅ OK: handle comes from a function call

Passing through Nitpick functions

You can write Nitpick functions that accept and return opaque handles. Since these functions return Result<T>, use raw to bypass the result wrapper when the function is guaranteed non-failing:

func:identity_handle = FILE_t(FILE_t:h) {
    pass h;
};

FILE_t:f  = fopen("/dev/null", "w");
FILE_t:f2 = raw identity_handle(f);   // ✅ OK: raw unwraps the Result<FILE_t>

Comparing handles

Currently, opaque handles cannot be compared to 0i64 or null using the == operator because they are strict structural types, not numeric integers. Null checks must be performed in C wrappers or via explicit external checking functions.

// if (f == 0i64) { exit 1; }   // ❌ Rejected by type checker

6. FFI Usage

Canonical pattern: libc FILE*

extern "libc" {
    opaque struct:FILE_t;
    func:fopen  = FILE_t(string:path, string:mode);
    func:fputs  = int32(string:s,     FILE_t:fp);
    func:fclose = int32(FILE_t:fp);
}

func:failsafe = int32(tbb32:err) { exit 1; };

func:main = int32() {
    FILE_t:f = fopen("/dev/null", "w");

    int32:_ = fputs("hello opaque world\n", f);

    int32:rc = fclose(f);
    if (rc != 0i32) { exit 1; }
    exit 0;
};

ABI detail: The compiler emits FILE_t as ptr in LLVM IR. No struct layout is created. fopen is declared as declare ptr @fopen(ptr, ptr) — exactly matching the C signature FILE* fopen(const char*, const char*).

Handling null returns

C functions like fopen return NULL on failure. Because Nitpick's type system currently does not support comparing opaque structs to numeric 0i64, you should wrap these calls in a C helper that returns a robust error code, or use a future std::ptr_is_null intrinsic.

[!WARNING] Nitpick's automatic null-checking applies to Result<T> return types, not to raw opaque handles from C. You must manually manage C-style null returns.


7. Value Semantics and Why opaque Prevents It

"Value semantics" means that a variable is the value it holds, and assignment copies that value independently. Primitive integers (int32) and structs have value semantics.

An opaque handle, however, represents a reference to an external resource (like an open file descriptor or a thread). If an opaque handle had value semantics, assigning FILE_t:copy = f; would duplicate the pointer. Both variables would think they "own" the file, leading to double-closes or use-after-free errors. By intentionally disabling value semantics, opaque struct forces you to respect the unique ownership of the underlying resource.


7. opaque struct vs Regular Struct

Property struct opaque struct
Layout known to Nitpick ✅ Yes ❌ No (C-owned)
Field access s.field ❌ Not allowed
Copy by assignment ✅ Allowed ❌ OPAQUE-COPY-001
IR representation %struct.Name { ... } ptr
Primary use Data aggregation C handle wrapping
Initialization Struct literal { field: val } Function call only

8. opaque vs any

Property any opaque struct
Type safety ❌ Untyped (void*) ✅ Named, typed
Copy restriction ❌ None ✅ OPAQUE-COPY-001
IR representation ptr ptr
Type checker visibility None Distinct named type
Use case Generic void pointer Typed C handle

Use any when you need a raw untyped pointer. Use opaque struct when you want the type checker to track a specific C resource type by name.


9. IR Representation

The compiler always lowers an opaque struct declaration to ptr in LLVM IR. No struct type is created in the module.

Given:

extern "libc" {
    opaque struct:FILE_t;
    func:fopen = FILE_t(string:path, string:mode);
}

The generated IR is:

declare ptr @fopen(ptr, ptr)    ; FILE_t → ptr, string → ptr

And a local variable:

%f = alloca ptr, align 8        ; FILE_t:f is just a pointer slot

This is ABI-identical to C's FILE** on the stack — exactly correct for C interop.


10. Complete Example: Wrapping Multiple C Types

extern "libsqlite3" {
    opaque struct:sqlite3;
    opaque struct:sqlite3_stmt;

    func:sqlite3_open        = int32(string:filename, sqlite3:db);
    func:sqlite3_prepare_v2  = int32(sqlite3:db, string:sql, int32:len,
                                     sqlite3_stmt:stmt, string:tail);
    func:sqlite3_step        = int32(sqlite3_stmt:stmt);
    func:sqlite3_finalize    = int32(sqlite3_stmt:stmt);
    func:sqlite3_close       = int32(sqlite3:db);
}

func:failsafe = int32(tbb32:err) { exit 1; };

func:main = int32() {
    sqlite3:db   = raw sqlite3_open(":memory:");
    sqlite3_stmt:stmt = raw sqlite3_prepare_v2(db, "SELECT 1", -1i32, 0);
    int32:_ = sqlite3_step(stmt);
    int32:_ = sqlite3_finalize(stmt);
    int32:_ = sqlite3_close(db);
    exit 0;
};

Each opaque struct declaration gives the type checker a distinct type name. The compiler ensures you never pass a sqlite3 where a sqlite3_stmt is expected.

pthread_t Thread Handle

extern "libpthread" {
    opaque struct:pthread_t;
    // Simplified signature
    func:pthread_create = int32(pthread_t:thread, any:attr, any:routine, any:arg);
    func:pthread_join   = int32(pthread_t:thread, any:retval);
}

Custom Allocator Handle Pattern

You can use opaque struct to define custom memory allocators safely:

extern "my_allocator" {
    opaque struct:Arena;
    func:arena_new   = Arena(int64:size);
    func:arena_alloc = any(Arena:a, int64:bytes);
    func:arena_free  = int32(Arena:a);
}

11. Comparison to Rust and C

C's void*

In C, opaque types are often represented as void*. This is completely untyped; the C compiler will happily allow you to pass a void* file handle into a function expecting a void* thread handle. Nitpick's opaque struct prevents this by giving each handle a distinct name.

Rust's *mut c_void

Rust uses *mut c_void for FFI. Like C's void*, it lacks distinct types unless wrapped in a newtype struct. Nitpick's opaque struct is the equivalent of declaring a Rust empty enum (enum SQLite3 {}) and passing *mut SQLite3, providing strong nominal typing at the FFI boundary.


11. Known Limitations


12. Error Reference

OPAQUE-COPY-001

error: Cannot copy opaque type 'T' — opaque types have no value semantics.
Use a function that returns 'T' instead (UTH-019, OPAQUE-COPY-001)

Cause: You attempted to initialize a new binding by copying an existing opaque handle directly.

Fix: Obtain the handle from a function call. If you need to pass the handle to multiple callers, pass it by value to each function (each call receives the pointer, not a Nitpick-managed copy):

// ❌ Wrong: copy
FILE_t:copy = f;

// ✅ Right: pass handle to functions, don't copy
int32:rc = fclose(f);