Rust for JavaScript Developers a Practical Translation Guide
TypeScript developers already think in types and async code. This guide maps each JavaScript habit to its Rust form and warns about the places where the mapping breaks.

TypeScript developers arrive at Rust with a real head start. You already accept that a type checker is a collaborator, you think in unions and optional fields, and you know async code needs structure. What you have not had to think about is who owns a value, because a garbage collector decided that for you. This guide translates each JavaScript habit to its Rust form and flags the places where the translation fails, with a program for each one. Every example was compiled with rustc 1.99.0, edition 2024, and run on an Apple M1 Max. Where the post compares against JavaScript, the runtime is Node 24.1.0. No crates are used, so each file is a complete src/main.rs.
null and undefined become one type you must open
JavaScript has two empty values and a habit of letting either one travel until something dereferences it. Rust has neither. A value that might be absent has the type Option<T>, an enum with two variants, Some(value) and None. The type checker will not let you use the inner value without saying what happens on None. This program has a map of users, one with an email and one without, and looks up three ids.
use std::collections::HashMap;
struct User {
name: String,
email: Option<String>,
}
fn main() {
let mut users: HashMap<u32, User> = HashMap::new();
users.insert(1, User { name: "Ada".into(), email: Some("[email protected]".into()) });
users.insert(2, User { name: "Grace".into(), email: None });
for id in [1, 2, 3] {
let line = match users.get(&id) {
Some(User { name, email: Some(e) }) => format!("{name} <{e}>"),
Some(User { name, email: None }) => format!("{name} has no email"),
None => format!("no user {id}"),
};
println!("{line}");
}
let domain = users
.get(&1)
.and_then(|u| u.email.as_deref())
.and_then(|e| e.split_once('@'))
.map(|(_, d)| d)
.unwrap_or("unknown");
println!("{domain}");
}Ada <[email protected]>
Grace has no email
no user 3
example.testThe match in the loop has three arms, and removing any of them is a compile error: the compiler checks that the cases are exhaustive. The first arm destructures through two levels at once, the user and the email inside it, which is what optional chaining plus an if would do in TypeScript, in a single pattern. The second arm covers a user with no email. The third covers the id that is not in the map. After the loop, the chain from get(&1) to unwrap_or is the Rust spelling of users.get(1)?.email?.split("@")[1] ?? "unknown". Each and_then is a ?. that continues only on Some, and unwrap_or is the ??. The Option guide lists the rest of the combinators.
The most common first mistake is to treat the return of a lookup as the value itself. In TypeScript, const age = ages.get("ada") gives you number | undefined, and strict mode warns you. In Rust there is no warning mode.
use std::collections::HashMap;
fn main() {
let mut ages: HashMap<&str, u32> = HashMap::new();
ages.insert("ada", 36);
let age: u32 = ages.get("ada");
println!("{}", age + 1);
}error[E0308]: mismatched types
--> src/main.rs:6:20
|
6 | let age: u32 = ages.get("ada");
| --- ^^^^^^^^^^^^^^^ expected `u32`, found `Option<&u32>`
| |
| expected due to this
|
= note: expected type `u32`
found enum `Option<&u32>`
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0308`.The message names both types: the code asked for a u32, and get returned Option<&u32>. That & matters, because the map hands back a reference to the number it stores, not a copy. There are several fixes and they say different things. ages["ada"] panics if the key is missing, like an assertion. ages.get("ada").copied().unwrap_or(0) supplies a default. A match or if let Some(age) = ages.get("ada") handles absence explicitly. Choose by what absence means in your program, which is the question the compiler is making you ask. Avoid sprinkling unwrap() as a way to silence this error. It turns a compile-time question into a crash at 2 a.m. The post on unwrap on None shows what the panic looks like.
Objects and union types become structs and enums
A plain JavaScript object does three jobs: a record with fixed fields, a dictionary with dynamic keys and a tagged union when it carries a type field. Rust separates them. A struct is the record, HashMap is the dictionary, and an enum is the tagged union. The enum is the one to learn early, because it replaces the pattern you wrote as a discriminated union.
#[derive(Debug)]
enum Event {
Click { x: i32, y: i32 },
Key(char),
Close,
}
fn describe(e: &Event) -> String {
match e {
Event::Click { x, y } if *x < 0 || *y < 0 => "click off screen".to_string(),
Event::Click { x, y } => format!("click at {x},{y}"),
Event::Key(c) => format!("key {c}"),
Event::Close => "close".to_string(),
}
}
fn main() {
for e in [Event::Click { x: 3, y: 4 }, Event::Click { x: -1, y: 0 }, Event::Key('q'), Event::Close] {
println!("{:?} -> {}", e, describe(&e));
}
}Click { x: 3, y: 4 } -> click at 3,4
Click { x: -1, y: 0 } -> click off screen
Key('q') -> key q
Close -> closeEvent is the equivalent of type Event = {type:"click",x:number,y:number} | {type:"key",key:string} | {type:"close"}, with two differences. The tag is not a string you can mistype, and each variant carries exactly its own fields: you cannot read x from a Key. The guard on the first Click arm, if *x < 0 || *y < 0, is the equivalent of an early branch, and the dereferences are there because match on a reference gives references to the fields. If you add a fourth variant, every match without a catch-all arm stops compiling, which is the property you want for a message type. Do not add a _ => arm as a habit, because it turns that guarantee off. The deeper tour is in enums explained and pattern matching.
Exceptions become Result values
JavaScript signals failure by throwing, and nothing in the signature says which functions can. Rust returns a Result<T, E>, so the failure is part of the type and the caller must act on it. The ? operator is the shortcut: on an Err it returns from the current function, converting the error type on the way, and on Ok it unwraps. This is the config-reading code you have written in Node, with the failures as values.
use std::fmt;
use std::num::ParseIntError;
#[derive(Debug)]
enum ConfigError {
Missing(&'static str),
BadNumber { key: &'static str, source: ParseIntError },
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::Missing(k) => write!(f, "missing key {k}"),
ConfigError::BadNumber { key, source } => write!(f, "{key} is not a number: {source}"),
}
}
}
fn get<'a>(pairs: &'a [(&str, &str)], key: &'static str) -> Result<&'a str, ConfigError> {
pairs
.iter()
.find(|(k, _)| *k == key)
.map(|(_, v)| *v)
.ok_or(ConfigError::Missing(key))
}
fn port(pairs: &[(&str, &str)]) -> Result<u16, ConfigError> {
let raw = get(pairs, "port")?;
raw.parse().map_err(|source| ConfigError::BadNumber { key: "port", source })
}
fn main() {
let inputs: [&[(&str, &str)]; 3] = [
&[("port", "8080")],
&[("host", "localhost")],
&[("port", "80x")],
];
for pairs in inputs {
match port(pairs) {
Ok(p) => println!("listening on {p}"),
Err(e) => println!("error: {e}"),
}
}
}listening on 8080
error: missing key port
error: port is not a number: invalid digit found in stringThe error enum has two variants, one for a missing key and one for a number that did not parse. It wraps the standard ParseIntError in source so the underlying reason is not lost. The Display implementation is what produces the human-readable lines in the output. In port, the ? after get(pairs, "port") is a throw in reverse: it passes the error up without a try block. The map_err call on the next line converts the standard library error into ours. The lifetime 'a in get says the returned string slice lives as long as the input slice, which is the compiler's way of letting you return a view without copying. That annotation is new for most TypeScript developers and is one of the few places where you must write what a garbage collector would have inferred.
Writing the enum and the Display impl by hand is tedious in a large program. Two crates remove the boilerplate: thiserror for libraries, anyhow for applications. The anyhow versus thiserror post says when to pick which, and error handling in Rust covers the wider design. One real difference from exceptions: Rust has panics, but they mean a bug or an impossible state, such as an index out of range, not an expected failure like a missing file.
The garbage collector becomes ownership
This is the part with no JavaScript equivalent. Every value has one owner. When the owner goes out of scope, the value is freed. You can lend a value out with &, as many shared references as you like or exactly one &mut. The consequence you notice first is in closures. A JavaScript closure captures variables by reference and keeps them alive for as long as the closure lives. A Rust closure captures by borrow unless you say otherwise, and a borrow cannot outlive the thing it borrows. Spawn a thread that reads a vector from the enclosing function and this is what happens.
use std::thread;
fn main() {
let names = vec![String::from("ada"), String::from("grace")];
let handle = thread::spawn(|| {
println!("{}", names.len());
});
handle.join().unwrap();
}error[E0373]: closure may outlive the current function, but it borrows `names`, which is owned by the current function
--> src/main.rs:5:32
|
5 | let handle = thread::spawn(|| {
| ^^ may outlive borrowed value `names`
6 | println!("{}", names.len());
| ----- `names` is borrowed here
|
note: function requires argument type to outlive `'static`
--> src/main.rs:5:18
|
5 | let handle = thread::spawn(|| {
| __________________^
6 | | println!("{}", names.len());
7 | | });
| |______^
help: to force the closure to take ownership of `names` (and any other referenced variables), use the `move` keyword
|
5 | let handle = thread::spawn(move || {
| ++++
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0373`.The compiler is worried about a specific scenario: the thread could still be running after main, or any function in its place, has returned and dropped names. The bound 'static on thread::spawn says the closure may not borrow from the local stack. In JavaScript this program is fine, because the closure keeps names reachable. In Rust you decide who owns the vector. The help text offers the fix, move, which transfers ownership of names into the closure.
use std::thread;
fn main() {
let names = vec![String::from("ada"), String::from("grace")];
let handle = thread::spawn(move || names.len());
println!("{}", handle.join().unwrap());
}2After the move, names is no longer usable in main, and the compiler enforces that. The thread returns the length, and join hands it back as a Result, because a thread can panic. If two threads need the same data you share it with Arc, and if they need to mutate it you add a Mutex, as in the Arc and Mutex pattern. The longer story of why these errors exist is in ownership in Rust. If your mental model of a variable is a name that points at an object somewhere on a heap, the shift is to think of the name as the object's owner.
Promises become futures, and futures are lazy
This is the translation with the sharpest edge. A JavaScript promise starts running the moment you create it. fetch(url) sends the request before you await anything. A Rust future is a state machine that does nothing until something polls it. Calling an async fn only builds the state machine. The program below makes that visible without a runtime. It defines block_on, a short executor built on the standard library's no-op waker, and prints from inside the future.
use std::future::Future;
use std::pin::pin;
use std::task::{Context, Poll, Waker};
async fn fetch(id: u32) -> String {
println!("fetching {id}");
format!("user-{id}")
}
fn block_on<F: Future>(fut: F) -> F::Output {
let mut fut = pin!(fut);
let mut cx = Context::from_waker(Waker::noop());
loop {
if let Poll::Ready(v) = fut.as_mut().poll(&mut cx) {
return v;
}
}
}
fn main() {
let pending = fetch(1);
println!("created the future, nothing has run yet");
let value = block_on(pending);
println!("got {value}");
let _forgotten = fetch(2);
println!("dropped without polling, so fetch(2) never printed");
}created the future, nothing has run yet
fetching 1
got user-1
dropped without polling, so fetch(2) never printedFollow the output. fetch(1) created the future, and nothing printed. Only when block_on polled it did "fetching 1" appear. The second future was created and dropped without ever being polled, so "fetching 2" never printed at all. In JavaScript the equivalent of that last line would have sent the request and discarded the result. This is the reason a Rust server does not need an AbortController for most cancellation: dropping a future stops it at the next suspension point. It is also the reason that a forgotten .await is a bug that compiles. The compiler catches the two common shapes of it. If you assign the future where a value is expected, you get a type error that tells you what to do:
error[E0308]: mismatched types
--> src/main.rs:6:24
|
6 | let name: String = fetch(1);
| ------ ^^^^^^^^ expected `String`, found future
| |
| expected due to this
|
note: calling an async function returns a future
--> src/main.rs:6:24
|
6 | let name: String = fetch(1);
| ^^^^^^^^
help: consider `await`ing on the `Future`
|
6 | let name: String = fetch(1).await;
| ++++++
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0308`.That came from let name: String = fetch(1); inside an async function. And if you call the function as a statement and drop the result, you get a warning rather than an error, which is why you should treat warnings in async code as failures in CI:
warning: unused implementer of `Future` that must be used
--> src/main.rs:6:5
|
6 | fetch(1);
| ^^^^^^^^
|
= note: futures do nothing unless you `.await` or poll them
= note: `#[warn(unused_must_use)]` (part of `#[warn(unused)]`) on by default
warning: 1 warning emittedA real program does not use a hand-written executor. It uses a runtime, almost always Tokio, because the standard library ships the Future trait and the async syntax but no scheduler and no I/O. That is a genuine departure from Node, where the event loop is built in. You choose the runtime, you start it with #[tokio::main], and you spawn tasks with tokio::spawn, which is the closest thing to calling an async function in JavaScript and letting it run. The runtime explained and async and await explained posts pick up from here. Two more differences will catch you. A blocking call inside an async task stalls the executor thread, which has no JavaScript parallel because Node has one thread to begin with, and a Mutex guard held across an .await is a deadlock waiting to happen.
Array methods become lazy iterators
map, filter, find and reduce all exist in Rust, but they live on iterators, not on the collection, and an iterator chain does nothing until something consumes it. That is the opposite of JavaScript, where arr.filter(...).map(...) builds two intermediate arrays eagerly. The program below puts a println! inside a map so you can see when the work happens.
fn main() {
let orders = vec![("ada", 30), ("grace", 0), ("ada", 12), ("linus", 8)];
let lazy = orders.iter().filter(|(_, qty)| *qty > 0).map(|(who, qty)| {
println!("visiting {who}");
qty * 2
});
println!("built the pipeline");
let doubled: Vec<i32> = lazy.collect();
println!("{doubled:?}");
let total: i32 = orders.iter().map(|(_, qty)| qty).sum();
let first_big = orders.iter().find(|(_, qty)| *qty > 10);
println!("{total} {first_big:?}");
}built the pipeline
visiting ada
visiting ada
visiting linus
[60, 24, 16]
50 Some(("ada", 30))"built the pipeline" prints before any "visiting" line, because filter and map only construct a description of the work. The collect call drives it, one element at a time through both stages, with no intermediate vector. Grace's order of zero is filtered out before the map sees it, which is why there are three visits, not four. The last two lines show consumers that stop early or reduce: sum adds everything, and find stops at the first match and returns an Option. The type annotation on doubled matters: collect can build a Vec, a HashMap, a String or a Result of a collection, and it picks from the type you ask for. In practice this style is as fast as a hand-written loop, and the iterators guide shows the comparison. A side effect inside map, as here, is only for demonstration. Keep chains pure and put effects in a for loop.
Strings are UTF-8 bytes, not UTF-16 code units
A JavaScript string is a sequence of UTF-16 code units, so "🦀".length is 2 and str[0] works until the text leaves the basic plane. A Rust String is UTF-8, and the language refuses to let you pretend a byte offset is a character index. Start with what the lengths say.
fn main() {
let word = "naïve 🦀";
println!("bytes: {}", word.len());
println!("chars: {}", word.chars().count());
println!("first char: {:?}", word.chars().next());
println!("slice [0..2]: {:?}", word.get(0..2));
println!("slice [0..3]: {:?}", word.get(0..3));
println!("upper: {}", word.to_uppercase());
}bytes: 11
chars: 7
first char: Some('n')
slice [0..2]: Some("na")
slice [0..3]: None
upper: NAÏVE 🦀The text has seven characters and eleven bytes: ï takes two bytes and the crab takes four. Slicing by byte range works only on character boundaries, so get(0..2) returns Some("na"), while get(0..3) would cut ï in half and returns None. The indexing form &word[0..3] panics in the same case, so use get when the offsets come from outside. Case conversion is Unicode-aware, so the diaeresis survives. Now try the habit you have from JavaScript:
fn main() {
let word = String::from("hello");
let first = word[0];
println!("{first}");
}error[E0277]: the type `str` cannot be indexed by `{integer}`
--> src/main.rs:3:22
|
3 | let first = word[0];
| ^ string indices are ranges of `usize`
|
= help: the trait `SliceIndex<str>` is not implemented for `{integer}`
= note: you can use `.chars().nth()` or `.bytes().nth()`
for more information, see chapter 8 in The Book: <https://doc.rust-lang.org/book/ch08-02-strings.html#indexing-into-strings>
help: `usize` implements trait `SliceIndex<T>`
--> /rustc/b940084d7eb6.../library/core/src/slice/index.rs:179:0
|
= note: `SliceIndex<[T]>`
--> /rustc/b940084d7eb6.../library/core/src/bstr/traits.rs:197:0
|
= note: `SliceIndex<ByteStr>`
= note: required for `String` to implement `Index<{integer}>`
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0277`.The error is blunt: a string cannot be indexed by an integer, and the note points at chars().nth() and bytes().nth(), which state which of the two you mean. chars() yields Unicode scalar values, not what a reader calls a character, so a flag emoji or an accented letter written as two code points still counts as more than one. If you need grapheme clusters, for truncating text in a UI, the unicode-segmentation crate does the job. The difference between an owned String and a borrowed &str is the other half of this topic, and String versus str is the one post to read before you write your first function signature.
npm becomes Cargo, and one tool does the work of five
The toolchain is the part JavaScript developers like most. cargo new scaffolds a project, cargo add edits Cargo.toml, cargo build compiles, cargo test runs tests with no framework to install, cargo fmt formats and cargo clippy lints. The mapping is close enough to learn in an afternoon.
| JavaScript | Rust |
|---|---|
package.json | Cargo.toml |
package-lock.json | Cargo.lock |
npm install foo | cargo add foo |
node_modules | sources in ~/.cargo/registry, build output in target/ |
| Jest, Vitest | cargo test and #[test] |
| Prettier, ESLint | cargo fmt, cargo clippy |
| a bundler and a transpiler | the compiler, with --release for optimization |
The practical difference is that a Cargo dependency is compiled from source with your code, so the first build is slow and later ones are incremental. There is no runtime resolution and no left-pad class of tiny packages, though the supply chain still deserves care. Cargo.toml explained goes through the manifest field by field.
What you get for the trouble: a measured comparison
The usual reason to learn Rust coming from Node is speed on CPU-bound work, so here is one honest data point. The program below finds the starting number under three million with the longest Collatz chain, using the same algorithm in both languages: one loop, no allocation, no libraries. The JavaScript version is the same file with let and !==. Both print the same answer, 2298025 with 559 steps.
fn main() {
let limit: u64 = 3_000_000;
let mut best = (0u64, 0u32);
for start in 1..limit {
let mut n = start;
let mut steps = 0u32;
while n != 1 {
n = if n % 2 == 0 { n / 2 } else { 3 * n + 1 };
steps += 1;
}
if steps > best.1 {
best = (start, steps);
}
}
println!("{} {}", best.0, best.1);
}rustc -O 0.43 s 0.43 s 0.63 s 0.51 s 0.69 s 0.80 s
node 24.1.0 6.83 s 6.85 s 7.07 s 6.82 s 7.01 s 7.58 sOn the M1 Max, six runs of each: Rust compiled with rustc -O took 0.43 to 0.80 seconds, and Node 24.1.0 took 6.82 to 7.58 seconds, roughly ten to fifteen times longer. The reason is mostly numeric: JavaScript numbers are doubles, the chain values pass 2 to the power 31, so V8 cannot keep them in 32-bit integers, while Rust's u64 is a native integer. The ratio would shrink for code that spends its time in I/O, in JSON parsing handled by native built-ins or in string manipulation, and it can invert for code where the JIT has a well-trodden path and Rust has an accidental allocation in the loop. Treat the figure as the answer to one question, tight numeric loops, and measure your own workload before deciding. The post on when Rust is not faster collects the cases where it is not.
Using Rust inside a JavaScript project
You do not have to rewrite anything to try this. Two routes cover most needs. Compile to WebAssembly for the browser with wasm-bindgen when a hot function such as an image filter or a parser needs to run client-side. Build a native Node addon with napi-rs when the same function is needed on the server and you want near-native speed without a process boundary. The walkthrough for the second is in Node addons with napi-rs. The choice is the usual one: WebAssembly for portability and sandboxing, a native addon for the lowest call overhead.
When this translation does not apply
The mapping above is for backend and tooling work, where Rust competes with Node. It is a poor fit in four situations. If your product is a UI-heavy web app, the framework ecosystem you rely on has no Rust equivalent at the same maturity, and WebAssembly cannot touch the DOM directly. If the team ships weekly and the bottleneck is feature work rather than CPU or memory, the compile-fix loop is a real cost. If the code is glue between hosted services with a few milliseconds of work per request, Node or Deno is fast enough and the time is better spent elsewhere. And if you rely on dynamic features such as eval, runtime plugin loading from source or monkey-patching, Rust offers nothing of the kind. For the other cases, particularly services that hold state in memory, process untrusted input or run for a long time, the type system pays for itself, and the language tour is the next stop.


