The missing middle
Post 2 covered pointers and strings. Post 3 covered structs and the alignment trap. Both deal with how data crosses the FFI boundary — but they cover only the most common patterns. Real C APIs use three more constructs that appear constantly and that most FFI tutorials pretend don't exist:
Enums — named integer constants. Every status code, every error type, every configuration flag in a C API is an enum. You'll encounter them in the first five minutes of integrating any library.
Unions — the same block of memory interpreted as different types. Used for variant data, type punning, and protocol parsing. Less common than enums, but when they appear, they're confusing if you've never seen them.
Opaque types — pointers to things you're not allowed to see inside. The most important pattern in well-designed C libraries: you get a handle, you pass it to functions, you never touch its internals. SQLite's sqlite3*, OpenSSL's SSL_CTX*, libsodium's internal state — all opaque.
None of these are difficult. But all three require different mapping strategies in Dart FFI, and getting them wrong produces either compiler errors or — worse — code that compiles, runs, and silently does the wrong thing.
Enums: integers wearing names
What a C enum actually is
In C, an enum is not what you think it is if you're coming from Dart. A Dart enum is a type-safe, closed set of values. A C enum is just a way to name integer constants:
enum compression_level {
COMPRESS_NONE = 0,
COMPRESS_FAST = 1,
COMPRESS_DEFAULT = 6,
COMPRESS_BEST = 9
};After compilation, these are just the integers 0, 1, 6, and 9. The names COMPRESS_NONE, COMPRESS_FAST, etc. exist only in the source code — they don't survive into the compiled binary as strings. The enum's underlying type is int (usually 32 bits, but the C standard only guarantees "at least 16 bits").
A function that uses this enum:
int32_t compress(const uint8_t* input, size_t length, uint8_t* output, enum compression_level level);In the compiled library, level is just an int32_t. The enum type is documentation, not enforcement. You could pass 42 and the compiler wouldn't stop you. C enums are integers with good intentions.
Mapping C enums to Dart FFI
Because C enums are just integers, you have two choices in Dart:
Option 1: Plain constants (simple, always works)
abstract class CompressionLevel {
static const none = 0;
static const fast = 1;
static const defaultLevel = 6;
static const best = 9;
}The function binding uses Int32:
typedef CompressNative = Int32 Function(
Pointer<Uint8> input, Size length, Pointer<Uint8> output, Int32 level);
typedef CompressDart = int Function(
Pointer<Uint8> input, int length, Pointer<Uint8> output, int level);Call it:
final result = compress(inputPtr, inputLength, outputPtr, CompressionLevel.fast);This is the approach most production FFI code uses. It's explicit, it works everywhere, and it matches what's actually happening at the binary level.
Option 2: Dart enum with conversion (type-safe, more Dart-idiomatic)
enum CompressionLevel {
none(0),
fast(1),
defaultLevel(6),
best(9);
final int value;
const CompressionLevel(this.value);
static CompressionLevel fromValue(int value) =>
CompressionLevel.values.firstWhere(
(e) => e.value == value,
orElse: () => throw ArgumentError('Unknown compression level: $value'),
);
}Call it:
final result = compress(inputPtr, inputLength, outputPtr, CompressionLevel.fast.value);The .value is the overhead you pay for type safety. Whether it's worth it depends on how many places you use the enum and whether mixing up constants is a real risk.
When a C function returns an enum
This is the more interesting case. Most C APIs return status codes:
enum status_code {
STATUS_OK = 0,
STATUS_ERROR = -1,
STATUS_INVALID_INPUT = -2,
STATUS_BUFFER_TOO_SMALL = -3,
STATUS_NOT_INITIALIZED = -4
};
enum status_code process(const uint8_t* data, size_t length);In Dart, the return type is int. You're responsible for interpreting it:
typedef ProcessNative = Int32 Function(Pointer<Uint8> data, Size length);
typedef ProcessDart = int Function(Pointer<Uint8> data, int length);
// ...
final code = process(dataPtr, dataLength);
if (code != StatusCode.ok) {
throw NativeException('process() failed', code);
}Never ignore the return value of a C function that returns a status code. C has no exceptions. The return code is the only way the function can tell you something went wrong. Ignoring it is the native equivalent of catching an exception and swallowing it.
Bitfield enums (flags)
Some C APIs use enums as bitfields — individual bits that can be combined with bitwise OR:
enum open_flags {
FLAG_READ = 1, // 0b0001
FLAG_WRITE = 2, // 0b0010
FLAG_CREATE = 4, // 0b0100
FLAG_APPEND = 8 // 0b1000
};
int32_t open_file(const char* path, int32_t flags);The values are powers of 2 so they can be combined without ambiguity:
// Open for reading and writing
int32_t fd = open_file("data.bin", FLAG_READ | FLAG_WRITE); // 1 | 2 = 3In Dart:
abstract class OpenFlags {
static const read = 1;
static const write = 2;
static const create = 4;
static const append = 8;
}
// Combine with bitwise OR — same as in C
final fd = openFile(pathPtr, OpenFlags.read | OpenFlags.write);The pattern: if enum values are powers of 2, they're flags. Combine them with |, test them with &:
final flags = OpenFlags.read | OpenFlags.write | OpenFlags.create;
if (flags & OpenFlags.write != 0) {
// write flag is set
}This is identical to how it works in C. The | and & operators on Dart int do exactly the same thing as in C.
Unions: one block of memory, multiple interpretations
The concept
A C union looks like a struct:
union value {
int32_t as_int;
float as_float;
uint8_t as_bytes[4];
};But it works completely differently. A struct allocates space for all fields — they sit side by side in memory. A union allocates space for the largest field — all fields overlap, sharing the same bytes.
Struct (12 bytes): Union (4 bytes):
┌──────┬──────┬──────────┐ ┌──────────────┐
│ int │float │ bytes[4] │ │ int │
│ 4B │ 4B │ 4B │ │ float │ ← same 4 bytes
└──────┴──────┴──────────┘ │ bytes[4] │
└──────────────┘Writing to one field changes all the others because they're the same memory. If you write 42 to as_int, then read as_float, you get whatever floating-point number is represented by the same 32 bits — which is 5.885e-44. Not useful, but legal.
Why unions exist
Three common use cases in C APIs:
1. Variant/tagged data — a value that could be one of several types:
enum value_type { TYPE_INT, TYPE_FLOAT, TYPE_STRING };
struct tagged_value {
enum value_type type;
union {
int32_t int_val;
float float_val;
char* string_val;
} data;
};You check type to know which field of data to read. This is C's version of a sum type or sealed class.
2. Type punning — examining the raw bytes of a value:
union float_bits {
float value;
uint32_t bits;
};
union float_bits fb;
fb.value = 3.14f;
printf("3.14 in binary: 0x%08X\n", fb.bits); // 0x4048F5C3This is how low-level code inspects the IEEE 754 representation of floating-point numbers.
3. Protocol parsing — reading a network packet or file header where the same bytes have different meanings depending on a header field.
Mapping unions in Dart FFI
Here's the thing most tutorials skip: Dart FFI does not have native union support in the way it has struct support. There's no Union base class that works like Struct. You have to handle unions manually.
There are two approaches:
Approach 1: Raw pointer arithmetic
Since all union fields start at offset 0, you can read the same memory as different types:
// Allocate 4 bytes (size of the largest field)
final ptr = calloc<Uint8>(4);
// Write as int32
ptr.cast<Int32>().value = 42;
// Read as float — same 4 bytes, different interpretation
final floatValue = ptr.cast<Float>().value; // 5.885e-44
// Read as bytes
final bytes = ptr.asTypedList(4); // [42, 0, 0, 0] on little-endian
calloc.free(ptr);The key insight: cast<T>() doesn't change the memory. It changes how you interpret the memory. That's exactly what a union does.
Approach 2: Struct with the largest field + accessor methods
For a tagged union, wrap it in a class:
// The C struct: { enum value_type type; union { int32_t i; float f; char* s; } data; }
final class TaggedValue extends Struct {
@Int32()
external int type;
// Use the size of the largest field (pointer = 8 bytes on 64-bit)
@Int64()
external int _rawData;
int get intValue {
assert(type == 0); // TYPE_INT
// The union starts at offset of _rawData, read as Int32
return _rawData & 0xFFFFFFFF;
}
double get floatValue {
assert(type == 1); // TYPE_FLOAT
final bytes = ByteData(4);
bytes.setInt32(0, _rawData & 0xFFFFFFFF, Endian.host);
return bytes.getFloat32(0, Endian.host);
}
Pointer<Utf8> get stringValue {
assert(type == 2); // TYPE_STRING
return Pointer.fromAddress(_rawData);
}
}This is ugly. It's supposed to be. Unions are the one place where C's simplicity doesn't map cleanly to Dart. The good news: unions in public C APIs are relatively rare. Most well-designed libraries hide this complexity behind opaque types (which we're about to cover).
Approach 3: Dart 3.0+ sealed classes on the Dart side
The cleanest pattern is to read the raw union in FFI and immediately convert it to a Dart sealed class:
sealed class NativeValue {}
class IntValue extends NativeValue {
final int value;
IntValue(this.value);
}
class FloatValue extends NativeValue {
final double value;
FloatValue(this.value);
}
class StringValue extends NativeValue {
final String value;
StringValue(this.value);
}
NativeValue readTaggedValue(Pointer<TaggedValue> ptr) {
final tv = ptr.ref;
return switch (tv.type) {
0 => IntValue(tv.intValue),
1 => FloatValue(tv.floatValue),
2 => StringValue(tv.stringValue.toDartString()),
_ => throw ArgumentError('Unknown value type: ${tv.type}'),
};
}This is the pattern you should use in production: cross the FFI boundary, read the raw bytes, and immediately convert to a Dart type that makes sense. Don't let C's memory model leak into your Dart code any further than necessary.
Opaque types: the most important pattern in C API design
The idea
An opaque type is a pointer to something you're not allowed to see inside. The library declares that the type exists but never shows you the struct definition:
// In the public header — all you see
typedef struct db_connection db_connection;
db_connection* db_open(const char* path);
int32_t db_execute(db_connection* conn, const char* sql);
void db_close(db_connection* conn);// In the library's private source — you never see this
struct db_connection {
int socket_fd;
char* host;
uint16_t port;
void* internal_state;
// ... 20 more fields
};You get a db_connection* — a pointer to something. You pass it to functions. You never dereference it, never read its fields, never allocate it yourself. The library creates it, uses it, and destroys it. You just hold the handle.
Why this pattern exists
Opaque types solve three problems simultaneously:
1. Encapsulation — the library can change its internal struct layout between versions without breaking your code. If you never see the fields, you can't depend on them.
2. Safety — you can't accidentally modify internal state. You can't write to the socket file descriptor, corrupt the connection string, or overwrite the internal buffer. The API is the only way in.
3. Memory ownership — the library allocates and frees the opaque struct. There's no question about who owns it: the library does. You're borrowing a handle.
This is the C equivalent of a private class with a public interface. Except in C, privacy is enforced by literally not including the struct definition in the public header. You can't access what you can't see.
How common are they?
Extremely. Nearly every well-designed C library uses opaque types for its primary objects:
| Library | Opaque type | Purpose |
|---------|-------------|---------|
| SQLite | `sqlite3*` | Database connection |
| SQLite | `sqlite3_stmt*` | Prepared statement |
| OpenSSL | `SSL_CTX*` | TLS context |
| OpenSSL | `SSL*` | TLS connection |
| libcurl | `CURL*` | HTTP session |
| libpng | `png_structp` | PNG encoder/decoder state |
| zlib | `z_stream*` | Compression stream |
| libsodium | (internal) | Crypto state buffers |If a C library is older than 10 years and still has a stable API, it almost certainly uses opaque types. The pattern is the primary reason C libraries achieve API stability across decades.
Mapping opaque types in Dart FFI
You have two approaches. Both work. One is better.
Approach 1: `Pointer<Void>` (quick, no ceremony)
typedef DbOpenNative = Pointer<Void> Function(Pointer<Utf8> path);
typedef DbOpenDart = Pointer<Void> Function(Pointer<Utf8> path);
typedef DbExecuteNative = Int32 Function(Pointer<Void> conn, Pointer<Utf8> sql);
typedef DbExecuteDart = int Function(Pointer<Void> conn, Pointer<Utf8> sql);
typedef DbCloseNative = Void Function(Pointer<Void> conn);
typedef DbCloseDart = void Function(Pointer<Void> conn);This works. But Pointer<Void> is a raw, untyped pointer. If you have two opaque types — db_connection* and db_cursor* — they're both Pointer<Void>. You could accidentally pass a cursor where a connection is expected. The compiler wouldn't stop you.
Approach 2: Custom opaque structs (type-safe, recommended)
// Declare empty structs that only exist for type safety
final class DbConnection extends Opaque {}
final class DbCursor extends Opaque {}
typedef DbOpenNative = Pointer<DbConnection> Function(Pointer<Utf8> path);
typedef DbOpenDart = Pointer<DbConnection> Function(Pointer<Utf8> path);
typedef DbExecuteNative = Int32 Function(
Pointer<DbConnection> conn, Pointer<Utf8> sql);
typedef DbExecuteDart = int Function(
Pointer<DbConnection> conn, Pointer<Utf8> sql);
typedef DbCloseNative = Void Function(Pointer<DbConnection> conn);
typedef DbCloseDart = void Function(Pointer<DbConnection> conn);Now Pointer<DbConnection> and Pointer<DbCursor> are distinct types. Pass the wrong one and the Dart analyzer catches it. The Opaque base class tells Dart FFI "this struct has no accessible fields" — which is exactly what an opaque type is.
This adds zero runtime cost. Pointer<DbConnection> is still just a memory address at runtime. The type parameter only exists for static analysis.
The lifecycle pattern
Opaque types almost always follow a create → use → destroy lifecycle:
// Create
db_connection* conn = db_open("path/to/database.db");
// Use (potentially many times)
db_execute(conn, "CREATE TABLE users (id INT, name TEXT)");
db_execute(conn, "INSERT INTO users VALUES (1, 'Alice')");
// Destroy
db_close(conn);In Dart, wrap this in a class that manages the lifecycle:
class Database {
Pointer<DbConnection>? _conn;
Database(String path) {
final pathPtr = path.toNativeUtf8();
try {
_conn = _bindings.dbOpen(pathPtr);
if (_conn == nullptr) {
throw DatabaseException('Failed to open database: $path');
}
} finally {
calloc.free(pathPtr);
}
}
void execute(String sql) {
_ensureOpen();
final sqlPtr = sql.toNativeUtf8();
try {
final result = _bindings.dbExecute(_conn!, sqlPtr);
if (result != 0) {
throw DatabaseException('SQL execution failed', result);
}
} finally {
calloc.free(sqlPtr);
}
}
void close() {
if (_conn != null) {
_bindings.dbClose(_conn!);
_conn = null;
}
}
void _ensureOpen() {
if (_conn == null || _conn == nullptr) {
throw StateError('Database is closed');
}
}
}This is the standard pattern for wrapping opaque types:
- Constructor calls the C
create/open/initfunction - Methods call the C functions that take the handle
close()/dispose()calls the Cdestroy/close/freefunction- Every method checks that the handle is still valid
Post 6 of this series covers NativeFinalizer — a mechanism that automatically calls the destroy function when the Dart object is garbage collected, preventing leaks if you forget to call close().
The null pointer check
When a C function returns an opaque pointer, NULL (address 0) means failure. Always check:
final conn = _bindings.dbOpen(pathPtr);
if (conn == nullptr) {
throw DatabaseException('db_open returned NULL');
}nullptr in Dart FFI is a pointer with address 0. Comparing with == nullptr is the standard null check for native pointers. Never dereference a null pointer — it will crash the entire process (not just throw an exception — the Dart VM itself will die).
A complete example: all three patterns together
Let's look at a realistic C API that uses all three — enums, unions, and opaque types — and build the Dart FFI mapping:
// config_parser.h
typedef struct config_parser config_parser;
enum config_value_type {
CONFIG_INT = 0,
CONFIG_FLOAT = 1,
CONFIG_STRING = 2,
CONFIG_BOOL = 3
};
enum config_status {
CONFIG_OK = 0,
CONFIG_NOT_FOUND = -1,
CONFIG_TYPE_MISMATCH = -2,
CONFIG_PARSE_ERROR = -3
};
// Create a parser from a file path
config_parser* config_open(const char* path);
// Get a value — caller checks type, reads appropriate field
enum config_status config_get(
config_parser* parser,
const char* key,
enum config_value_type* out_type, // output: what type the value is
int64_t* out_int, // output: integer value (if type is CONFIG_INT)
double* out_float, // output: float value (if type is CONFIG_FLOAT)
const char** out_string, // output: string pointer (if type is CONFIG_STRING)
int32_t* out_bool // output: bool value (if type is CONFIG_BOOL)
);
// Clean up
void config_close(config_parser* parser);This is a real pattern you'll see in C: instead of a union, the API uses separate output parameters — one per possible type. The out_type tells you which one was filled in. It's less elegant than a union but more explicit.
The Dart mapping:
// Opaque type
final class ConfigParser extends Opaque {}
// Enums as constants
abstract class ConfigValueType {
static const int_ = 0;
static const float_ = 1;
static const string_ = 2;
static const bool_ = 3;
}
abstract class ConfigStatus {
static const ok = 0;
static const notFound = -1;
static const typeMismatch = -2;
static const parseError = -3;
}
// Bindings
typedef ConfigOpenNative = Pointer<ConfigParser> Function(Pointer<Utf8> path);
typedef ConfigOpenDart = Pointer<ConfigParser> Function(Pointer<Utf8> path);
typedef ConfigGetNative = Int32 Function(
Pointer<ConfigParser> parser,
Pointer<Utf8> key,
Pointer<Int32> outType,
Pointer<Int64> outInt,
Pointer<Double> outFloat,
Pointer<Pointer<Utf8>> outString,
Pointer<Int32> outBool,
);
typedef ConfigGetDart = int Function(
Pointer<ConfigParser> parser,
Pointer<Utf8> key,
Pointer<Int32> outType,
Pointer<Int64> outInt,
Pointer<Double> outFloat,
Pointer<Pointer<Utf8>> outString,
Pointer<Int32> outBool,
);
typedef ConfigCloseNative = Void Function(Pointer<ConfigParser> parser);
typedef ConfigCloseDart = void Function(Pointer<ConfigParser> parser);And the Dart wrapper that makes it pleasant to use:
sealed class ConfigValue {}
class ConfigInt extends ConfigValue { final int value; ConfigInt(this.value); }
class ConfigFloat extends ConfigValue { final double value; ConfigFloat(this.value); }
class ConfigString extends ConfigValue { final String value; ConfigString(this.value); }
class ConfigBool extends ConfigValue { final bool value; ConfigBool(this.value); }
class Config {
final Pointer<ConfigParser> _parser;
Config(String path) : _parser = _open(path);
static Pointer<ConfigParser> _open(String path) {
final pathPtr = path.toNativeUtf8();
try {
final parser = _bindings.configOpen(pathPtr);
if (parser == nullptr) throw ConfigException('Failed to open: $path');
return parser;
} finally {
calloc.free(pathPtr);
}
}
ConfigValue get(String key) {
final keyPtr = key.toNativeUtf8();
final typePtr = calloc<Int32>();
final intPtr = calloc<Int64>();
final floatPtr = calloc<Double>();
final stringPtr = calloc<Pointer<Utf8>>();
final boolPtr = calloc<Int32>();
try {
final status = _bindings.configGet(
_parser, keyPtr, typePtr, intPtr, floatPtr, stringPtr, boolPtr,
);
if (status == ConfigStatus.notFound) {
throw ConfigKeyNotFound(key);
}
if (status != ConfigStatus.ok) {
throw ConfigException('config_get failed', status);
}
return switch (typePtr.value) {
ConfigValueType.int_ => ConfigInt(intPtr.value),
ConfigValueType.float_ => ConfigFloat(floatPtr.value),
ConfigValueType.string_ => ConfigString(stringPtr.value.toDartString()),
ConfigValueType.bool_ => ConfigBool(boolPtr.value != 0),
_ => throw ConfigException('Unknown type: ${typePtr.value}'),
};
} finally {
calloc.free(keyPtr);
calloc.free(typePtr);
calloc.free(intPtr);
calloc.free(floatPtr);
calloc.free(stringPtr);
calloc.free(boolPtr);
}
}
void close() => _bindings.configClose(_parser);
}Look at what happened: we started with a C API that uses opaque types, enums, and multiple output parameters. We ended with a Dart class that has Config('path').get('key') returning a sealed class. The FFI complexity is completely contained in the wrapper. The rest of the app never sees a pointer.
That's the pattern. Enums become constants or Dart enums. Opaque types become Pointer<CustomOpaque>. Unions and variant data become sealed classes. The FFI boundary is thin, and the Dart side is clean.
Summary: when you'll encounter each pattern
| Pattern | How common | Where you'll see it |
|---------|-----------|-------------------|
| Enums (status codes) | Every C library | Return values, configuration, error codes |
| Enums (bitflags) | Most C libraries | File modes, feature flags, options |
| Unions (tagged) | Some C libraries | Variant data, event types, protocol parsing |
| Unions (type punning) | Rare in public APIs | Low-level byte manipulation, serialisation |
| Opaque types | Every well-designed C library | Connections, contexts, handles, state objects |Enums you'll map in the first hour of integration. Opaque types you'll wrap on the first day. Unions you'll encounter occasionally and handle case by case. Now you know how to handle all three.
Next: Callbacks
The next post flips the direction. So far, Dart has always been the caller and C has been the callee. But many C libraries need to call you — progress callbacks, event handlers, completion functions. That's where threads become critical, and where NativeCallable enters the picture.