Native FFI
Outbound FFI lets statically compiled TypeScript call C ABI symbols directly. A strict JSON manifest connects a signature-only TypeScript declaration to a native symbol and supplies the archives or objects that resolve it at link time. There is no runtime symbol lookup and no JavaScript engine at the boundary.
A complete example
Declare the native function in TypeScript. The declaration gives the type checker its ordinary source-level signature; it emits no JavaScript body.
declare function nativeScale(value: number): number;
console.log(nativeScale(21));Implement a C ABI symbol with the matching native signature:
double native_scale(double value) {
return value * 2.0;
}Bind the two names in an FFI manifest. Library paths are resolved relative to this file.
{
"ffi_format": 1,
"functions": [
{
"name": "nativeScale",
"symbol": "native_scale",
"params": ["f64"],
"returns": "f64"
}
],
"libraries": ["./libnative.a"],
"system_libraries": []
}Build the native archive, then pass the manifest to scriptc:
$ clang -c native.c -o native.o
$ ar rcs libnative.a native.o
$ scriptc build main.ts --ffi ffi.json -o app
$ ./app
42The FFI binding applies only to a direct call of that exact declaration. A function with a body, an overload, a generic declaration, an alias such as const f = nativeScale, or a shadowing local does not silently become a native call.
ABI classes
The manifest is the native ABI authority. TypeScript has only number, so its declaration cannot distinguish a double from an integer-width parameter.
| Manifest class | TypeScript type | C ABI type and behavior | Parameter | Return |
|---|---|---|---|---|
f64 | number | double | yes | yes |
bool | boolean | uint8_t; inputs are 0 or 1, any nonzero return becomes true | yes | yes |
u8 | number | uint8_t; inputs use JavaScript's modulo conversion | yes | yes |
u32 | number | uint32_t; inputs use ToUint32 | yes | yes |
i32 | number | int32_t; inputs use ToInt32 | yes | yes |
string | string | const uint8_t *, size_t; UTF-8 bytes, length-delimited | yes | no |
bytes | Uint8Array or Buffer | const uint8_t *, size_t; raw bytes, length-delimited | yes | no |
void | void | void | no | yes |
String and byte pointers are borrowed only for the duration of the call. Native code must not mutate, free, or retain them. Strings may contain embedded NUL bytes, so always use the supplied length; an empty span may have a null pointer. The current formats deliberately have no pointer, string, or byte return because those need an explicit ownership and allocator contract.
For C++, export the symbol with extern "C" so it keeps the manifest's unmangled C name.
Callbacks and context pointers
Format 2 adds call-scoped C function-pointer parameters. It describes the function pointer and opaque context as independent ABI entries, in their actual positions—matching the model used by C, Rust's extern "C" fn plus *mut c_void, and Zig's *const fn (...) callconv(.c) plus *anyopaque.
For example, this C function takes a callback first, a value second, and its context last. The callback itself receives the context last:
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:
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.
{
"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 parameter lists currently accept f64, bool, u8, u32, and i32, plus at most one context entry; callback returns accept those scalar classes or void.
For a raw 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. This remains call-scoped: the native function must invoke it synchronously on the thread that entered the native call.
lifetime must currently be "call". Native code must not retain a callback or context, invoke it after the outer function returns, or invoke it from another thread. As with a bad native pointer or mismatched signature, violating that contract is outside scriptc's memory-safety guarantees. Retained callbacks will require an explicit registration/unregistration ownership model; foreign-thread callbacks will additionally require runtime scheduling and synchronization.
If a 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.
Manifest fields
ffi_format- Required. Format
1supports value parameters; format2preserves them and adds callback/context entries. functions- Required array. Every entry has exactly
name,symbol,params, andreturns. Binding names and symbols must be unique. In format 2, callback ids must be unique within a function and every context must match exactly one callback. libraries- Optional array of archive or object paths. Relative paths are resolved from the manifest directory and appended after the generated program at link time.
system_libraries- Optional array of linker-neutral library names. For example,
["m"]is emitted as-lm.
Unknown fields, invalid ABI classes, duplicate names, and signature mismatches fail the build with an SC5xxx diagnostic. The same manifest can be passed to scriptc coverage so native call sites count as statically compiled.
Boundary rules and current limits
- Native calls are synchronous and must return normally. Do not unwind C++ exceptions or
longjmpacross 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.
- Callbacks are synchronous, call-scoped, and same-thread only. Retained callbacks and foreign-thread invocation are not supported yet.
- There are no variadic calls, struct-by-value arguments, owned pointer returns, or runtime
dlopen/dlsymhandles yet. - 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.