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.

main.ts
declare function nativeScale(value: number): number;

console.log(nativeScale(21));

Implement a C ABI symbol with the matching native signature:

native.c
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.json
{
  "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
42

The 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 classTypeScript typeC ABI type and behaviorParameterReturn
f64numberdoubleyesyes
boolbooleanuint8_t; inputs are 0 or 1, any nonzero return becomes trueyesyes
u8numberuint8_t; inputs use JavaScript's modulo conversionyesyes
u32numberuint32_t; inputs use ToUint32yesyes
i32numberint32_t; inputs use ToInt32yesyes
stringstringconst uint8_t *, size_t; UTF-8 bytes, length-delimitedyesno
bytesUint8Array or Bufferconst uint8_t *, size_t; raw bytes, length-delimitedyesno
voidvoidvoidnoyes

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. Format 1 deliberately has 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.

Manifest fields

ffi_format
Required. Must be 1.
functions
Required array. Every entry has exactly name, symbol, params, and returns. Binding names and symbols must be unique.
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 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.
  • There are no callbacks, variadic calls, struct-by-value arguments, owned pointer returns, or runtime dlopen/dlsym handles 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.