DEV Community

David Anyatonwu
David Anyatonwu

Posted on

Giving TypeScript a Rust-Powered Turbo Button with FFI

TypeScript is excellent for building applications quickly. Rust is excellent when you need predictable performance, low-level control, or memory-efficient computation.

Foreign Function Interface—usually shortened to FFI—allows us to combine both. Our application can remain in TypeScript while selected operations run inside a compiled Rust library.

Let’s build a small native function that calculates the mean of a large array of numbers.

Creating the Rust Library

Create a new Rust library:

cargo new signal-math --lib
cd signal-math

Enter fullscreen mode Exit fullscreen mode

Configure Cargo to produce a native dynamic library:

# Cargo.toml

[package]
name = "signal-math"
version = "0.1.0"
edition = "2024"

[lib]
crate-type = ["cdylib"]

Enter fullscreen mode Exit fullscreen mode

A cdylib is a dynamic system library designed to be consumed by another language. Depending on the operating system, Cargo will produce a .so, .dylib, or .dll file.

Now add our exported function:

// src/lib.rs

#[unsafe(no_mangle)]
pub extern "C" fn mean(values: *const f64, length: usize) -> f64 {
    if values.is_null() || length == 0 {
        return f64::NAN;
    }

    let values = unsafe {
        std::slice::from_raw_parts(values, length)
    };

    values.iter().sum::<f64>() / length as f64
}

Enter fullscreen mode Exit fullscreen mode

Three details make this function accessible from TypeScript:

  • pub makes the function public.
  • extern "C" gives it the C calling convention.
  • #[unsafe(no_mangle)] preserves the exported name as mean.

Rust cannot prove that the pointer supplied by TypeScript is valid, so converting it into a slice requires an unsafe block. The goal is to keep this unsafe boundary as small and carefully checked as possible. The Rustonomicon’s FFI guide provides more detail about these boundaries.

Build the library:

cargo build --release

Enter fullscreen mode Exit fullscreen mode

The generated file will be one of the following:

Linux:   target/release/libsignal_math.so
macOS:   target/release/libsignal_math.dylib
Windows: target/release/signal_math.dll

Enter fullscreen mode Exit fullscreen mode

Calling Rust from TypeScript

Deno provides a native Deno.dlopen() API for loading dynamic libraries directly from TypeScript.

Create app.ts in the project directory:

const libraryName = {
  linux: "libsignal_math.so",
  darwin: "libsignal_math.dylib",
  windows: "signal_math.dll",
}[Deno.build.os];

const libraryPath = new URL(
  `./target/release/${libraryName}`,
  import.meta.url,
).pathname;

const library = Deno.dlopen(libraryPath, {
  mean: {
    parameters: ["buffer", "usize"],
    result: "f64",
  },
} as const);

try {
  const measurements = new Float64Array([
    12.5,
    18.2,
    21.7,
    15.9,
    19.3,
  ]);

  const result = library.symbols.mean(
    measurements,
    BigInt(measurements.length),
  );

  console.log(`Mean: ${result}`);
} finally {
  library.close();
}

Enter fullscreen mode Exit fullscreen mode

Run it with:

deno run --allow-ffi app.ts

Enter fullscreen mode Exit fullscreen mode

The result should be:

Mean: 17.52

Enter fullscreen mode Exit fullscreen mode

The Float64Array gives Rust a contiguous block of 64-bit floating-point values. TypeScript passes its buffer and length across the boundary, while Rust interprets that memory as a slice.

Deno requires the --allow-ffi permission because native libraries run outside its normal security sandbox and inherit the process’s system access. Only load libraries you trust. See the official Deno FFI documentation for its supported native types and security considerations.

The Boundary Is the Important Part

The calculation itself is simple. The interesting engineering happens at the boundary between the languages.

TypeScript and Rust must agree on:

  • Function names
  • Calling conventions
  • Parameter sizes
  • Return types
  • Memory layouts
  • Pointer validity
  • Resource ownership

FFI does not translate arbitrary Rust or TypeScript objects automatically. It works best with C-compatible values such as integers, floating-point numbers, pointers, byte buffers, and carefully defined structs.

For complex data, a common pattern is to serialize it into JSON, MessagePack, or another binary format before crossing the boundary. This is easier to maintain than exposing deeply nested native structures, although serialization introduces some overhead.

Avoid Tiny Native Calls

Calling Rust through FFI has a cost. It would be inefficient to cross the boundary once for every number in an array.

Prefer this:

const average = library.symbols.mean(values, BigInt(values.length));

Enter fullscreen mode Exit fullscreen mode

Over an API shaped like this:

for (const value of values) {
  library.symbols.processOneNumber(value);
}

Enter fullscreen mode Exit fullscreen mode

Batching work into fewer calls makes the native boundary worthwhile. Good candidates include image processing, compression, cryptography, parsing, simulations, and large numerical operations.

FFI Is Powerful, but It Changes the Safety Model

Rust is memory-safe only while its safety requirements are respected. Once raw pointers arrive from another language, Rust cannot automatically guarantee that:

  • The pointer is valid.
  • The buffer is large enough.
  • The memory remains alive during execution.
  • Both languages agree on its layout.
  • The memory is released exactly once.

A production library should therefore validate inputs, document ownership rules, prevent Rust panics from crossing the FFI boundary, and expose the smallest possible native interface.

Final Thoughts

Rust and TypeScript do not have to compete for ownership of an application.

TypeScript can handle APIs, user interfaces, orchestration, and rapidly changing business logic. Rust can provide a focused native engine for operations where performance or low-level control genuinely matters.

FFI becomes the narrow bridge between them. Keep that bridge small, explicit, and well-tested, and you can gain Rust’s native capabilities without giving up TypeScript’s development experience.

Top comments (0)