UnFFI

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 unffi
pnpm add unffi
npm install unffi
yarn add unffi

On Node.js, also install koffi:

npm install koffi

Bun 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.

math.c
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    # Linux

See Tutorials for Rust and Go examples.

Open it

Hover any identifier to see what TypeScript inferred from the schema — no annotations, no codegen.

main.ts
import { ,  } from 'unffi'

const  = await ('./math.dylib', {
  :   { : [., .], : . },
  : { : [.],    : . },
})

const sum = ..(2, 3)
const sum: number
const hello = ..('world')
const hello: string

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() here

Manual 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, ) => {
a: bigint
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 ..(, .)
const outLen: number

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

On this page