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:
- Has a known name in Nitpick, so the type checker can enforce correct usage
- Has no value semantics — you cannot copy an opaque handle by assignment
- Is always represented as a raw pointer (
ptr) in LLVM IR, matching C'sT*ABI exactly - Is primarily used at FFI boundaries to wrap C structs whose layout is unknown or irrelevant to Nitpick code
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
- No
nodropintegration — Nitpick does not automatically call a destructor when an opaque handle goes out of scope. You must manually call the C cleanup function (e.g.,fclose,sqlite3_close). This is a planned feature. - No null-safety — Opaque handles received from C are not automatically checked for null. The type system has no concept of nullable vs. non-nullable opaque handles.
- No opaque type alias — The form
opaque:MyHandle;(withoutstruct) is not currently supported. All opaque declarations use theopaque struct:Nameform. - No K-semantics enforcement — The K-semantics model has stub load rules for
opaque structbut does not enforce the copy restriction. Copy safety is enforced solely by the Nitpick type checker.
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);