Call C libraries
On this page
Call a C library from your application by declaring it in the build config. Maligator reads the library's headers with clang and compiles a binding into the application, so the C compiler handles calling conventions, structs passed by value, and macros. You need the library's headers, clang (or Zig for a --target build), and the library itself as an archive, a shared library, or sources.
Maligator hosts the application. Embedding the Maligator runtime in a C application is outside the supported API.
Bind a small library
A library compiled from sources shows the whole loop. Save a header and its implementation:
int stats_add(int a, int b);
double stats_mean(const double *values, int count);#include "stats.h"
int stats_add(int a, int b) {
return a + b;
}
double stats_mean(const double *values, int count) {
double total = 0;
for (int index = 0; index < count; index++) total += values[index];
return count == 0 ? 0 : total / count;
}Declare the library in a contract and name the contract in the build config:
import { defineLibrary } from "@maligator/cli";
export default defineLibrary({
headers: ["stats.h"],
find: [{ includeDirs: ["."], sources: ["stats.c"] }],
});import { defineBuild } from "@maligator/cli";
export default defineBuild({
entry: "src/index.ts",
ffi: { stats: { contract: "native/stats.ffi.ts" } },
});Ask what binds:
maligator ffi inspect statsstats_add takes and returns integers, so it binds as it is. stats_mean takes a
pointer, and a header cannot say whether that pointer is one value, an output, or an
array. The report says so and suggests the fact that binds it:
stats_mean param 1 (values): pointer (const double *) needs a fact: a handle, buffer, out parameter, or string
functions: {
stats_mean: { params: { values: { array: { length: "count" } } } },
},Add the suggestion to the contract, beside find:
functions: { stats_mean: { params: { values: { array: { length: "count" } } } } },values now takes a JavaScript array or a Float64Array, and count disappears from
the signature because the binding passes the array's length. Generate declarations and
call both functions:
maligator ffi typesimport { stats_add, stats_mean } from "maligator:ffi/stats";
console.log(stats_add(2, 3), stats_mean([1, 2, 3, 4]));maligator run5 2.5Larger libraries follow the same loop: declare, inspect, add the facts the report asks for, and regenerate declarations.
Declare the library
Name the library under ffi and point it at a contract:
import { defineBuild } from "@maligator/cli";
export default defineBuild({
entry: "src/index.ts",
ffi: { raylib: { contract: "native/raylib.ffi.ts" } },
});The contract holds what is true of the library wherever it is installed: the headers to bind and how to find it.
import { defineLibrary } from "@maligator/cli";
export default defineLibrary({
headers: ["raylib.h"],
find: [
{
pkgConfig: "raylib",
frameworks: ["Cocoa", "IOKit", "OpenGL", "QuartzCore", "CoreVideo"],
},
],
});Spell headers the way C code includes them, such as curl/curl.h. Each find entry names a pkg-config package, libraries to link from libraryDirs, or sources to compile into the application, plus includeDirs, defines, and frameworks as needed. The build uses the first entry that works on the machine. Paths in a contract resolve from the contract's directory.
A find in the build config replaces the contract's entries on one machine, and link chooses between "static" and "shared". Without link, the build links statically when every library has an archive.
Note that a static archive does not carry its own dependencies. The build adds the private libraries pkg-config lists; name any others in libraries or frameworks, as the raylib contract does for macOS.
Call it
Import from maligator:ffi/<name>. Exports keep their C names.
import {
BeginDrawing,
ClearBackground,
CloseWindow,
DrawCircleV,
EndDrawing,
GetMousePosition,
InitWindow,
MAROON,
RAYWHITE,
WindowShouldClose,
} from "maligator:ffi/raylib";
InitWindow(800, 450, "Hello from Maligator");
while (!WindowShouldClose()) {
BeginDrawing();
ClearBackground(RAYWHITE);
DrawCircleV(GetMousePosition(), 40, MAROON);
EndDrawing();
}
CloseWindow();Generate declarations so the editor and tsc resolve the import, and include the file in your TypeScript project:
maligator ffi typesmaligator run, dev, and build compile the binding and link the library. run and dev build a development runtime that contains the binding instead of using a packaged one, so they need the native toolchain.
Production builds with a closed module graph generate wrappers for the exports the application and its workers use, and for the handle types, fields, views, and callable signatures those exports reach. When none of them registers a callback, wrappers skip the rooting and pinning that callback reentry needs. Namespace access retains its observable exports. Declaration generation and inspection describe the full header surface; development runtimes retain it for later application edits.
How values cross
Conversions never coerce. A value of the wrong type throws a TypeError, and a number that is not an integer or does not fit throws a RangeError. Both name the C function and parameter.
- Integers up to 32 bits are numbers. Wider integers through 64 bits are bigints.
floatanddoubleare numbers,boolis a boolean, and enums use their compiler-compatible integer type.- A
const char *parameter takes a string, passed as UTF-8 for the duration of the call. A string with a NUL character throws. - A
const char *result is copied into a string.NULLbecomesnull. - A struct whose fields are all values passes as a plain object, copied in each direction.
- Anonymous struct members keep C's promoted field names. Named fields with anonymous struct types remain nested objects, including elements of fixed arrays.
- Named bit-fields use their declared widths for range checks. Padding fields stay hidden; the C compiler owns layout and packing.
- Enum members and macros are constants when their type maps, including struct literals such as raylib's
RAYWHITE.
A function is exported only when every parameter and its result map. Functions that take or return other pointers, callbacks, or variadic arguments are not exported until the contract describes them.
For arithmetic typedefs that need their C precision, use a native representation:
types: {
Wide: { representation: "native" },
Precise: { representation: "native" },
}Each type exports an opaque value box and a factory, such as Wide(), that creates
C arithmetic zero. C functions, constants, globals, records, and arrays retain the
opted typedef instead of converting it to a JavaScript scalar. Use C functions to
inspect or compute with these values. This preserves native arithmetic precision,
including complex components, fixed vector lanes, and bit-precise integer widths.
Clang supplies storage size and alignment, including vector padding and explicit
typedef alignment; ffi check tracks these ABI properties.
Arguments become independent native copies before calls or worker dispatch, so the source box can be disposed afterward. Callback arguments also remain valid after the invocation. Disposal releases a box's copied storage. The typedef definition must be unqualified arithmetic; const-qualified aliases need a C value interface.
Plain records and copied struct handles can contain const members when returned by C or passed to a JavaScript callback. Both also pass back to C by value, including on workers. Input conversion reads properties before initializing the native value; later JavaScript mutations do not change a queued worker's copy. A record output buffer can contain const members when C initializes every selected field before returning. An inout value is constructed before the call; C may update its mutable fields. Counted arrays of const records also use initialized copies, including on workers. Fixed numeric and record array buffers keep their declared bounds and nested dimensions. Selected union members also use native initialization, including const scalars, records, and fixed arrays. A const discriminator can share storage with an identical integer member or a matching struct prefix. The selected payload initializes that storage; its nested discriminator must match the outer JavaScript tag. Empty cases initialize the tag directly. Incompatible types, offsets, and bit-field widths remain unbound. JavaScript callbacks can return these values synchronously or through waiting worker calls. Failed or canceled callbacks return a zero native value while the JavaScript error propagates to its caller.
Repeated string inputs can retain a UTF-8 encoding alongside their JavaScript storage, avoiding encoding work on later synchronous calls. Long ASCII inputs can instead grow their existing backing to add a NUL terminator; short ones copy into the call's frame. Preparation increases retained memory and can cost more than copying for short reuse sequences. Mutable inputs, callbacks, and asynchronous calls use private input copies.
Describe what headers cannot say
Headers give types, not intent. A fact in the contract states the rest, and a fact that does not fit its declaration fails the build:
| What the C code does | Fact | Guide |
|---|---|---|
| Writes through a pointer, or reads and writes it | out, inout |
Pointers and buffers |
| Takes a buffer or array with a count | bytes, array with length |
Pointers and buffers |
| Takes or returns text | string |
Pointers and buffers |
Selects what a void * points at with another argument |
pointee with variants |
Pointers and buffers |
| Allocates a resource the program must release | handle with destroy |
C resources |
| Keeps owning a pointer it returns | borrowed |
C resources |
| Fills state the caller allocates | handle with storage: "caller" |
C resources |
| Calls back into the program | callback |
Callbacks |
| Returns a function pointer | callable |
Callbacks |
| Exposes globals, unions, or variadic functions | globals, shape, variants |
Globals, unions, and variadics |
Facts go on a function's params, keyed by C name or 1-based position, on its
returns, or on a type under types.
Find what cannot bind
maligator ffi inspect raylibThe report lists each function that is not exported and the fact it needs. It also suggests facts from naming and signature conventions: a count parameter beside a pointer, a pointer the function writes, the void * a callback receives or the function that returns it, a setter that replaces its callback, a struct with its own release function or an opaque one nothing releases, a getter's result as borrowed, a result whose documentation says how to free it. Each suggestion is checked against the headers and listed only when it makes a function bind, but it comes from names rather than the library's documentation, so review it before adding it to the contract.
--json includes the structured C model and the accepted facts, alongside mapped
signatures, header documentation, missing facts and suggestions in contract shape.
Each missing fact has a code naming the kind of fact it needs, such as handle,
callback or text, or unsupported when no fact binds it, and the C parameter it
belongs to. The model retains unresolved record fields and typedef chains, including
transitive system types, so a tool can follow a pointer's type before proposing a
fact. Its declaration tables are arrays, and exact C integer values are decimal
strings.
A contract error starts with the fact path it rejects, such as
functions.png_read_row.params.row.bytes, so the fix goes in that entry.
Audited native calls
By default, a native call can invoke callbacks registered through any configured library, including another binding to the same C dependency. An application whose closed module graph imports no export that can register a callback, in any library, compiles its calls without that reentry. If the C function's contract guarantees it never enters JavaScript, state that fact on the function:
functions: {
read_value: { callsJavaScript: false },
}This permits callback-free marshalling and compiler facts for eligible calls. Input getters and result cleanup keep their own effects: a struct getter can run JavaScript, and a configured release function can invoke an earlier callback. The fact gives no purity or thread-safety guarantee. Typed variadic variants inherit it from their C function; a returned function pointer has its own call contract.
Run blocking calls on a worker
async exports a function a second time, under a new name, as one that runs the C call on a worker thread and returns a promise of its result. A slow call, such as a database query or an image decode, then leaves the event loop free:
functions: {
sqlite3_step: { async: "sqlite3_step_async" },
},The arguments convert before the call starts, and a conversion error rejects the promise. Buffers and handles the call uses stay pinned until the promise settles: transferring or resizing such a buffer, or closing such a handle, throws. JavaScript can still write the buffer's bytes in the meantime, so leave them alone until then. A handle a callback lends is refused, since the call outlives the callback. The result converts when the promise settles, so a call that changes what a pointer result points to must wait until then. An async export copies array arguments, and the program needs an event loop, such as the web platform surface.
Call-scoped callbacks run on the program's thread while the C caller waits, including callbacks from threads the library creates. Without user data, the library must invoke the C callback on the worker executing that call. An unhandled callback exception rejects that call's promise; later callbacks in the same call return zero without running JavaScript. Exceptions caught inside a callback keep their ordinary behavior.
Callbacks retained through a release parameter can also be registered asynchronously.
They continue to queue and wait after registration settles, until C releases them.
Explicit queued: true callbacks continue without waiting. Replacement setters and
closes registrations can also have async twins. A close registration's promise
settles when its C call returns; a deferred completion remains retained afterward.
Async registration requires a handle the program holds, rather than a callback loan.
Failed scheduling leaves the handle open for retry. Once accepted, close registrations
drain on native workers during teardown, retaining caller storage and managed buffers
until the C completion arrives. For a synchronous registration, use queued if the
library calls back from other threads.
Use async only for functions the library allows on another thread while the program makes other calls, such as SQLite in its default serialized mode.
Handle field reads throw a TypeError while a worker call uses the handle, including
handles passed in arrays. Read fields after the call's promise settles.
A handle's destroy function can also have an async export. The handle rejects other
calls and disposal until destruction finishes. A conditional destroy preserves the
handle when C reports failure, so you can retry. VM teardown drains accepted destructors
on native workers and reconciles their results even when their promises are discarded.
A scheduling error leaves the resource open for retry.
For a constructor that returns a new resource lifetime, set returns.fresh to true.
This distinguishes a new allocation from an alias when C reuses the address
of a resource whose worker destruction has not returned. Without this fact, calls
that return the same handle family reject before entering C while its worker close
is pending. Existing aliases keep their shared identity; a fresh resource gets a new
wrapper and its own callbacks.
Check a library upgrade
C declarations can change without changing their TypeScript types. For example,
short and int both map to number, but accept different ranges. Record fields,
pointer qualifiers and accepted ownership facts can change too.
After reviewing a contract against the installed headers, record its declarations:
maligator ffi check --updateCommit maligator-ffi.lock.json with the contract. Fixed argument, cleanup status, and
documentation facts follow macro definitions through included headers, including
function-like helpers and token-pasted expansions. Supporting macros stay private unless
their headers belong to the declared library surface. Context-dependent macros such as
__COUNTER__ and __FILE__ cannot become automatic JavaScript constants or cleanup success
codes; fixed arguments remain C expressions evaluated by the C compiler.
Run maligator ffi check in CI to report changes
before accepting a library upgrade. The command reads the headers and facts and exits
with status 1 if they differ or have no accepted snapshot. Review
the changes, update the facts if needed, then run --update again.
Record snapshots include target size, alignment, field offsets, and declaration
attributes. Packing and alignment changes require review even when the generated
JavaScript types stay the same. Layout values in JSON use bits so bit-fields retain
their exact offsets. A reachable complete record without an identifiable layout
cannot be accepted.
Typedef snapshots also retain declaration attributes. Aliases with alignment
attributes record their effective target alignment, which can differ from the
alignment of the underlying record.
Ownership rules can change while the C signature stays the same. Cite the header documentation that supports a type or function fact so the check watches its text:
functions: {
shapes_name: {
returns: { free: "shapes_release" },
evidence: [{ kind: "function", name: "shapes_release" }],
},
},The kind can be function, typedef, struct, union, enum or macro.
The check includes documentation on parameters, fields and enumerators of the cited
declaration. Uncited comments do not affect it. A citation must resolve to extracted
documentation; removing the declaration or its documentation prevents an update.
Reviewing the text and the library's behavior remains the contract author's job.
Write Maligator tests that exercise the facts, such as releasing an owned resource or copying a counted buffer. Run them as a sanitizer probe before accepting an upgrade:
maligator ffi check raylib --probe tests/raylib.test.ts --updateThe probe uses UBSan on macOS and ASan+UBSan elsewhere. Its test output and source
identities are retained under .cache/ffi-check/. A failed or skipped-only probe,
a changed input or an edited lockfile prevents acceptance. --probe-timeout sets the
child process deadline in milliseconds, including time spent inside C; it defaults
to 30000. Probes run on the host. Prebuilt libraries need an instrumented build for
sanitizer coverage inside their code.
Use --target rust-triple to check or accept headers for a cross-build target. Each
target has its own snapshots, and ffi check raylib --update updates only raylib for
the selected target. Header snapshots and probes provide evidence for reviewing a
contract; they do not establish the linked binary's ABI or every ownership rule.
maligator test builds a test runtime with the bindings too. In test --watch,
JavaScript edits start a fresh application in the linked runtime, preserving the C
library's process state. Changes to contracts, headers, sources, or resolved library
files replace that runtime after its applications finish cleanup. Portable wire
output rejects C libraries.