Python ctypes for Node.js. A foreign function interface built with C++20, node-addon-api and libffi. If you know Python ctypes, you already know node-ctypes.
Development help is welcome: issues, pull requests and discussions are all appreciated.
Tip
If you find node-ctypes useful, consider ⭐ this project and why not ... Buy me a coffee 😄
npm install node-ctypesPrebuilt binaries ship for Windows, macOS 13.5+ and Linux (glibc 2.28+ or musl / Alpine), on x64 and ARM64 — wherever Node 24 runs.
import { CDLL, Structure, c_int32, c_size_t, c_char_p } from "node-ctypes";
// 1. Load a shared library
const libc = new CDLL(process.platform === "win32" ? "msvcrt" : null);
// 2. Declare a function signature
const strlen = libc.func("strlen", c_size_t, [c_char_p]);
console.log(strlen("hello")); // 5n
// 3. Declare a struct
class Point extends Structure {
static _fields_ = [
["x", c_int32],
["y", c_int32],
];
}
const p = new Point(3, 4);
console.log(p.x, p.y); // 3 4Build from source
Requires Node.js >= 24, CMake >= 3.15, and a C++20 compiler and standard library. Run these commands from a source checkout:
npm install
npm run buildTwo equivalent styles — use whichever reads better:
// Python-style: set argtypes/restype on the bound symbol
const abs = libc.abs;
abs.argtypes = [c_int32];
abs.restype = c_int32;
abs(-42); // 42
// One-shot: declare signature at lookup
const abs2 = libc.func("abs", c_int32, [c_int32]);
abs2(-42); // 42For Windows __stdcall APIs use WinDLL instead of CDLL. For COM-style
auto-HRESULT-checking use OleDLL.
Direct native calls are limited to a bounded set of exact scalar C++ signatures with up to four arguments, on 64-bit little-endian targets using the default ABI. Other signatures use the existing cached libffi plans/CIFs, including pointers and strings whose pointee types and qualifiers are not encoded in the native type metadata. Float/double arguments continue to use libffi on x86-64. This changes the optimization route, not the JavaScript API.
import { Structure, Union, array, c_int32, c_uint32, c_float, c_char } from "node-ctypes";
class Rect extends Structure {
static _fields_ = [
["x", c_int32],
["y", c_int32],
["w", c_int32],
["h", c_int32],
];
}
class Value extends Union {
static _fields_ = [
["i", c_int32],
["f", c_float],
];
}
// Bit fields: [name, type, bitCount]
class Flags extends Structure {
static _fields_ = [
["enabled", c_uint32, 1],
["mode", c_uint32, 3],
["priority", c_uint32, 4],
];
}
// Arrays: elementType * count (Python: `c_int32 * 5`)
const IntArr5 = array(c_int32, 5);
const arr = IntArr5(1, 2, 3, 4, 5); // Python: (c_int32 * 5)(1, 2, 3, 4, 5)
// char arrays read and write as strings (Python: bytes)
const name = array(c_char, 16)();
name.value = "node"; // .value / .raw as in Pythonarray(type, n) reuses the same type while it is alive. Instances, function
signatures and containing types keep their array types alive; the cache itself
does not. Unused array types and their length entries can be garbage-collected,
including arrays of builtin element types.
An array type in argtypes is passed by address, as in C and Python
(fill.argtypes = [c_int32 * 4]). As in Python, the argument must be an
instance of an array with the same element type and length; a Buffer or a
typed array is also accepted as its memory. Other values raise
ArgumentError.
Builtin numeric scalars accept numbers, booleans and BigInt; integers wrap to
their C width, but reject non-integral or non-finite numbers. Use BigInt when
the original integer needs more precision than a JS number can represent.
No argument, or undefined in a constructor, initializes zero. Explicit
null, strings and arbitrary objects are not implicit numeric conversions.
Assigning a scalar instance to a field or passing it to a declared numeric
argument requires a compatible type. The documented numeric-pointer argument
extensions below are unchanged.
c_bool and wintypes.VARIANT_BOOL use JavaScript truthiness, using .value
for scalar instances rather than treating every instance as true.
VARIANT_BOOL stores true as -1, and its results and callback arguments
are booleans. Boolean bitfields normalize before masking and read as booleans.
Nested structs, anonymous fields (static _anonymous_ = ["data"]),
explicit packing (static _pack_ = 1), minimum alignment
(static _align_ = 16), self-referential pointers (POINTER(() => Node))
and subclasses that add fields to their base class all work identically to
Python. The layout of bit fields follows Python's _layout_: "gcc-sysv"
by default, "ms" on Windows or with _pack_. Bit fields wider than 53
bits read as BigInt.
_pack_ follows Python's integer conversion and permits zero and non-powers
of two (for example 3) with the MS layout. An explicit _pack_, including
zero, is incompatible with "gcc-sysv". Layouts with non-power-of-two
alignment can be used for memory serialization but are rejected as native
by-value signatures: they do not describe a valid C ABI alignment.
Structures and unions passed or returned by value keep this layout, also
with _pack_: as with GCC and clang, a structure with misaligned fields is
passed in memory on x86-64 System V (Linux, macOS Intel), where Python
ctypes passes it in registers and the called function reads wrong values.
Each returned structure or union owns a snapshot of its value, including
results declared with functional struct() / union() definitions. Array
views, nested composites and union getters remain valid after later calls,
reentrant conversion or errcheck, and concurrent callAsync() jobs.
Mutating one result does not modify another result's value storage.
This is not a deep copy through pointers: pointer fields can still refer to
the same external memory. Explicit definition.toObject(buffer) keeps its
existing buffer-view behavior. Synchronous low-level native struct_buffer
results are borrowed, reusable storage; public callables snapshot their
results before any JavaScript result conversion.
Low-level native StructType descriptors are builders only until consumed.
Adding a descriptor as a field, constructing an ArrayType from it (even at
length zero), preparing a function/callback signature, or creating/reading a
native value finalizes the captured layout and its dependencies.
addField() then throws instead of changing sizes, offsets or FFI metadata
already in use. Build a new descriptor for a different layout.
getSize() and getAlignment() alone leave an unused builder open; an
outer builder can still receive fields after it captures a finalized child.
Direct by-value self-reference is rejected. Pointers do not capture or
finalize the pointed-to layout.
import { POINTER, pointer, cast, c_int32, c_void_p } from "node-ctypes";
// Pointer to an existing c-data instance (Python: pointer(x))
const x = new c_int32(42);
const px = pointer(x);
px.contents.value; // 42 (Python: px.contents.value — a live c_int32)
px[0]; // 42 (Python: px[0])
px[0] = 100;
x.value; // 100 (same memory)
// View existing memory as a typed pointer
const IntPtr = POINTER(c_int32);
const p = IntPtr.fromBuffer(someBuffer);
p[0];
p[1];
p[2]; // pointer arithmetic
// cast() — Python: cast(ptr, POINTER(T))
const q = cast(someBuffer, POINTER(c_int32));
const r = cast(addressAsBigInt, POINTER(MyStruct));A POINTER(T) restype returns a pointer object, and a NULL pointer (return
value or struct field) is null — where Python returns a falsy pointer.
To hand a raw address to a function, use c_void_p as the argtype for
portable code (Python accepts raw int only there). As in Python, values
that cannot be converted (a plain JS function, an object) raise
ArgumentError instead of passing NULL; c_void_p also accepts strings
(as char *, Python bytes) and any TypedArray/ArrayBuffer.
Declared POINTER(T) parameters reject incompatible ctypes instances,
arrays and pointer types before entering C. Compatible subclasses and arrays
are accepted, and a matching scalar/structure is passed by reference.
These implicit scalar/array/NULL conversions apply to direct arguments only.
To adapt one of these values through _as_parameter_, return an explicit
compatible pointer or byref value instead, as in CPython.
An explicit cast() changes the pointer type. Raw addresses and raw
Buffer/TypedArray/ArrayBuffer memory remain node-ctypes extensions; use
c_void_p for portable raw-address code.
byref(value, byteOffset = 0) preserves the original owner and supports
signed byte offsets. In-range references remain Buffer views; references
outside the object's byte range carry an address and owner. As with raw
addresses, the caller must ensure such memory is valid before dereferencing.
Function pointers read from fields or obtained from FFI bindings can be
assigned back to fields/arrays and passed as pointer arguments. Bare JS
functions still require callback() / a prototype-created callback.
import { callback, CFUNCTYPE, c_int32, c_void_p, readValue } from "node-ctypes";
// Python-style type: CFUNCTYPE(restype, ...argtypes)
const CompareFn = CFUNCTYPE(c_int32, c_void_p, c_void_p);
const cmp = new CompareFn((a, b) => readValue(a, c_int32) - readValue(b, c_int32));
// Or the one-shot form:
const cmp2 = callback((a, b) => readValue(a, c_int32) - readValue(b, c_int32), c_int32, [c_void_p, c_void_p]);
// Pass the callback itself (as in Python) or its address `cmp.pointer`.
qsort(arr, 5, 4, cmp);
// CFUNCTYPE types also work as struct fields (read back as a callable).
// IMPORTANT: release the trampoline when done. Unlike Python, we cannot
// rely on GC — the callback holds a libffi closure that native code may
// still reference.
cmp.release();
cmp2.release();CFUNCTYPE and WINFUNCTYPE weakly intern live prototypes by the complete
identity and order of their argument/return types, separately for each calling
convention. Repeated requests reuse the same live prototype, as in Python,
without keeping abandoned types alive. Callers, callbacks and output bindings
retain their prototype while they are live.
Prototype metadata is shared. Changing its public restype / argtypes
metadata does not recompile the captured native signature or alter cache keys;
request a different signature to change the ABI. Each new caller prepares its
argument-conversion pipeline and captures its result-check hook independently.
Existing callers retain their result-check snapshot and their own errcheck.
An installed from_param converter still resolves the method at call time,
as before.
Both callbacks and libraries implement Symbol.dispose, so they work
with the using declaration for scope-bound cleanup (also on throw):
{
using libc = new CDLL(null);
using cmp = callback(fn, c_int32, [c_void_p, c_void_p]);
qsort(arr, 5, 4, cmp);
} // close() + release() run automaticallyA bound function keeps its library loaded as long as it is referenced, also
when the CDLL object itself is no longer used. After close(), loaded is
false and its bound functions and symbol lookups throw; close() itself
throws while a call to one of them is in progress (from a callback, or a
pending callAsync()). Repeated close() calls are harmless.
Existing in_dll() views keep the underlying module loaded even after
close(). Release of the native library handle is deferred until the last
dependent view, Buffer, typed array or typed pointer copy is collected; those
existing views remain usable. Actual physical unloading depends on the OS loader: for example,
musl keeps shared libraries resident until process exit even after dlclose().
Raw addresses obtained with symbol() do not carry this ownership information.
Set NODE_CTYPES_DEBUG_CALLBACKS=1 to log a stack trace for every
callback garbage-collected without .release().
Callbacks invoked by native threads need threadSafeCallback(): the
JavaScript function runs on the main thread, and the calling thread waits
for its result (several threads can call it at the same time; each gets the
result of its own call, 0 if the function throws). Like a timer, a
thread-safe callback keeps the event loop
alive until released; .unref() lets the process exit when nothing else
is pending (calls are still delivered while it runs), .ref() restores it:
const onEvent = threadSafeCallback((code) => console.log("event", code), null, [c_int]).unref();
startWorker(onEvent); // a native library calling onEvent from its own threadFor an embedding runtime that must detach its native thread state before waiting,
the optional fourth argument can be { abi, nativeContext: { suspend, resume, onError, notify? } }
instead of the existing ABI string. Each hook accepts a ctypes native function,
a low-level function handle, or a nonzero BigInt function address:
const onRequest = threadSafeCallback(handleRequest, c_int, [c_int], {
nativeContext: {
suspend: runtime.func("save_context", c_void_p, []),
resume: runtime.func("restore_context", null, [c_void_p]),
onError: runtime.func("install_error", null, [c_void_p]),
notify: runtime.func("wake_host", null, []),
},
});Function objects resolve through _as_parameter_ or address once, when the
callback is constructed. The hooks are called directly using the default C ABI;
the function wrapper's JS converters and errcheck are not invoked:
void *suspend(void)runs on a foreign caller before queueing.void resume(void *)restores that caller's saved state after dispatch, including queue failure or environment shutdown. Neither hook runs on the Node host fast path.void onError(void *)consumes an opaque error pointer on the original caller thread, after restoration. During the JS callback, usecallback.setNativeError(pointer)once and return the native API's required error sentinel (NULL,-1, etc.). Error payloads belong to each invocation, including concurrent and nested calls; JS exceptions are still reported as documented above.- Optional
void notify(void)runs on the foreign caller after successful queueing, while its native state is detached. It may wake the host through the consumer's runtime, but must not execute JavaScript itself.
callback.drain() executes a snapshot of pending calls on the owning Node
thread, returning the number it claimed. Each call executes at most once,
whether the TSFN or a drain gets there first. Delayed TSFN deliveries retain
only the shared request state and do not touch completed calls' native arguments.
The consumer must provide a safe way to reenter the host and call drain();
calling it before entering a blocking native wait is not sufficient.
Hooks must not throw or execute JavaScript. Plain JS functions and known JS-backed callback wrappers are rejected; an address cannot prove a function's signature, validity or implementation. Supplied function objects are retained by the native dispatcher through release and queued delivery cleanup. This does not prevent explicitly closing their libraries or releasing their underlying resources; raw addresses carry no owner. Keep the callback and the hook libraries alive until all native calls have returned; explicit release is rejected while a native-context call is active. These hooks do not acquire the host runtime context, pump a blocked host event loop automatically, or implement interpreter ownership/subinterpreters. The host JS handler must acquire/release its own runtime context around native API operations. The ordinary callback path is unchanged when no hooks are supplied.
Both callback implementations store narrow integer results in a complete
libffi ffi_arg, with the declared signedness; failures zero that complete
slot. Floating-point results retain their own storage width. The native
Callback / ThreadSafeCallback classes also expose getPointer(), which
throws after release.
GetLastError() / get_errno() read a private thread-local slot —
exactly like Python's ctypes.get_last_error() / ctypes.get_errno().
The slot is updated only by FFI calls through libraries opened with
use_last_error or use_errno; unflagged calls leave it unchanged. As in Python
those calls swap it with the system value: the function sees the value set
with set_errno() / set_last_error(), the value it leaves becomes the
private one, and the system value is restored after the call.
const user32 = new WinDLL("user32.dll", { use_last_error: true });
const FindWindow = user32.func("FindWindowW", c_void_p, [c_wchar_p, c_wchar_p]);
FindWindow("NoSuchClass", null);
GetLastError(); // top-level (Python: ctypes.get_last_error)
user32.get_last_error(); // per-library aliasThe snapshot is captured in native code immediately after the native call,
whether direct or through libffi, before any other JS can clobber it. To read the raw system
error instead, call GetLastError via FFI like any other function.
wintypes is Python's ctypes.wintypes: the same types (BOOL, BYTE,
WORD, DWORD, HANDLE, HWND, LPARAM, WPARAM, LPCWSTR, …), structures (RECT,
POINT, SIZE, MSG, FILETIME, WIN32_FIND_DATAW, …) and pointer types
(LPDWORD = POINTER(DWORD), LPRECT, PHANDLE, …), plus common extras
(LRESULT, UINT_PTR, LSTATUS, …).
import { WinDLL, Structure, POINTER, byref, wintypes } from "node-ctypes";
const { WORD, HWND, BOOL, RECT, LPRECT } = wintypes;
const kernel32 = new WinDLL("kernel32.dll");
class SYSTEMTIME extends Structure {
static _fields_ = [
["wYear", WORD],
["wMonth", WORD],
["wDayOfWeek", WORD],
["wDay", WORD],
["wHour", WORD],
["wMinute", WORD],
["wSecond", WORD],
["wMilliseconds", WORD],
];
}
kernel32.GetLocalTime.argtypes = [POINTER(SYSTEMTIME)];
kernel32.GetLocalTime.restype = null;
const st = new SYSTEMTIME();
kernel32.GetLocalTime(byref(st));
const user32 = new WinDLL("user32.dll");
user32.GetClientRect.argtypes = [HWND, LPRECT];
user32.GetClientRect.restype = BOOL;
const rect = new RECT();
user32.GetClientRect(hwnd, byref(rect));
rect.right - rect.left; // LONG (c_long) values are BigIntSet fn.errcheck = (result, func, args) => ... to check or replace a result,
or set it to null to clear it. For lib.func(), library attributes and
CFUNCTYPE / WINFUNCTYPE callers, func is the public callable and args
contains the original, unconverted arguments. Result conversion and any
OleDLL HRESULT check run before the hook.
Each invocation captures its hook before argument adapters or getters run.
Replacing or clearing it affects later invocations, including reentrant calls,
not the call already in progress or a pending callAsync() job.
Hooks do not prevent otherwise unreachable functions and libraries from being
garbage-collected, even when they reference the function itself. Active calls
and workers retain their captured hook through result checking; close() is
still rejected while that checking runs.
The low-level native handle's setErrcheck() API instead passes the native
handle as func, invoked with .call(), not a directly callable function.
Builtin scalar types return ordinary JS values. A subclass instead produces an instance containing the original C bytes, without invoking its constructor, public buffer factory or writer. Derived scalar fields, array elements, pointer indexing and callback arguments likewise expose typed instances; memory views remain live, including in mirror mode.
The result pipeline is:
C bytes -> restype representation -> _check_retval_ -> errcheck -> OUT/INOUT.
A static _check_retval_ receives the represented result and can replace it
or throw. It is captured when the function is bound; reassigning an attribute's
restype rebuilds that binding.
class CheckedInt extends c_int32 {
static _check_retval_(value) {
return value.value + 100;
}
}
const checkedAbs = libc.func("abs", CheckedInt, [c_int32]);
checkedAbs(-7); // 107Explicit HRESULT validation runs before errcheck with all loaders and
prototype callers, not only OleDLL. An error in result checking prevents
both errcheck and output conversion.
OleDLL attributes and func(name) default to HRESULT; other loaders
default to c_int. An explicit restype always overrides the default.
When at least one out param is declared, the call returns only the
out-params (single value, or array if >1) — the restype is
discarded. To also inspect it (e.g. a thread ID or success flag), attach an
errcheck:
const { DWORD, HWND, LPDWORD } = wintypes;
const proto = WINFUNCTYPE(DWORD, HWND, LPDWORD);
const fn = proto.bind(user32, "GetWindowThreadProcessId", [
{ dir: "in", name: "hWnd" },
{ dir: "out", name: "pid" },
]);
const pid = fn(hwnd); // positional; thread ID is discarded
const p2 = fn({ hWnd: hwnd }); // or named-args
fn.errcheck = (threadId, func, args) => {
if (!threadId) throw new Error("call failed");
return args; // continue returning the PID from the output instance
};
const p3 = await fn.callAsync(hwnd);
fn.errcheck = (threadId, func, args) => {
if (!threadId) throw new Error("call failed");
return { threadId, processId: args[1].value };
};
const ids = fn(hwnd); // inspect both the return value and the output parameterAs in Python, the value returned by errcheck is the result of the call,
unless it is args itself: then the out-params are returned. Here args
is the expanded argument list, with out-params as instances.
These bindings have their own hook, independent of any cached lib.func()
binding, and support callAsync() with the same output processing and
per-call hook snapshot. Adapter temporaries stay alive through that processing,
also when the hook throws.
OUT conversion invokes the output instance's __ctypes_from_outparam__().
By default a builtin scalar yields its value, while a scalar subclass yields
the instance. Override the method for custom output conversion.
Use { dir: "inout", name: "value" }, or tuple flag 3, to pass an existing
instance and return that exact instance after C modifies it. INOUT does not
call __ctypes_from_outparam__; positional, named and default arguments use
the same rule. Mixed outputs preserve parameter order. If errcheck returns
anything other than the original args, neither OUT nor INOUT unpacking runs.
// Zero-copy view over a Node Buffer (Python: Structure.from_buffer)
const r = RECT.from_buffer(packet, 16);
// Wrap an externally-allocated pointer (Python: Structure.from_address)
const r2 = RECT.from_address(malloc_result);
// Bind a C-exported global (Python: c_int.in_dll)
const tz = c_int32.in_dll(libc, "_timezone");
// Raw bytes at an address (Python: (c_ubyte * n).from_address(addr));
// `bytes._buffer` is a Buffer over that memory, as Python's memoryview()
const bytes = array(c_uint8, 64).from_address(malloc_result);
const view = new Float32Array(bytes._buffer.buffer, bytes._buffer.byteOffset, 16);Typed pointers read from fields or array elements, and views derived from
them, retain their known memory owners. Typed assignments and
from_buffer_copy() preserve known pointer references, including overlapping
and self-copies; replacing a pointer releases its previous reference.
Raw copies (memmove(), Buffer methods) do not transfer ownership metadata.
Likewise, from_address() given a raw address cannot discover its allocation
or library: the caller must keep that memory valid. Keeping a known owner
alive does not take over responsibility for freeing externally allocated
memory or explicitly releasing callbacks.
memmove() and memset() return the destination address as BigInt, including
zero-length operations (0n for a NULL destination with count zero).
Counts must be non-negative safe integers. Copies/fills involving JS memory
still stop at its end; raw native addresses remain the caller's responsibility.
Scalar from_buffer() / from_buffer_copy() validate byte size and offset
before producing an instance and accept Buffer, typed views and ctypes memory.
Electron does not allow Buffers over memory that V8 did not allocate, so a
Buffer view of native memory cannot exist there. node-ctypes detects this
when it loads (EXTERNAL_BUFFERS is false) and keeps the same API:
- instances on native memory (
from_address,in_dll,pointer.contents,p[i], pointer fields) work on a copy that is re-read before every access and written back after every assignment, so they still see what C writes and C sees what JavaScript writes; - passed to a C function,
addressof(),pointer(),memmove()andmemset(), such an instance uses the original address; - the
_bufferof such an instance is the copy: a typed array or Buffer method over it does not read or write native memory. Use the instance (arr[i],s.field) ormemmove().
Only the bytes of the accessed field are copied, one native call per read
or write; instances on ordinary Buffers (new Point(), c_int(5)) do not
need this native-memory synchronization. Set NODE_CTYPES_NO_EXTERNAL_BUFFERS=1
to run in this mode on Node.js.
// Instance protocol: auto-unwrap on call
class Handle {
constructor(h) {
this._as_parameter_ = h;
}
}
user32.CloseHandle(new Handle(hwnd));
// Class protocol: custom conversion in argtypes
class StrLen extends c_int32 {
static from_param(s) {
return typeof s === "string" ? s.length : s;
}
}
const abs = libc.func("abs", c_int32, [StrLen]);
abs("hello"); // 5As in Python, from_param receives the argument as passed (also with
CFUNCTYPE prototypes), its result is converted as the declared type, and
an error it throws becomes ArgumentError ("argument 1: RangeError: ...").
Objects passed through argument adapters and the objects produced by their
conversions stay alive while later arguments are converted and until the
result and errcheck have been processed. With callAsync(), those temporary
owners stay alive until its Promise settles. The native worker also retains
memory passed by value or as an integer address, including values materialized
by getters.
_as_parameter_ chains are resolved recursively, including results of
from_param, with intermediate owners retained. As in CPython, an adapter may
be visited again when a stateful getter eventually returns a usable value;
object identity alone is not rejected as a cycle, and there is no fixed
128-step limit. Non-terminating recursion raises an argument conversion error.
The available recursion depth and stack-error wording follow the JavaScript
runtime, rather than CPython's runtime-specific recursion guard.
This only covers the call: if C stores a pointer for later use, or returns a
pointer into an argument's memory, keep the source object referenced for as
long as that pointer is used. Callbacks still require explicit .release().
class NetHeader extends BigEndianStructure {
static _fields_ = [
["magic", c_uint32],
["length", c_uint32],
];
}
const h = new NetHeader();
h.magic = 0x12345678; // stored as 12 34 56 78 on the wireLittleEndianStructure, BigEndianUnion, LittleEndianUnion work the
same way. Byte order is per-class — nested structs keep their own.
Primitive arrays also follow their containing class's byte order, recursively for multidimensional arrays, including floats and wide-character arrays. Arrays of structures keep the element structure's own byte order. The same array type can be shared with native/little-endian containers without being modified. Raw Buffer and typed-memory copies still copy bytes, not values converted between byte orders.
Argument and return types are inferred from declared type classes such as
c_int32 and c_size_t. A literal tuple (as const) preserves argument types:
const libc = new CDLL(null);
const strlen = libc.func("strlen", c_size_t, [c_char_p] as const);
const n: bigint = strlen("hello"); // typed as bigint, not anystatic _fields_ = [["x", c_int32]] compiles as written in Python (no
as const). For typed struct fields use defineStruct instead of
extends Structure:
class Point extends defineStruct({ x: c_int32, y: c_int32 }) {
distance() {
return Math.sqrt(this.x ** 2 + this.y ** 2);
}
}
const p = new Point({ x: 3, y: 4 }); // p.x, p.y typed as numberstruct() / union() fields, POINTER() targets and arrays of numeric
types are typed too; a null return type is void:
const PointDef = struct({ x: c_int32, y: c_double });
PointDef.get(buf, "y"); // number
POINTER(c_int32).fromAddress(addr)[0]; // number (.contents: c_int32 instance)
array(c_int32, 4).create([1, 2, 3, 4])[0]; // number
libc.func("srand", null, [c_uint32]); // returns undefined| Type | Aliases | Size | Notes |
|---|---|---|---|
c_int8 |
c_byte |
1 | |
c_uint8 |
c_ubyte |
1 | |
c_int16 |
c_short |
2 | |
c_uint16 |
c_ushort |
2 | |
c_int32 |
c_int |
4 | |
c_uint32 |
c_uint |
4 | |
c_int64 |
c_longlong |
8 | |
c_uint64 |
c_ulonglong |
8 | |
c_long |
platform | 4 on Windows, 8 on Unix 64-bit; BigInt values | |
c_ulong |
platform | mirrors c_long; BigInt values |
|
c_float |
4 | ||
c_double |
8 | ||
c_bool |
1 | ||
c_char |
1 | 1-char string (Python bytes); also 0..255 |
|
c_wchar |
2 or 4 | 1-char string; 2 on Windows, 4 on Unix | |
c_char_p |
pointer | char * (NUL-terminated) |
|
c_wchar_p |
pointer | wchar_t * (NUL-terminated wide) |
|
c_void_p |
c_voidp |
pointer | opaque pointer / HANDLE |
c_time_t |
8 | time_t (SIZEOF_TIME_T) |
|
c_size_t |
pointer | pointer-sized unsigned integer | |
c_ssize_t |
pointer | pointer-sized signed integer | |
HRESULT |
4 | c_int32 subclass — auto-raise on OleDLL |
Plus the Win32 types, structures and pointer types of ctypes.wintypes under wintypes.
CDLL(name, mode?)orCDLL(name, { mode?, use_errno?, use_last_error? })— C calling convention;modeis thedlopenflag (RTLD_GLOBAL,RTLD_LOCAL,DEFAULT_MODE)WinDLL(name, ...)— Windows__stdcallOleDLL(name, ...)— likeWinDLL, auto-raises on negativeHRESULTfind_library(name)— cross-platform name resolutioncdll.<libname>,windll.<libname>— lazy namespaces;cdll.LoadLibrary(name),new LibraryLoader(CDLL)- Functions without
restypereturnc_int; withoutargtypesthe arguments are converted from their JS values (as Python)
struct({...})/class extends Structure— composite struct typesclass extends Union— union typesarray(type, n)/ARRAY(type, n)— Python'stype * n; callable:array(c_int, 3)(1, 2, 3); elements can be simple types, structures, arrays,POINTER(T)andCFUNCTYPEtypesPOINTER(type)— pointer-to-type; accepts a thunkPOINTER(() => T)pointer(instance)— pointer to an existing c-data instanceCFUNCTYPE(restype, ...argtypes)— function pointer type:proto(jsFn)(callback),proto(address),proto([name, lib], paramflags?)(Python:proto((name, dll), paramflags))WINFUNCTYPE(restype, ...argtypes)—__stdcallcallback typedefineStruct({...})— typed-TS variant ofstruct
sizeof(type)/alignment(type)— in bytesaddressof(obj)→ BigIntbyref(obj, byteOffset = 0)— lightweight reference with a signed byte offsetcast(ptr, type)— reinterpret a pointer as another typememmove(dst, src, n)/memset(dst, val, n)— buffers, ctypes instances, pointers or addresses; return the destination address as BigIntreadValue(ptr, type, offset?)/writeValue(ptr, type, val, offset?)create_string_buffer(init, size?)/c_buffer/create_unicode_buffer(init, size?)— with.value(and.raw)string_at(addr, size?)/wstring_at(addr, size?)— exactlysizeunits, or up to the NUL
With an explicit byte count, string_at returns a lossless Latin1 string:
Buffer.from(string_at(addr, n), "latin1") recovers all n original bytes,
including embedded NUL and invalid UTF-8. Without a count it retains the
UTF-8 text representation. wstring_at counts native wide-character units.
get_errno()/set_errno(v)GetLastError()/SetLastError(v)/FormatError(code), Python namesget_last_error()/set_last_error(v)(all platforms)ArgumentError— raised for arguments that do not matchargtypes("argument N: TypeError: ...")- Per-library:
lib.get_last_error()/lib.get_errno()
T.from_buffer(buf, offset?)— zero-copy viewT.from_buffer_copy(buf, offset?)— independent copyT.from_address(addr)— wrap raw pointerT.in_dll(lib, name)— bind to exported globalT._as_parameter_/T.from_param(v)— adapter protocols
Structs, unions, bit fields (_layout_ "gcc-sysv" / "ms"), anonymous
fields, _pack_, _align_, subclasses of structures and unions, nested layouts,
structures and unions by value (a struct restype returns an instance),
arrays (also of arrays, pointers and function pointers), pointers and
pointer arithmetic (also POINTER(POINTER(T))), callbacks (CFUNCTYPE /
WINFUNCTYPE, also as struct fields), prototype((name, dll), paramflags),
from_buffer / from_buffer_copy / from_address / in_dll,
_as_parameter_ / from_param, paramflags out-params, errcheck,
use_errno / use_last_error, OleDLL + HRESULT auto-raise,
BigEndianStructure / LittleEndianStructure and union variants,
ctypes.wintypes, keepalive of objects assigned to fields (_objects).
- Callbacks must be released with
.release(). GC cannot detect when C code still holds the pointer; relying on it causes use-after-free. - No automatic memory management for returned pointers.
- Both class and functional syntax:
class extends Structure(Python-like) andstruct({...})(functional) are both supported. - Use type classes such as
c_int32, rather than string names such as"int32", for the Python-compatible, typed API. bytesare JS strings:c_charvalues,c_char * narrays and fields,c_char_pandcreate_string_buffer().valueuse strings (UTF-8) where Python usesbytes;.rawis aBuffer.- NULL pointers are
null, where Python returns a falsy pointer object. c_char_p/c_wchar_paccept raw addresses (BigInt/number) as argtypes. Python is stricter (raisesTypeErroronint). For portable code usec_void_pthere; both libraries accept it.get_last_error()/set_last_error()exist on every platform (Python: Windows only), so the same code runs everywhere.- 64-bit and pointer-sized integers (
c_int64,c_uint64,c_size_t,c_ssize_t) also acceptnull(as 0) and aBufferorArrayBuffer(as its address) as arguments. - Variadic arguments follow the C promotions: a
c_float,c_int8,c_char, … instance passed beyondargtypesis passed asdouble/int, where Python fails (ffi_prep_cif_var failed). - A plain object is accepted for a structure argument passed by value (fields by name).
c_wcharalso accepts a code point (new c_wchar(0x4E2D)), where Python requires a 1-character string.memmove()/memset()stop at the end of JS memory (Buffer, typed array,ArrayBuffer) instead of writing past it.- Integer types reject non-integral numbers (
3.7,NaN) as Python rejects floats; JS has no int/float distinction, so3.0is accepted. - Floating-point scalars accept BigInt in the range
-2**63to2**64-1, converted to a JS number; larger BigInts raise a range error. This applies consistently to constructors, fields and function arguments. - Array instances are Buffers whose Array methods work on the elements
(
slice,map,includes, …, as Python sequences); the Buffer methods (readInt32LE,subarray, …) work on the bytes. - Array index reads follow JavaScript bounds behavior: out-of-range
indices return
undefined; negative indices do not wrap around as in Python. - Unions by value on x86-64 System V are passed with the ABI rules; Python passes them in the wrong registers (libffi#33).
- Structure/Union fields are accessors of the class, as Python fields
are descriptors (
vars(s)is empty):Object.keys(s)and{ ...s }do not list them; uses.toObject().JSON.stringify(s)andconsole.log(s)show the fields.
ctypes.resize(instance, size)— Structure/Union instances use a NodeBuffer, and Buffers aren't resizable in place. Work-around:new MyStruct(myBuffer)with a pre-sized buffer.c_longdouble(80/128-bit float,ffi_type_longdouble) — rarely used; use a native wrapper that converts to supported types, or access storage through pointers/bytes. Declaring along doubleABI value asc_doubleis not a portable substitute.
2.0 follows Python ctypes more closely. Code relying on the 1.x behavior needs these changes:
| 1.x | 2.0 (as Python) |
|---|---|
ptr.contents returned the value |
returns a live instance: ptr.contents.value (simple types), ptr.contents.x (structs); ptr[0] still returns the value |
POINTER(T) restype returned a bigint address |
returns a pointer object (null for NULL); use .address for the number |
NULL POINTER struct fields were pointer objects |
they are null |
c_char values were numbers |
1-character strings; c_char * n fields read/write as strings |
invalid c_char values were truncated |
TypeError ("one character bytes, bytearray, or an integer in range(256) expected, not ...") |
wrong argument types raised TypeError |
raise ArgumentError (a TypeError subclass), message "argument N: TypeError: ..." |
| unsupported pointer arguments (functions, plain objects) passed NULL | raise ArgumentError ("'function' object cannot be interpreted as ctypes.c_void_p") |
functions without restype / argtypes |
default restype is c_int; without argtypes the call uses the fixed-argument ABI |
create_string_buffer(init) |
also takes size and returns a Buffer with .value / .raw |
c_long / c_ulong (and BOOL, LONG, DWORD, ULONG) fields and .value were numbers on Windows |
BigInt on every platform, like function results; writes accept numbers and BigInt |
wintypes.CHAR was c_ubyte; LPDWORD, PDWORD, LPBYTE, PBYTE, PHKEY were c_void_p |
as Python: CHAR = c_char and pointer types are POINTER(T) (fields and results are pointer objects) |
string_at(addr, n) / wstring_at(addr, n) stopped at the first NUL |
read exactly n units; without n, wstring_at reads up to the NUL (no 256-character limit) |
Each example in examples/ has its own README; see the index.
- Electron + React — a native system clock through a sandboxed preload bridge, synchronized C-memory views and a packaged ASAR application
- Embedded Python — CPython through its C API: code, modules, callbacks, JavaScript objects by reference, shared memory, threads, asyncio and cancellation (Windows, macOS, Linux)
- Smart card (PC/SC) —
winscardon Windows,PCSC.frameworkon macOS,libpcscliteon Linux - Cryptoki (PKCS #11) — complete 3.2 declarations with 2.20–3.2 dispatch and a standalone SoftHSM demo: temporary token, login, RSA, ECDSA, AES and SHA-256
- Accessibility — macOS UI automation with the Accessibility API
- COM automation — shell shortcuts (
IShellLinkW) and Active Directory (IDirectorySearch) - Controls — Win32 common controls
- LDAP — LDAPv3 paged search (
wldap32) - Registry —
setValue/getValue/openKey/deleteKey - System tray — tray icon + popup menu
From the repository root, npm --prefix tests test first builds a small C library (tests/fixtures/, into
build/fixture/) used by the tests of structures by value, array parameters
and reentrant calls; npm run build:test-fixture builds it alone. Matched
Python/JavaScript contracts live under tests/cpython_parity/, divided by domain,
and load the same library. Node-specific async, representation and GC checks
live under tests/node_specific/; genuine Python-only scheduling checks live
under tests/python_specific/. The former common/ and windows/ suites have
been migrated into these domains.
Paired JavaScript uses the Python-shaped API: Structure/Union classes,
ARRAY, library attributes with argtypes/restype, and CFUNCTYPE bindings.
The structural guard checks neighboring files and matching class/method IDs
using the existing TypeScript parser and Python AST; runtime assertions remain
independent checks against CPython, not a claim based on file names alone.
npm install
npm run build
npm --prefix tests install
npm --prefix tests test # functional/parity tests
NODE_CTYPES_NO_EXTERNAL_BUFFERS=1 npm --prefix tests test # mirrored native memory
npm --prefix tests run test:parity # matched CPython and JS contracts
npm --prefix tests run test:parity:check # paired file/case/discovery/style guard
npm --prefix tests run test:python # all Python references
npm run test:types # TypeScript declarations
npm run docs # TypeDoc API site in docs/
npm run docs:serve # previewSee the test suite guide for paired-case conventions, runtime differences and focused commands.
There are two JavaScript comparisons, both against another FFI library. From the repository root, after building node-ctypes:
npm --prefix tests install # includes Koffi
npm run build:test-fixture # shared C functions for both libraries
npm run bench:koffi # node-ctypes vs Koffi; Node 24+
npm run bench:ffi # node-ctypes vs node:ffi; Node 26+The private test package explicitly approves the install script for Koffi 3.3.3.
When upgrading Koffi, review the new version before updating that approval with
npm --prefix tests install-scripts approve koffi; future versions are not
automatically authorized.
bench:ffi adds --experimental-ffi automatically. Both comparisons
print a table grouped by workload and automatically generate HTML reports:
tests/benchmarks/results/koffi.htmltests/benchmarks/results/node-ffi.html
These paths are relative to the repository root, regardless of the working directory. Each successful run replaces its previous report; generated results are ignored by Git. Open the HTML file directly in a browser: it is responsive and self-contained, with native HTML/CSS bar charts and no browser JavaScript, external resources or server requirements.
| Column | Meaning |
|---|---|
| Case | The same operation and inputs for both libraries |
| node-ctypes / competitor | Median time +/- MAD (dispersion), in ns per operation |
| Ratio | node-ctypes time divided by competitor time |
A ratio of 0.50x means node-ctypes took half the time; 2.00x means it
took twice the time. Lower times are better. There is no overall score
combining unrelated calls, memory access and asynchronous scheduling.
In the charts, shorter bars are better. Each workload group shares a linear
scale starting at zero; different groups use different scales. Bars show
medians, with +/- MAD whiskers (not confidence intervals). Each library also
shows its total measured operation count and elapsed time across the samples.
Reports record library/runtime versions, CPU, OS/kernel, buffer mode, start/ finish timestamps and Git revision/working-tree status. Missing CPU or Git metadata produces an explicit warning rather than invented values.
The comparisons cover scalars, mixed arguments, short/long strings, buffer arguments, field reads and callbacks. Koffi also covers structures by value, variadic calls and async calls with 1/8 calls in flight. Cases without an equivalent documented node:ffi API are listed as not compared, not emulated with a different native signature.
The CFUNCTYPE rows measure calls through an already-bound node-ctypes
prototype, against an independently bound competitor function with the same
C signature. Bindings are separate to avoid sharing optimization feedback
with the direct-call rows. These cases do not include prototype construction
or callback registration.
Both sides call the same C fixture and verify results before/after timing. String contents are encoded on every call; node-ctypes reuses scratch storage, not previously encoded strings. Measurements describe warmed repeated calls, not cold-start allocation costs. String lengths are compared as Numbers, including conversion where the API returns BigInt; wide integers are BigInt in both APIs. Structures are plain objects. Async times include scheduling and are reported per completed call, with the concurrency shown in the case name.
Defaults are nine samples of at least 250 ms per library after warmup, alternating library order. Each native signature has a separate call-site to avoid mixing V8 optimization feedback between cases. Each row is printed when its measurements finish. Run comparisons serially, not concurrently. Optional arguments:
npm run bench:koffi -- --filter string,callback
npm run bench:ffi -- --samples 11 --duration 500
npm run bench:koffi -- --output comparison.json--output comparison.json additionally saves medians, dispersion and all sample
durations/operation counts as JSON, and writes comparison.html next to it
instead of the default HTML report. Parent directories are created automatically.
HTML generation runs only after all measurements finish.
benchmark_koffi.js and benchmark_node_ffi.js define the actual side-by-side
comparisons; runner.js shares timing, table output and HTML generation without
extra dependencies or a separate renderer script. There is no separate
self-benchmark command, and benchmarks are not run by the functional/parity
test suite.
The workflow retains the eight Node 24 native build targets (Windows/macOS,
x64/ARM64, and Linux glibc/musl). C++20 conformance is checked with
-pedantic-errors on GCC/Clang and /permissive- on MSVC; vendor optimization
and the node-addon-api integration remain unchanged.
Three runtime-control jobs reuse the built artifacts on Node 26 without rebuilding the addon, running the functional parity suite normally and with mirrored buffers. A Linux job runs the focused Python 3.15 reference tests in addition to the Python 3.14 reference coverage.
The package assembler requires all eight nonempty binaries and includes
declarations, README, changelog and license. It fails on missing/unknown
artifacts or a stale output directory. Publish depends on the runtime gates,
requires a pushed tag matching the manifest version exactly, and uses next
for prereleases instead of latest. Manual workflow runs never publish.
These are configured gates, not a substitute for a successful native CI run.
npm --prefix tests test builds the fixture and runs the suite;
npm --prefix tests run test:js reuses an already-built fixture/addon.
Both use the documented --test-timeout runner option.
MIT. Built with libffi, node-addon-api, and cmake-js.