---
title: Native FFI
url: "https://scriptc.dev/docs/ffi"
docs_index: /llms.txt
lastUpdated: 2026-10-18
---

> For an index of all documentation, see [/llms.txt](/llms.txt).

Native FFI lets a compiled program call functions in C libraries. Use it when your program needs an existing native library, a platform API, or a small helper you write in C. You declare the function in TypeScript, describe its C signature in a JSON manifest, and scriptc links the native code into the executable at build time.

## Call a C function

Declare the function in TypeScript without a body. The declaration gives the type checker its signature:

```ts title="main.ts"
declare function nativeScale(value: number): number;

console.log(nativeScale(21));
```

Implement the function in C:

```c title="native.c"
double native_scale(double value) {
  return value * 2.0;
}
```

Connect the two names in a manifest. `params` and `returns` give the C types, and `libraries` lists the objects or archives to link, relative to the manifest:

```json title="ffi.json"
{
  "ffi_format": 1,
  "functions": [
    {
      "name": "nativeScale",
      "symbol": "native_scale",
      "params": ["f64"],
      "returns": "f64"
    }
  ],
  "libraries": ["./libnative.a"],
  "system_libraries": []
}
```

Build the C code into an archive, then pass the manifest to scriptc with `--ffi`:

```console
$ clang -c native.c -o native.o
$ ar rcs libnative.a native.o
$ scriptc build main.ts --ffi ffi.json -o app
$ ./app
42
```

Only direct calls to that exact declaration become native calls. A function with a body, an overload, a generic declaration, an alias such as `const f = nativeScale`, or a shadowing local stays an ordinary TypeScript function.

For C++, export the function with `extern "C"` so it keeps an unmangled name. Pass the same manifest to `scriptc coverage` so the native calls count as compiled.

<span id="replacing-a-node-api-addon" />

## Replace a Node-API addon

Compiled programs can't load Node-API (`.node`) addons, because those depend on Node.js itself. To keep using an addon's native code, move the operation into a plain C function and call it through FFI. If you also publish the addon for Node.js, keep the Node-API registration and value conversion in a separate wrapper.

For example, a program that calls a small numeric helper from an addon:

```ts title="addon-client.ts"
import { createRequire } from "node:module";

const require = createRequire(import.meta.url);
const native = require("./native.node") as {
  scale(value: number): number;
};

console.log(native.scale(21));
```

becomes the `nativeScale` declaration and direct call shown in [Call a C function](#call-a-c-function), with the helper's logic in `native_scale`. Renaming a `.node` file, or linking an archive that still calls `napi_*` functions, doesn't remove its dependency on Node.js. See [native addon limitations](/docs/limitations#native-addons).

## ABI classes

Each parameter and result in the manifest has an ABI class that names its C type. TypeScript has only `number`, so the manifest, not the declaration, decides whether a value crosses as a double or as an integer of a specific width.

<table>
  <thead>
    <tr>
      <th>
        Manifest class
      </th>

      <th>
        TypeScript type
      </th>

      <th>
        C ABI type and behavior
      </th>

      <th>
        Parameter
      </th>

      <th>
        Return
      </th>
    </tr>
  </thead>

  <tbody>
    <tr>
      <td>
        <code>f64</code>
      </td>

      <td>
        <code>number</code>
      </td>

      <td>
        <code>double</code>
      </td>

      <td>
        yes
      </td>

      <td>
        yes
      </td>
    </tr>

    <tr>
      <td>
        <code>f32</code>

        (format 6)
      </td>

      <td>
        <code>number</code>
      </td>

      <td>
        <code>float</code>

        ; rounds to IEEE-754 single precision on the native boundary, widens back to a JavaScript number on return
      </td>

      <td>
        yes
      </td>

      <td>
        yes
      </td>
    </tr>

    <tr>
      <td>
        <code>bool</code>
      </td>

      <td>
        <code>boolean</code>
      </td>

      <td>
        <code>uint8\_t</code>

        ; inputs are 0 or 1, any nonzero return becomes

        <code>true</code>
      </td>

      <td>
        yes
      </td>

      <td>
        yes
      </td>
    </tr>

    <tr>
      <td>
        <code>u8</code>
      </td>

      <td>
        <code>number</code>
      </td>

      <td>
        <code>uint8\_t</code>

        ; inputs use JavaScript's modulo conversion
      </td>

      <td>
        yes
      </td>

      <td>
        yes
      </td>
    </tr>

    <tr>
      <td>
        <code>u32</code>
      </td>

      <td>
        <code>number</code>
      </td>

      <td>
        <code>uint32\_t</code>

        ; inputs use

        <code>ToUint32</code>
      </td>

      <td>
        yes
      </td>

      <td>
        yes
      </td>
    </tr>

    <tr>
      <td>
        <code>i8</code>

        ,

        <code>u16</code>

        ,

        <code>i16</code>

        (format 6)
      </td>

      <td>
        <code>number</code>
      </td>

      <td>
        <code>int8\_t</code>

        ,

        <code>uint16\_t</code>

        ,

        <code>int16\_t</code>

        ; modulo conversion like the corresponding JavaScript typed array
      </td>

      <td>
        yes
      </td>

      <td>
        yes
      </td>
    </tr>

    <tr>
      <td>
        <code>i32</code>
      </td>

      <td>
        <code>number</code>
      </td>

      <td>
        <code>int32\_t</code>

        ; inputs use

        <code>ToInt32</code>
      </td>

      <td>
        yes
      </td>

      <td>
        yes
      </td>
    </tr>

    <tr>
      <td>
        <code>cstring</code>
      </td>

      <td>
        <code>string</code>
      </td>

      <td>
        <code>const char \*</code>

        ; callback input only, copied through lossy UTF-8 decoding
      </td>

      <td>
        callback only (formats 3–7)
      </td>

      <td>
        no
      </td>
    </tr>

    <tr>
      <td>
        <code>string</code>
      </td>

      <td>
        <code>string</code>
      </td>

      <td>
        <code>const uint8\_t \*, size\_t</code>

        ; UTF-8 bytes, length-delimited
      </td>

      <td>
        yes; callback input in formats 3–7
      </td>

      <td>
        no
      </td>
    </tr>

    <tr>
      <td>
        <code>bytes</code>
      </td>

      <td>
        <code>Uint8Array</code>

        or

        <code>Buffer</code>
      </td>

      <td>
        <code>const uint8\_t \*, size\_t</code>

        ; raw bytes, length-delimited
      </td>

      <td>
        yes; callback input in formats 3–7
      </td>

      <td>
        no
      </td>
    </tr>

    <tr>
      <td>
        <code>void</code>
      </td>

      <td>
        <code>void</code>
      </td>

      <td>
        <code>void</code>
      </td>

      <td>
        no
      </td>

      <td>
        yes
      </td>
    </tr>

    <tr>
      <td>
        <code>mutable-bytes</code>

        (format 6)
      </td>

      <td>
        <code>Uint8Array</code>

        or

        <code>Buffer</code>
      </td>

      <td>
        <code>uint8\_t \*, size\_t</code>

        ; a writable borrow of the exact view, including its byte offset and length
      </td>

      <td>
        outbound calls only
      </td>

      <td>
        no
      </td>
    </tr>
  </tbody>
</table>

For ordinary outbound parameters, string and byte pointers are borrowed only for the duration of the call. Native code must not mutate <code>string</code> or <code>bytes</code> spans. Format 6's <code>mutable-bytes</code> permits in-place writes within the supplied length; mutations are visible through the original array and its aliases. Native code must not free or retain any borrowed pointer. Strings may contain embedded NUL bytes, so always use the supplied length; an empty span may have a null pointer. String and byte returns remain unsupported because they need an explicit ownership and allocator contract. Format 7 can return an opaque pointer as a bigint; scriptc does not infer its lifetime, allocation size, or deallocator.

Set <code>ffi\_format</code> to <code>6</code> to use the additional scalar widths or writable spans. The new scalar classes also work in call-scoped, retained, and foreign-thread callback parameters, and in same-thread callback returns. Foreign-thread delivery widens their values before queueing them without losing precision. Formats 1–5 retain their original class sets.

Set <code>ffi\_format</code> to <code>7</code> for <code>i64</code>, <code>u64</code>, and <code>pointer</code>. These classes use TypeScript <code>bigint</code> and the C types <code>int64\_t</code>, <code>uint64\_t</code>, and <code>void \*</code>. Integer inputs use the low 64 bits; pointer inputs use the low pointer-width bits. Results preserve every bit without conversion through a JavaScript number. They work in direct calls and same-thread callbacks, including retained callbacks. Foreign-thread callback parameters using these classes remain refused.

For operating-system helpers, wrap the system call in a fixed-signature C function too. For example, Linux's variadic `prctl` needs a wrapper that supplies its operation and trailing arguments. A wrapper returning C `int32_t` uses the manifest's `i32` return class and a TypeScript `number`; check its error result in TypeScript before using the value. Build the object or archive for the same target as the scriptc binary.

## Callbacks and context pointers

Many C APIs take a function pointer and an opaque `void *` context that they pass back to the callback. Format 2 lets you pass a TypeScript function in that position. The manifest describes the function pointer and the context as separate entries, each in its actual position in the C signature.

For example, this C function takes a callback first, a value second, and its context last. The callback itself receives the context last:

```c title="native.c"
typedef double (*map_callback)(double value, void *context);

double native_map(map_callback callback, double value, void *context) {
  return callback(value, context);
}
```

The TypeScript declaration contains only source values. Context entries are supplied by the compiler, so there is no context parameter in TypeScript:

```ts title="main.ts"
declare function nativeMap(
  callback: (value: number) => number,
  value: number,
): number;

const offset = 7;
console.log(nativeMap((value) => value + offset, 5));
```

The callback id connects the two independently positioned context entries. Both positions are explicit; no adjacency or conventional argument order is assumed.

```json title="ffi.json"
{
  "ffi_format": 2,
  "functions": [
    {
      "name": "nativeMap",
      "symbol": "native_map",
      "params": [
        {
          "callback": {
            "id": "map",
            "params": ["f64", { "context": "map" }],
            "returns": "f64",
            "lifetime": "call"
          }
        },
        "f64",
        { "context": "map" }
      ],
      "returns": "f64"
    }
  ],
  "libraries": ["./libnative.a"]
}
```

A callback descriptor consumes one TypeScript function parameter and one native function-pointer slot. A context entry consumes no TypeScript parameter and one native `void *` slot. Callback parameters can use `f64`, `bool`, `u8`, `u32`, and `i32` from format 2, `cstring`, `string`, and `bytes` from format 3, and the classes added by formats 6 and 7 as described in [ABI classes](#abi-classes), plus at most one context entry. Callback returns are scalar or `void`.

Format 3 string-bearing callback parameters copy native data before the closure runs. `cstring` reads one non-null, NUL-terminated `const char *`. `string` and `bytes` each consume a `const uint8_t *, size_t` pair; a null pointer is valid only when its length is zero. Text is decoded as UTF-8 with malformed sequences replaced by U+FFFD, matching `Buffer.toString("utf8")`. The resulting string or `Uint8Array` is freshly owned scriptc storage, so the closure may retain it without depending on the native buffer's lifetime. An unexpected null `cstring`, or a null non-empty span, traps at the boundary instead of being treated as empty.

For a raw call-scoped C callback type with no userdata, omit the context entry from both parameter lists. scriptc installs that closure in a binding-specific thread-local slot around the native call, so captures and nested calls still work.

### Retained callbacks

Format 4 adds `lifetime: "retained"` for same-thread native APIs that store a callback and invoke it from a later FFI call. Registration pins the closure and its captures. A paired release binding passes the same trampoline and closure context back to native code, then unpins one matching registration after the native call returns:

```ts title="main.ts"
declare function timerAdd(interval: number, tick: () => void): void;
declare function timerRemove(tick: () => void): void;

const tick = () => console.log("tick");
timerAdd(100, tick);
// A later native pump call may invoke tick here.
timerRemove(tick);
```

```json title="ffi.json"
{
  "ffi_format": 4,
  "functions": [
    {
      "name": "timerAdd",
      "symbol": "timer_add",
      "params": [
        "u32",
        {
          "callback": {
            "id": "tick",
            "params": [{ "context": "tick" }],
            "returns": "void",
            "lifetime": "retained"
          }
        },
        { "context": "tick" }
      ],
      "returns": "void"
    },
    {
      "name": "timerRemove",
      "symbol": "timer_remove",
      "params": [
        { "callback": { "release": "timerAdd:tick" } },
        { "context": "timerAdd:tick" }
      ],
      "returns": "void"
    }
  ]
}
```

The release argument must be the same function value used for registration. Registrations to a context-bearing descriptor are counted: registering the same closure twice requires two releases. A raw descriptor's slot has replace semantics instead — every set call supersedes the previous registration, including one that passes the already-registered closure, so exactly one release is ever pending for that descriptor. Releasing an unregistered value traps because native code may still hold the original pointer — the trap fires before the native release call runs, so native code never observes the invalid release. The callback's function type must be exact at retained call sites; an implicit wrapper would create a different pointer and make release identity unsound. An inline function literal as a release argument is rejected for the same reason: it creates a fresh closure at every evaluation, a pointer no registration holds — pass the same named value used to register. Registering an inline literal remains legal; such a registration is simply permanent and is dropped by the exit teardown. A single binding cannot both register and release the same descriptor — the manifest loader rejects a `release` targeting a retained callback declared in the same function's parameter list, because the register-then-release ordering within one call would defeat the pre-call release validation.

Context-bearing descriptors support multiple concurrent closures. A raw retained descriptor has no context pointer, so it has one slot on the script thread with replace semantics: the previous registration stays live and dispatching until the replacing set call returns (a native setter that flushes the outgoing callback mid-replace still reaches the old closure), then it is released and the slot commits to the new closure. Script-thread retained registrations do not keep the event loop alive; format 5 foreign registrations do. At process exit, `process` `'exit'` listeners run first — they may still release registrations or pump script-thread callbacks on every exit path. Foreign posting is disarmed when the loop stops, so a straggling native post is dropped without an error. On exits that run atexit handlers, the runtime then drops the remaining registrations and disarms raw slots; `process.exit()` terminates immediately after its listeners and skips that sweep, leaving remaining registrations to the operating system. A raw-slot invocation after teardown traps instead of reaching a freed closure. A context-bearing registration has no slot to disarm — its trampoline and context pointer dangle once teardown frees the closure — so native code must not invoke one after exit; a library that can fire on its own exit path should have its registrations released from a `process` `'exit'` listener.

Retained identity is scoped to the declaring binding. Every retained callback parameter is its own descriptor: the `<binding>:<callback-id>` pair names one registration ledger, one generated trampoline, and (for a raw descriptor) one slot, and a release binding validates and unpins only registrations made through the binding its `release` reference targets. Two bindings that store into the same native state — a plain setter and a flush-on-replace setter for one native slot, say — are therefore independent descriptors that pass native code two different function pointers. Registering the same function value through both and then releasing it through one is unsound: the release unpins in its own descriptor's ledger, but native code compares stored pointers against the other descriptor's trampoline, so the surviving registration stays armed and keeps dispatching — nothing traps, and the callback keeps firing after the program believes it released it. Keep a function value registered with one such native registration point through exactly one binding at a time, and release it through that binding's paired release.

### Callbacks from other threads

Format 5 adds `invoke: "foreign"` to a retained, context-bearing callback descriptor for libraries that invoke the callback from their own threads:

```json title="ffi.json"
{
  "ffi_format": 5,
  "functions": [
    {
      "name": "timerAdd",
      "symbol": "timer_add",
      "params": [
        "u32",
        {
          "callback": {
            "id": "tick",
            "params": ["cstring", { "context": "tick" }],
            "returns": "void",
            "lifetime": "retained",
            "invoke": "foreign"
          }
        },
        { "context": "tick" }
      ],
      "returns": "void"
    }
  ]
}
```

The native trampoline never runs script code. It copies scalar values and native string/byte memory into plain staging storage, posts to the process event loop, and returns immediately—even if native code happened to invoke it on the script thread. The loop delivers one invocation per turn, FIFO by enqueue order, with microtasks and timers interleaved. Live foreign registrations are ref'd: they keep the loop alive until their paired release binding runs. A callback may release itself; already-enqueued deliveries remain valid and drain before its closure pin is dropped. Throws follow ordinary timer-callback behavior and are uncaught unless the surrounding loop-dispatch semantics catch them.

Foreign delivery is deliberately fire-and-forget. It requires `lifetime: "retained"`, `returns: "void"`, and a context entry. Value-returning foreign callbacks would have to block the library thread on the script loop and are refused as deadlock-prone. Delivery takes at least one loop turn and is not suitable for real-time work such as audio DSP. Direct execution of script closures on native threads remains permanently unsupported because reference counting, exception cells, and fibers are thread-confined. Dereferenceable struct callback parameters are also unsupported; use an opaque native handle with accessor functions when the API permits it.

### Exceptions in callbacks

If a script-thread callback throws, the adapter returns zero (or `void`) to native code and suppresses further script callback execution while the exception is pending. When the outer native function returns, the original exception resumes through scriptc's ordinary catchable unwind path. Native work performed between the callback's return and the outer function's return is not rolled back. A foreign callback's native trampoline has already returned before its closure runs; a throw therefore follows event-loop callback semantics instead.

## Static bindings for node:ffi

JavaScript packages that load `node:ffi` through `createRequire` can run unchanged with a format 7 manifest. Add a `library` string to each function to make it available through `ffi.dlopen`. The string must match the library name the package passes to `dlopen`; the implementation comes from the objects or archives the manifest links, so nothing is loaded at runtime.

```json title="ffi.json"
{
  "ffi_format": 7,
  "libraries": ["./native.o"],
  "functions": [
    {
      "name": "nativeU64",
      "symbol": "native_u64",
      "library": "native",
      "params": ["u64"],
      "returns": "u64"
    }
  ]
}
```

```c title="native.c"
#include <stdint.h>
uint64_t native_u64(uint64_t value) { return value; }
```

```js title="app.mjs"
import { createRequire } from "node:module";
const ffi = createRequire(import.meta.url)("node:ffi");
const { lib, functions } = ffi.dlopen("native", {
  nativeU64: { arguments: ["u64"], return: "u64" }
});
console.log(functions.nativeU64(9007199254740993n));
lib.close();
```

Catalog entries support scalar and pointer signatures. Each requested symbol must have the compiled argument and return types. Calls check arity and integer ranges, and 64-bit arguments must be bigints. In this Node API, `bool` is an alias for `u8`, so its arguments and results are numbers. The direct manifest API retains its boolean convention.

Pointer arguments accept a non-negative bigint address, an ArrayBuffer or view, a NUL-free UTF-8 string, or nullish values for a null pointer. Storage stays alive during the call. `getRawPointer` returns an ArrayBuffer or view's address, including a view's byte offset. `toArrayBuffer(pointer, length)` copies memory; passing `false` as its third argument creates a borrowed view. The caller must keep foreign memory valid for the entire lifetime of a borrowed view and must supply a readable range.

For packages that call `lib.registerCallback`, declare each callback signature in the root `callbacks` array:

```json
"callbacks": [
  { "library": "native", "params": ["u64"], "returns": "u64", "capacity": 16 }
]
```

The compiler emits a fixed pool of native function pointers for each signature. Capacity defaults to 16 and may be 1–256. Registrations occupy independent slots; unregistering a callback or closing its library releases its slots. Exhausting the compiled capacity throws. These callbacks run on the registering script thread; calling one from a foreign thread is unsupported. Native code must stop using a callback pointer before unregistering it or closing its library. Closing a library also makes its saved function wrappers reject further calls.

Callback returns undergo the same scalar range checks; pointer returns must be nullish or a non-negative bigint. Callback exceptions follow scriptc's deferred exception contract: the native call receives a zero result, and the exception reaches the calling script frame when that native call returns.

On Darwin targets, a format 7 manifest may name system frameworks in `frameworks`, such as `["Foundation"]`. Objects and archives still belong in `libraries`, and ordinary system libraries belong in `system_libraries`.

## Manifest fields

<dl>
  <dt>
    <code>ffi\_format</code>
  </dt>

  <dd>
    Required. Format

    <code>1</code>

    supports value parameters; format

    <code>2</code>

    adds callback and context entries; format

    <code>3</code>

    adds copied

    <code>cstring</code>

    , string, and byte callback parameters; format

    <code>4</code>

    adds retained registrations and release references; format

    <code>5</code>

    adds retained callbacks from other threads, delivered on the event loop; format

    <code>6</code>

    adds

    <code>f32</code>

    ,

    <code>i8</code>

    ,

    <code>u16</code>

    ,

    <code>i16</code>

    , and outbound

    <code>mutable-bytes</code>

    spans; format

    <code>7</code>

    adds

    <code>i64</code>

    ,

    <code>u64</code>

    , and

    <code>pointer</code>

    , plus the

    <code>library</code>

    ,

    <code>callbacks</code>

    , and

    <code>frameworks</code>

    fields used by

    <a href="#static-bindings-for-nodeffi"><code>node:ffi</code> bindings</a>

    . Each format accepts everything earlier formats do.
  </dd>

  <dt>
    <code>functions</code>
  </dt>

  <dd>
    Required array. Every entry has

    <code>name</code>

    ,

    <code>symbol</code>

    ,

    <code>params</code>

    , and

    <code>returns</code>

    , and in format 7 an optional

    <code>library</code>

    . Binding names and symbols must be unique. Callback ids must be unique within a function and every context must match exactly one callback or release. A release references a retained

    <code>\<binding>:\<callback-id></code>

    in the same manifest and inherits its callback ABI.
  </dd>

  <dt>
    <code>libraries</code>
  </dt>

  <dd>
    Optional array of archive or object paths. Relative paths are resolved from the manifest directory and appended after the generated program at link time.
  </dd>

  <dt>
    <code>system\_libraries</code>
  </dt>

  <dd>
    Optional array of linker-neutral library names. For example,

    <code>\["m"]</code>

    is emitted as

    <code>-lm</code>

    .
  </dd>

  <dt>
    <code>frameworks</code>
  </dt>

  <dd>
    Format 7, Darwin targets. Optional array of system framework names, such as

    <code>\["Foundation"]</code>

    .
  </dd>

  <dt>
    <code>callbacks</code>
  </dt>

  <dd>
    Format 7. Optional array of callback signatures for

    <code>node:ffi</code>

    <code>registerCallback</code>

    , each with

    <code>library</code>

    ,

    <code>params</code>

    ,

    <code>returns</code>

    , and an optional

    <code>capacity</code>

    from 1 to 256 (default 16).
  </dd>
</dl>

Unknown fields, invalid ABI classes, duplicate names, and signature mismatches fail the build with an `SC5xxx` diagnostic.

## Boundary rules and current limits

- Native calls are synchronous and must return normally. Do not unwind C++ exceptions or `longjmp` across the boundary.
- Native code is outside scriptc's exception, reference-counting, and sanitizer contracts. A bad pointer or mismatched C signature can still corrupt the process.
- `invoke` defaults to `"script-thread"`. Callbacks from other threads (format 5) are asynchronous, return `void`, need a context entry, are released explicitly, and aren't suitable for real-time work; TypeScript code never runs directly on a foreign thread.
- There are no variadic calls, struct-by-value arguments, or owned pointer returns. The `node:ffi` catalog resolves only bindings compiled into the binary; runtime `dlopen`/`dlsym` loading remains unsupported.
- The archive or object must match the build target. Cross-compilation does not translate native inputs.
- Outbound FFI is currently available for executable builds, not `scriptc build --lib`.

---

For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)

For an index of all available documentation, see [/llms.txt](/llms.txt)

For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md)