Use Rust from Node.js and TypeScript
Node.js can load a compiled library; the file is called an addon and ends in .node. This page builds one, textkit, from Rust with napi-rs, and calls it from JavaScript and TypeScript. Everything ran with Rust 1.99.1 and Node 24 in the image behind this site (Linux ARM64; on x86-64 the .node file name says x64). The steps that download something (npm ci, Cargo’s crates) need the network. Pinned: napi 3.15.0, napi-derive 3.6.13, napi-build 2.6.0 and @napi-rs/cli 3.10.8, with both lock files committed.
RememberA #[napi] attribute turns a Rust function into a JavaScript export, and the build writes the TypeScript declarations for it.
The project
Section titled “The project”textkit/ ├── src/ │ └── lib.rs the Rust code on this page ├── bad.mts TypeScript that the types reject ├── build.rs one call that sets up the linker for a Node addon ├── Cargo.lock created by Cargo ├── Cargo.toml a cdylib that depends on napi ├── demo.mjs JavaScript that calls the addon ├── demo.mts TypeScript that calls it, checked against the generated types ├── package-lock.json created by npm ├── package.json pins the build tool and has the build scripts ├── tsconfig.bad.json └── tsconfig.json
[package]name = "textkit"version = "0.1.0"edition = "2024"
[lib]crate-type = ["cdylib"]
[dependencies]napi = { version = "=3.15.0", default-features = false, features = ["napi9", "async"] }napi-derive = "=3.6.13"
[build-dependencies]napi-build = "=2.6.0"fn main() { napi_build::setup();}{ "name": "textkit", "version": "0.1.0", "private": true, "main": "index.js", "types": "index.d.ts", "napi": { "binaryName": "textkit" }, "scripts": { "build": "napi build --platform", "build:release": "napi build --platform --release" }, "devDependencies": { "@napi-rs/cli": "3.10.8", "@types/node": "24.19.2" }}crate-type = ["cdylib"] asks Cargo for “a dynamic system library … to be loaded from another language” (Reference: linkage), which is what Node loads. The = signs pin exact versions, and Pin napi and napi-derive together shows a pair that did not build. default-features = false turns off the default features of napi (listed on docs.rs). napi9 enables the bindings for Node-API version 9, and async enables the Tokio runtime that async fn exports use, below.
use napi::bindgen_prelude::*;use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode};use napi_derive::napi;use std::thread;
/// Numbers in, number out. `+` panics on overflow in a debug build.#[napi]pub fn add(a: i32, b: i32) -> i32 { a + b}
/// The same job, with the failure as a value.#[napi]pub fn add_checked(a: i32, b: i32) -> Result<i32> { a.checked_add(b) .ok_or_else(|| Error::new(Status::GenericFailure, "i32 overflow"))}
/// The same `+`, with the panic caught at the boundary and thrown as a JavaScript `Error`.#[napi(catch_unwind)]pub fn add_caught(a: i32, b: i32) -> i32 { a + b}
/// A struct that becomes a plain JavaScript object, copied field by field.#[napi(object)]pub struct Stats { pub count: u32, pub mean: f64, pub largest: Option<i32>,}
/// The JavaScript array is copied into a `Vec`.#[napi]pub fn stats(values: Vec<i32>) -> Stats { let sum: i64 = values.iter().map(|&v| i64::from(v)).sum(); Stats { count: values.len() as u32, mean: if values.is_empty() { 0.0 } else { sum as f64 / values.len() as f64 }, largest: values.iter().copied().max(), }}
/// An `Err` becomes a thrown JavaScript `Error`.#[napi]pub fn parse_port(text: String) -> Result<u16> { text.trim() .parse::<u16>() .map_err(|e| Error::new(Status::InvalidArg, format!("not a port: {e}")))}
/// A Rust struct that JavaScript holds as a class instance.#[napi]pub struct Counter { count: u32,}
#[napi]impl Counter { #[napi(constructor)] pub fn new(start: u32) -> Self { Counter { count: start } }
#[napi] pub fn increment(&mut self) -> u32 { self.count += 1; self.count }
#[napi(getter)] pub fn count(&self) -> u32 { self.count }}
/// A `Buffer` argument is JavaScript's own memory: this changes the caller's bytes.#[napi]pub fn xor_in_place(mut data: Buffer, key: u8) { for byte in data.iter_mut() { *byte ^= key; }}
/// A `Vec<u8>` argument is a copy: the caller's array is untouched.#[napi]pub fn xor_copy(mut data: Vec<u8>, key: u8) -> Vec<u8> { for byte in data.iter_mut() { *byte ^= key; } data}
/// An `async fn` returns a Promise; the body runs on napi's Tokio runtime.#[napi]pub async fn count_words(text: String) -> u32 { text.split_whitespace().count() as u32}
/// Which thread am I on? Rust's `ThreadId`, as text.#[napi]pub fn thread_id() -> String { format!("{:?}", thread::current().id())}
#[napi]pub async fn thread_id_async() -> String { format!("{:?}", thread::current().id())}
/// A callback that a Rust thread may call. `false` means the callback takes/// the value alone, with no leading error argument.#[napi]pub fn count_up(limit: u32, on_tick: ThreadsafeFunction<u32, (), u32, Status, false>) { thread::spawn(move || { for i in 1..=limit { on_tick.call(i, ThreadsafeFunctionCallMode::Blocking); } });}Build it
Section titled “Build it”napi build --platform runs Cargo, copies the library next to package.json under a name that includes the platform, and writes a loader index.js and the declarations index.d.ts.
$ npm ci # needs network: installs the pinned @napi-rs/cli and @types/node $ npm ls --depth=0 @napi-rs/cli @types/node [email protected] /home/you/textkit ├── @napi-rs/[email protected] └── @types/[email protected] $ npm run build 2>&1 | grep -v WARNING > [email protected] build > napi build --platform $ ls # the build added index.js, index.d.ts and the .node file Cargo.lock demo.mjs node_modules textkit.linux-arm64-gnu.node Cargo.toml demo.mts package-lock.json tsconfig.bad.json bad.mts index.d.ts package.json tsconfig.json build.rs index.js src
The grep removes two WARNING lines the build prints here; they say the container gave the tool no machine identity to take a lock with. Next, the generated declarations. The doc comments on the Rust functions became its comments.
$ cat index.d.ts
/* auto-generated by NAPI-RS */
/* eslint-disable */
/**
* Which binding artifact the generated loader actually loaded: `'native'` for
* a native addon, otherwise the `platformArchABI` of the WASI flavor. Every
* flavor napi-rs can build is listed, because `NAPI_RS_NATIVE_LIBRARY_PATH`
* can point the loader at a WASI artifact this package does not build itself.
*/
export declare const __napiBindingTarget: 'native' | 'wasm32-wasi' | 'wasm32-wasip1'
/** A Rust struct that JavaScript holds as a class instance. */
export declare class Counter {
constructor(start: number)
increment(): number
get count(): number
}
/** Numbers in, number out. `+` panics on overflow in a debug build. */
export declare function add(a: number, b: number): number
/** The same `+`, with the panic caught at the boundary and thrown as a JavaScript `Error`. */
export declare function addCaught(a: number, b: number): number
/** The same job, with the failure as a value. */
export declare function addChecked(a: number, b: number): number
/**
* A callback that a Rust thread may call. `false` means the callback takes
* the value alone, with no leading error argument.
*/
export declare function countUp(limit: number, onTick: ((arg: number) => void)): void
/** An `async fn` returns a Promise; the body runs on napi's Tokio runtime. */
export declare function countWords(text: string): Promise<number>
/** An `Err` becomes a thrown JavaScript `Error`. */
export declare function parsePort(text: string): number
/** The JavaScript array is copied into a `Vec`. */
export declare function stats(values: Array<number>): Stats
/** A struct that becomes a plain JavaScript object, copied field by field. */
export interface Stats {
count: number
mean: number
largest?: number
}
/** Which thread am I on? Rust's `ThreadId`, as text. */
export declare function threadId(): string
export declare function threadIdAsync(): Promise<string>
/** A `Vec<u8>` argument is a copy: the caller's array is untouched. */
export declare function xorCopy(data: Array<number>, key: number): Array<number>
/** A `Buffer` argument is JavaScript's own memory: this changes the caller's bytes. */
export declare function xorInPlace(data: Buffer, key: number): void
A .node file is not a special format. In this build it was byte for byte the library Cargo produced:
$ cmp target/aarch64-unknown-linux-gnu/debug/libtextkit.so textkit.linux-arm64-gnu.node && echo identical
identical
Call it from Node
Section titled “Call it from Node”import { add, addChecked, stats, parsePort, Counter, xorInPlace, xorCopy, countWords, threadId, threadIdAsync, countUp } from './index.js'
console.log('add(2, 3) =', add(2, 3))console.log('add(2 ** 31, 1) =', add(2 ** 31, 1))console.log('add(1.5, 2) =', add(1.5, 2))try { addChecked(2147483647, 1) } catch (e) { console.log('addChecked :', e.code, '|', e.message) }try { add('1', 2) } catch (e) { console.log('add("1", 2) :', e.code, '|', e.message) }
console.log('stats([3, 1, 2]) =', stats([3, 1, 2]))console.log('stats([]) =', stats([]))console.log('parsePort(" 8080 ") =', parsePort(' 8080 '))try { parsePort('99999') } catch (e) { console.log('parsePort("99999") :', e instanceof Error, e.code, '|', e.message) }
const counter = new Counter(5)counter.increment()console.log('Counter =', counter.count, counter instanceof Counter)
const shared = Buffer.from([1, 2, 3])xorInPlace(shared, 0xff)console.log('xorInPlace(buffer) ->', shared)const copied = [1, 2, 3]console.log('xorCopy(array) ->', xorCopy(copied, 0xff), 'and the array is still', copied)
const pending = countWords('one two three')console.log('countWords -> Promise?', pending instanceof Promise)console.log('countWords =', await pending)console.log('same thread twice? ', threadId() === threadId(), '| async on another thread?', threadId() !== (await threadIdAsync()))
await new Promise((resolve) => countUp(3, (n) => { console.log('tick', n); if (n === 3) resolve() }))$ node demo.mjs
add(2, 3) = 5
add(2 ** 31, 1) = -2147483647
add(1.5, 2) = 3
addChecked : GenericFailure | i32 overflow
add("1", 2) : NumberExpected | Failed to convert napi value String into rust type `i32`
stats([3, 1, 2]) = { count: 3, mean: 2, largest: 3 }
stats([]) = { count: 0, mean: 0 }
parsePort(" 8080 ") = 8080
parsePort("99999") : true InvalidArg | not a port: number too large to fit in target type
Counter = 6 true
xorInPlace(buffer) -> <Buffer fe fd fc>
xorCopy(array) -> [ 254, 253, 252 ] and the array is still [ 1, 2, 3 ]
countWords -> Promise? true
countWords = 3
same thread twice? true | async on another thread? true
tick 1
tick 2
tick 3
What crossed the boundary, line by line:
- Numbers.
add(2 ** 31, 1)gave-2147483647: Node-API cuts the value to 32 bits before Rust runs. The Node-API docs say “the result is truncated to the equivalent of the bottom 32 bits”, and1.5became1. The C addon below gives the same answers, so this is Node-API, not napi-rs. Nothing warns you; for large values declarei64orf64(see napi-rs type conversions). - Wrong types.
add("1", 2)threw beforeaddran, with the codeNumberExpected. - Errors.
Err(..)from a function returningResultbecame a thrownErrorwhosecodeis the napiStatusname. In Rust (Errors) the failure is in the return type; in JavaScript it is an exception that no signature mentions. - Data. The array became a
Vec<i32>(copied), and theStatsstruct became a plain object (copied back).Nonebecame a missing property, which is whylargestis absent for the empty list.Counteris different: JavaScript holds a handle to a Rust value that stays in Rust, and each method call goes back into Rust.
TypeScript
Section titled “TypeScript”The declarations let the compiler check calls into Rust. Node 24 can run .mts files itself by erasing the types, and the Node docs say no type checking is performed. So the checking is tsc’s job. The tsc here is the compiler the image ships, not one in package.json; the first step prints its version, and the two errors below are from that version. To repeat this elsewhere, install TypeScript yourself.
import { add, stats, Counter, countWords, type Stats } from './index.js'
const s: Stats = stats([3, 1, 2])const c = new Counter(5)console.log(add(2, 3), s.largest, c.increment(), await countWords('a b c'))import { add, stats } from './index.js'
add('1', 2)console.log(stats([1, 2, 3]).total){ "compilerOptions": { "target": "es2022", "module": "nodenext", "strict": true, "noEmit": true, "types": ["node"] }, "files": ["demo.mts"]}{ "extends": "./tsconfig.json", "files": ["bad.mts"]}$ tsc --version # the compiler installed in this image, not in package.json Version 7.0.2 $ tsc -p . # demo.mts tsc: no errors $ node demo.mts 5 3 6 3 $ tsc -p tsconfig.bad.json # bad.mts bad.mts(3,5): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'. bad.mts(4,30): error TS2339: Property 'total' does not exist on type 'Stats'. $ node bad.mts file:///home/you/textkit/bad.mts:3 add('1', 2) ^ Error: Failed to convert napi value String into rust type `i32` at file:///home/you/textkit/bad.mts:3:1 at ModuleJob.run (node:internal/modules/esm/module_job:561:25) at async node:internal/modules/esm/loader:647:26 at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5) { code: 'NumberExpected' } Node.js v24.22.1
tsc rejected both mistakes without running anything. node bad.mts skipped that check, and the Rust side then refused the string at run time. largest?: number is in the types because the Rust field was an Option, but a declaration says nothing about what a function can throw: the napi-rs docs say “TypeScript does not encode thrown exceptions or rejected Promises”.
Buffers share memory, arrays are copied
Section titled “Buffers share memory, arrays are copied”xorInPlace took a Buffer and changed the bytes JavaScript still holds. xorCopy took a Vec<u8>, which napi-rs fills by copying, and the caller’s array was untouched. Both were in the demo above.
Sharing saves the copy but not the rules. The napi-rs typed array docs warn that Buffer is Send and Sync so it can cross threads, yet JavaScript can keep changing the same bytes: reading them from a Rust worker thread meanwhile is a data race, and they advise copying first or following an ownership protocol. The compiler cannot see that rule; it is the gap Send and Sync and unsafe describe.
Promises and callbacks
Section titled “Promises and callbacks”count_words is an async fn, so it came out as Promise<number>. The two threadId comparisons show what ran where: two synchronous calls had the same Rust ThreadId, and the async one had a different one. The napi-rs docs say an exported async fn runs on a Tokio runtime that napi-rs provides. For work that blocks, the AsyncTask docs describe running a Task in the libuv thread pool (libuv is the C library behind Node’s event loop). For a Tokio refresher see Tokio.
count_up took a callback and called it from a Rust thread, once per number. JavaScript values may only be touched on the JavaScript thread, so napi-rs wraps the callback in a ThreadsafeFunction: Rust can call it from any thread, and the callback runs on the JavaScript thread. The false in the type is CalleeHandled: the callback receives the value alone, as (arg: number) => void; with the default true it is error-first. The ThreadsafeFunction docs cover queue limits and failed calls.
When Rust panics
Section titled “When Rust panics”A debug and a release build behave differently at the edge of i32. Cargo’s profile docs say a panic occurs on overflow when overflow-checks is on, and list it as on for dev and off for release. The build above was a debug build; npm run build:release adds --release.
$ stat -c '%s %n' textkit.linux-arm64-gnu.node # size in bytes, name 17184184 textkit.linux-arm64-gnu.node $ node -e "console.log(require('./index.js').add(2147483647, 1))"; echo "exit code: $?" thread '<unnamed>' (531) panicked at src/lib.rs:9:5: attempt to add with overflow note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace fatal runtime error: failed to initiate panic, error 211428624, aborting bash: line 1: 531 Aborted (core dumped) node -e "console.log(require('./index.js').add(2147483647, 1))" exit code: 134 $ node -e "try { addCaught(2147483647, 1) } catch (e) { console.log('caught:', e.code, '|', e.message) }"; echo "exit code: $?" thread '<unnamed>' (547) panicked at src/lib.rs:22:5: attempt to add with overflow note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace caught: GenericFailure | attempt to add with overflow exit code: 0 $ npm run build:release 2>&1 | grep -v WARNING > [email protected] build:release > napi build --platform --release $ stat -c '%s %n' textkit.linux-arm64-gnu.node # size in bytes, name 1235152 textkit.linux-arm64-gnu.node $ node -e "console.log(require('./index.js').add(2147483647, 1))"; echo "exit code: $?" -2147483648 exit code: 0
This output changes from run to run. This is one real run.
In the debug build, add(2147483647, 1) panicked inside Rust and the Node process ended with exit code 134. I did not look into why unwinding failed (the line failed to initiate panic). The numbers in that output change from run to run, and your shell may print its own line about the abort. The napi-rs docs say an uncaught panic in a synchronous function “can terminate the process”, and offer #[napi(catch_unwind)], which add_caught uses: the same panic became an Error with code GenericFailure, and the process kept going. add_checked is the better fix when you can write it: the failure is then a value you chose. In the release build there was no panic: + wrapped to -2147483648.
The two file sizes are for this crate, one build each: about 17 MB for debug and 1.2 MB for release. See Performance for what the profiles change.
Pin napi and napi-derive together
Section titled “Pin napi and napi-derive together”napi and napi-derive are two crates, and the code that #[napi] writes calls items in napi. This pair of pinned versions failed to build in the image, and the pair above (3.15.0 and 3.6.13) built. The first two lines are Cargo moving the committed Cargo.lock to the pinned pair:
$ cargo build # first 10 lines
Downgrading napi v3.15.0 -> v3.14.2 (available: v3.15.0)
Downgrading napi-derive v3.6.13 -> v3.6.12 (available: v3.6.13)
error[E0433]: cannot find `NativeBorrowScopeRelease` in `bindgen_prelude`
--> src/lib.rs:95:1
|
95 | #[napi]
| ^^^^^^^ could not find `NativeBorrowScopeRelease` in `bindgen_prelude`
cargo exit status: 101
That is one failing pair and one working pair; I did not look for the first napi that works with napi-derive 3.6.12. Pin both and move them together.
Other ways in
Section titled “Other ways in”A C addon is the same idea written by hand against the Node-API header, node_api.h, which the image ships with Node. The Node docs call Node-API “ABI stable across versions of Node.js”: the binary interface stays the same, so a compiled addon can keep loading in later Node versions. This is add as a C addon:
#include <node_api.h>
static napi_value add(napi_env env, napi_callback_info info) { size_t argc = 2; napi_value argv[2]; napi_get_cb_info(env, info, &argc, argv, NULL, NULL);
int32_t a, b; if (argc < 2 || napi_get_value_int32(env, argv[0], &a) != napi_ok || napi_get_value_int32(env, argv[1], &b) != napi_ok) { napi_throw_type_error(env, NULL, "add expects two numbers"); return NULL; }
napi_value result; napi_create_int32(env, a + b, &result); return result;}
NAPI_MODULE_INIT() { napi_value fn; napi_create_function(env, "add", NAPI_AUTO_LENGTH, add, NULL, &fn); napi_set_named_property(env, exports, "add", fn); return exports;}const { add } = require('./add.node')
console.log('add(2, 3) =', add(2, 3))console.log('add(2 ** 31, 1) =', add(2 ** 31, 1))console.log('add(1.5, 2) =', add(1.5, 2))try { add('1', 2) } catch (e) { console.log('add("1", 2) :', e.name, '|', e.message) }$ gcc -Wall -shared -fPIC -I<node>/include/node -o add.node add.c $ node demo.js add(2, 3) = 5 add(2 ** 31, 1) = -2147483647 add(1.5, 2) = 3 add("1", 2) : TypeError | add expects two numbers $ wc -l add.c 25 add.c
It is 25 lines for one function, with no declaration file: gcc produced no types. The TypeError text came from my C. The Node docs describe node-gyp as “a tool written specifically to compile Node.js addons”, driven by a binding.gyp file (addons docs). I did not run it. The steps below show that the image has no make and no node-gyp on the PATH, though npm bundles a copy. node-gyp’s README also lists Python as a requirement, so I used gcc directly.
| Route | What you write | Run on this page |
|---|---|---|
| napi-rs (above) | Rust with #[napi]; declarations are generated |
Yes |
| C addon on Node-API | C, by hand, with node-gyp or a compiler command |
Yes, with gcc; node-gyp not run |
| WebAssembly | Rust compiled to a .wasm file that Node or a browser instantiates |
On the WebAssembly page |
node:ffi |
Call a C library from JavaScript with no addon | Not in Node 24 |
| Neon | Another Rust framework for Node addons, named at neon-rs.dev | Not run |
$ command -v make || echo "make: not found" make: not found $ command -v node-gyp || echo "node-gyp: not on PATH" node-gyp: not on PATH $ ls <node>/lib/node_modules/npm/node_modules | grep -x node-gyp # the copy that npm bundles node-gyp $ node -e "import('node:ffi').then(() => console.log('node:ffi loaded'), (e) => console.log(e.code))" ERR_UNKNOWN_BUILTIN_MODULE
The Node docs for a newer version list an experimental node:ffi module, added in v26.1.0. Node 24 in the image refused to load it, as the last step shows.
The same job in your language
Section titled “The same job in your language”Only the TypeScript/JavaScript and C rows ran on this page; the others are named so that you know what to search for.
| Language | How native code is called | Run here |
|---|---|---|
| TypeScript, JavaScript | A Node-API addon (above), or WebAssembly | Yes |
| Python | ctypes, cffi, or an extension module such as PyO3’s: Use Rust from Python |
On that page |
| Go | cgo | No |
| Java | JNI, or the foreign function and memory API (JEP 454) | No |
| Kotlin | The Java mechanisms | No |
| C# | P/Invoke | No |
| C | The Node-API header node_api.h, as in add.c above |
Yes |
| C++ | node-addon-api wraps Node-API in C++ classes | No |
The route that skips each runtime’s own format, a plain C function exported with extern "C", is the subject of C and C++.
Try it yourself
Section titled “Try it yourself”Predict each result first, then run the session below; it builds the addon again and ran all four in the image.
- A big total. Call
stats([2147483647, 2147483647]). The sum does not fit in ani32: what ismean? - A negative
u32. Constructnew Counter(-1)and readcount. Does it throw? - A bad argument to an
async fn. CallcountWords(42). Does it return a rejected Promise or throw at once? The napi-rs error docs say arguments are converted before the Promise is created. - A
Uint8Arrayfor aBuffer. Passnew Uint8Array([1, 2, 3])toxorInPlace(u, 255). Is it accepted, and do the bytes change?
$ node -e "console.log(require('./index.js').stats([2147483647, 2147483647]))" { count: 2, mean: 2147483647, largest: 2147483647 } $ node -e "try { console.log(new (require('./index.js').Counter)(-1).count) } catch (e) { console.log(e.code, '|', e.message) }" 4294967295 $ node -e "try { require('./index.js').countWords(42); console.log('returned a Promise') } catch (e) { console.log('threw', e.code, '|', e.message) }" threw StringExpected | Failed to convert JavaScript value `Number 42 ` into rust type `String` $ node -e "const { xorInPlace } = require('./index.js'); const u = new Uint8Array([1, 2, 3]); try { xorInPlace(u, 255); console.log(u) } catch (e) { console.log(e.code, '|', e.message) }" Uint8Array(3) [ 254, 253, 252 ]
Read more
Section titled “Read more”- napi-rs documentation, starting with getting started
- Node-API and Node.js: TypeScript in the Node 24 docs
- Use Rust from Python, C and C++ and WebAssembly: the same question for other targets
- Rust for TypeScript developers: the phrasebook, with the toolchain map
Learn Rust is an independent project, not affiliated with the Rust Project or the Rust Foundation. Code and text are under the MIT licence.Source on GitHub.