Getting started
Call any native library with a C ABI from JavaScript in a few lines.
unffi works with any shared library that exports a C ABI — C, Rust, Go, Zig, C++, and more. One schema, one import, runs on Bun, Deno, and Node.
Install
bun add unffipnpm add unffinpm install unffiyarn add unffiOn Node.js, also install koffi:
npm install koffiBun and Deno ship FFI natively.
Build a shared library
You need a .dylib (macOS), .so (Linux), or .dll (Windows). Any language that exports a C ABI works.
int add(int a, int b) { return a + b; }
const char* greet(const char* name);clang -shared -fPIC -o math.dylib math.c # macOS
clang -shared -fPIC -o math.so math.c # LinuxSee Tutorials for Rust and Go examples.
Open it
Hover any identifier to see what TypeScript inferred from the schema — no annotations, no codegen.
import { , } from 'unffi'
const = await ('./math.dylib', {
: { : [., .], : . },
: { : [.], : . },
})
const sum = ..(2, 3)
const hello = ..('world')lib.symbols is fully typed from your schema — argument and return types are inferred, no casts needed.
Built-in system bindings
You do not always need to write a schema yourself. UnFFI ships focused OS bindings for common native libraries:
import { openCoreFoundation } from 'unffi/macos/CoreFoundation'
import { openLibc } from 'unffi/linux/libc'
import { openKernel32 } from 'unffi/windows/kernel32'These modules use the same schema inference and await using lifecycle as manual dlopen. See System libraries for the platform reference.
Lifecycle
dlopen returns an AsyncDisposable. The await using statement closes the handle when the block exits, even on throw.
{
await using lib = await dlopen('./math.dylib', {
add: { args: [t.i32, t.i32], returns: t.i32 },
})
lib.symbols.add(1, 2)
} // ← lib.close() hereManual lifecycle:
const lib = await dlopen('./math.dylib', {
add: { args: [t.i32, t.i32], returns: t.i32 },
})
try {
lib.symbols.add(1, 2)
} finally {
await lib[Symbol.asyncDispose]()
}close(), [Symbol.dispose](), and [Symbol.asyncDispose]() are all available and idempotent.
Passing callbacks
Put t.fn([...argTypes], returnType) in the schema where a function pointer argument is expected. Pass a plain JS function at the call site — unffi wraps it automatically.
import { , } from 'unffi'
const = await ('./sorter.dylib', {
: {
: [., ., .([., .], .)],
: .,
},
})
const = new ([3n, 1n, 4n, 1n, 5n, 9n])
..(, ., (a, ) => { return < ? -1 : > ? 1 : 0
})The comparator's a and b are typed as bigint because they correspond to t.i64 — JS matches C exactly, end to end.
The callback must stay alive for as long as native code might invoke it. Closing the library while a callback is in use is undefined behaviour.
Async / nonblocking calls
Mark a symbol async: true to run it off the JS thread. The bound function returns a Promise.
import { , } from 'unffi'
const = await ('./codec.dylib', {
: { : [., .], : ., : true },
})
const = new (1024)
const outLen = await ..(, .)Zero-copy buffers
Use t.buffer for TypedArray arguments — unffi passes the backing memory pointer directly, no copy.
import { , } from 'unffi'
const = await ('./fill.dylib', {
: { : [., .], : . },
})
const = new (1024)
..(, .)
// buf is mutated in-place by native code