Rust for JavaScript and TypeScript Developers
If you write JavaScript or TypeScript, you already know more Rust than you think. Rust has closures that work a lot like arrow functions, async/await, iterator methods with familiar names like map, filter, and find, destructuring, and one command-line tool (Cargo) that does what npm does. If you use TypeScript, you’re also used to a compiler that catches mistakes before your code runs. Rust’s compiler does the same thing, only stricter.
So what’s actually new? Three things. Rust has no garbage collector: every value has one owner, and the compiler checks ownership and borrowing before your program runs. Rust has no undefined or null: a value that might be missing has the type Option. And Rust has no exceptions: a function that can fail returns a Result. Most of your learning time will go into these three ideas.
Why do JavaScript developers learn Rust? Usually for one of these reasons: you want a fast web backend or command-line tool that ships as a single file, you want to speed up a hot spot in the browser with WebAssembly, you want to write a native Node.js addon, or you’re curious about the tools you already use. SWC, Biome, Oxc, Rolldown, Rspack, Turbopack, Lightning CSS, and Deno are all written in Rust.
This page maps what you know from JavaScript and TypeScript to Rust, so you can spend your time on the parts that are really new. For a general comparison with C++, Go, and Python instead, see Rust vs Other Languages.
Language concepts: JavaScript and TypeScript to Rust
Section titled “Language concepts: JavaScript and TypeScript to Rust”Use this table as a phrase book. The last column links to the lesson on this site that explains the Rust side.
| JavaScript / TypeScript | Rust | Learn more |
|---|---|---|
let and const |
let (can’t change) and let mut (can change). A JS const object can still be changed inside; a Rust let value can’t |
Variables |
number |
You pick a size: i32, i64, u32, usize, f64, … Integers and floats are different types |
Data Types |
bigint |
i128 / u128, or the num-bigint crate for any size |
Data Types |
string |
String (owned, growable) and &str (a borrowed view of text) |
Strings |
boolean |
bool |
Booleans |
| Template literals | format!("Hi {name}") and println!("Hi {name}") |
Output |
any, unknown |
No any. Use generics, an enum, or serde_json::Value for JSON of unknown shape |
Generics, Serde & JSON |
interface or type describing an object’s shape |
struct |
Structs |
interface describing methods |
trait |
Traits |
| Structural typing (same shape = same type) | Nominal typing: two structs with the same fields are still different types | Structs |
| Union types and discriminated unions | enum, where each variant can carry its own data |
Enums |
String literal types like "small" or "large" |
An enum with plain variants: enum Size { Small, Large } |
Enums |
undefined, null, optional property x?: T |
Option<T>: either Some(value) or None |
Option |
?. and ?? |
? (inside a function that returns Option), .map(...), .and_then(...), .unwrap_or(...) |
Option |
Non-null assertion x! |
.unwrap() or .expect("reason"), which stop the program if the value is None |
Panic |
throw, try/catch, rejected Promises |
Result<T, E> + the ? operator |
Result |
class MyError extends Error |
Your own error enum (often with thiserror); anyhow in applications |
Custom Errors, anyhow & thiserror |
class with fields and methods |
struct for the data + an impl block for the methods |
Structs |
extends (inheritance) |
No inheritance. Use composition (a struct inside a struct) + traits with default methods | Traits, Design Patterns |
class Dog implements Animal |
impl Animal for Dog |
Traits |
| An array of different classes that share an interface | Vec<Box<dyn Animal>> (a trait object) |
Trait Objects |
Generics like T extends HasId |
Generics with trait bounds: T: HasId |
Generics |
| Arrow functions and callbacks | Closures, described by the traits Fn, FnMut, FnOnce |
Closures, Fn, FnMut & FnOnce |
this |
An explicit self, &self, or &mut self parameter. No surprises about what this points to |
Structs |
Destructuring const { x, y } = point |
Patterns: let Point { x, y } = point; (also in match and function parameters) |
Pattern Matching |
Spread { ...user, admin: true } |
Struct update syntax: User { admin: true, ..user } |
Structs |
switch, or if chains on typeof / kind |
match (the compiler checks you covered every case) |
match |
=== |
==, which compares values (through the PartialEq trait). No type coercion, ever |
Common Traits |
for...of |
for item in &items |
for |
map, filter, reduce, find, some, every |
Iterator methods: map, filter, fold, find, any, all, then collect |
Iterators |
Generators (function*) and Symbol.iterator |
Implement the Iterator trait |
Custom Iterators |
Promise<T> |
A Future with Output = T. Unlike a Promise, it does nothing until awaited |
Async/Await |
async / await |
async fn and .await, plus a runtime like tokio to run them |
Async/Await, Async in Depth |
Promise.all |
tokio::join!, or join_all from the futures crate |
Async in Depth |
setTimeout |
tokio::time::sleep in async code, std::thread::sleep otherwise |
Async/Await |
Web Workers, worker_threads |
Real threads: std::thread::spawn, or rayon for parallel loops |
Concurrency |
ES modules: import, export |
Modules: mod, use, and pub |
Modules |
JSON.parse / JSON.stringify |
serde_json with #[derive(Serialize, Deserialize)] |
Serde & JSON |
console.log |
println!, and dbg! for quick debugging. Use {:?} for types that derive Debug |
Output |
readonly, as const, Object.freeze |
Not needed: everything is read-only unless you write mut |
Variables |
| Decorators | Attributes like #[derive(Debug)] and macros |
Macros |
Number("abc") gives NaN |
"abc".parse::<i32>() returns an Err you have to handle |
Type Casting, Conversions |
| Garbage collector | Ownership and borrowing, checked at compile time | Ownership, Borrowing |
Collections
Section titled “Collections”| JavaScript | Rust | Learn more |
|---|---|---|
Array |
Vec<T> (every item has the same type) |
Vectors |
Fixed-length array, or a TS tuple [string, number] |
[T; N] for a fixed length, a tuple (String, i32) for mixed types |
Arrays, Tuples |
Map, or an object used as a dictionary (Record<string, T>) |
HashMap<K, V> |
HashMaps |
| A map with sorted keys | BTreeMap<K, V> |
HashMaps |
Set |
HashSet<T> (or BTreeSet<T> for sorted order) |
HashSet |
Array used as a queue (push + shift) |
VecDeque<T> (push_back / pop_front, both fast) |
VecDeque & BinaryHeap |
Array used as a stack (push + pop) |
Vec<T> with push / pop |
Vectors |
Uint8Array, Buffer |
Vec<u8>, or a byte slice &[u8] |
Slices |
WeakMap, WeakRef |
Weak<T> (from Rc or Arc) |
Weak & Cow |
One thing to watch: a JavaScript Map, Set, and plain object remember the order you inserted things. A Rust HashMap and HashSet don’t. If you need sorted order, use BTreeMap; if you need insertion order, the indexmap crate gives you exactly that.
Tooling: npm to cargo
Section titled “Tooling: npm to cargo”Cargo will feel like home. It’s your package manager, build tool, test runner, and script runner in one, and every Rust project uses it the same way.
| JavaScript | Rust | Notes |
|---|---|---|
node + npm (or pnpm, yarn) |
cargo |
Builds, runs, tests, adds packages, publishes. See Cargo & Crates |
package.json |
Cargo.toml |
|
package-lock.json |
Cargo.lock |
Created for you |
node_modules/ |
No per-project folder. Downloads are cached once in ~/.cargo, build output goes to target/ |
|
| npmjs.com | crates.io | Every published crate’s API docs are on docs.rs |
npm install zod |
cargo add serde |
|
npm init |
cargo new my_app (cargo new --lib for a library) |
|
npm run dev / build / test |
cargo run / cargo build / cargo test |
Add --release for an optimized build; debug builds are much slower |
"scripts" in package.json |
No scripts section. Use cargo aliases in .cargo/config.toml, or a task runner like just |
|
npx some-tool |
cargo install some-tool, then run it |
cargo binstall downloads prebuilt binaries instead of compiling |
nvm and .nvmrc |
rustup and a rust-toolchain.toml file |
See Rust Tools & Setup |
| npm / pnpm workspaces (monorepo) | A Cargo workspace | One Cargo.toml at the top lists the member crates |
tsc |
rustc, called for you by cargo |
|
tsc --noEmit |
cargo check |
Checks types without building; much faster |
| ESLint | cargo clippy |
Hundreds of helpful lints, official |
| Prettier | cargo fmt |
One standard style for everyone |
nodemon, tsx watch |
cargo watch or bacon |
Installed with cargo install |
| Jest / Vitest / Mocha | Built-in #[test] and cargo test (cargo nextest is a popular faster runner) |
See Testing |
| Jest snapshots | insta |
See Testing Beyond the Basics |
jest.mock() |
mockall, or hand-written fakes that implement your trait |
|
| fast-check | proptest |
Property-based testing |
| c8 / Istanbul (coverage) | cargo llvm-cov |
|
| JSDoc / TypeDoc | /// doc comments in Markdown + cargo doc |
Code examples in docs are run as tests. See Documentation |
| VS Code + the TypeScript server | VS Code + rust-analyzer, or JetBrains RustRover | rust-analyzer gives completion, go-to-definition, and inline errors |
npm publish |
cargo publish |
Here is a small Cargo.toml, so you can compare it with a package.json:
[package]name = "orders-api"version = "0.1.0"edition = "2021"
[dependencies]axum = "0.8"serde = { version = "1", features = ["derive"] }serde_json = "1"tokio = { version = "1", features = ["full"] }A version like "1" means the same as "^1" in package.json: any compatible 1.x release. Features are optional parts of a crate that you switch on; see Cargo Features & cfg.
Libraries and frameworks: “What do we use instead of…?”
Section titled “Libraries and frameworks: “What do we use instead of…?””Every JavaScript developer asks this first. “We use Express and Prisma. What’s the Rust version?” Here are the closest equivalents. Like npm, crates.io has many choices for most jobs; these are the popular, well-maintained ones.
Web and networking
Section titled “Web and networking”| JavaScript | Rust | Notes |
|---|---|---|
| Express, Fastify, Koa | axum (we build one in Project: Web API), actix-web, Rocket | axum and actix-web are widely used in production |
| Express middleware | tower layers and the tower-http crate (CORS, compression, tracing, timeouts) |
axum uses tower directly |
fetch, axios |
reqwest |
Async by default, with an optional blocking client. See Networking |
ws, Socket.IO |
WebSockets in axum (the ws feature), tokio-tungstenite, or socketioxide (a Socket.IO server) |
|
| Apollo Server (GraphQL) | async-graphql |
|
gRPC (@grpc/grpc-js) |
tonic (with prost for protobuf) |
|
| Swagger / OpenAPI generators | utoipa |
|
jsonwebtoken |
jsonwebtoken |
Same name, same idea |
bcrypt |
argon2 or bcrypt |
Data access
Section titled “Data access”| JavaScript | Rust | Notes |
|---|---|---|
| Prisma, TypeORM | SeaORM (async, entities, migrations) or Diesel (queries type-checked at compile time) | SeaORM feels closest to Prisma and TypeORM |
Knex, pg, postgres.js |
sqlx: you write plain SQL, and the query! macro checks it against your real database at compile time |
Async, very popular, not an ORM |
better-sqlite3 |
rusqlite, or sqlx with SQLite |
|
| Mongoose, the MongoDB driver | mongodb (the official driver) |
No Mongoose-style schemas; you use serde structs |
| ioredis | redis |
Rust Databases shows sqlx, Diesel, and SeaORM with working code.
Everyday building blocks
Section titled “Everyday building blocks”| JavaScript | Rust | Notes |
|---|---|---|
JSON.parse / JSON.stringify |
serde + serde_json |
The standard; also does TOML, YAML, and more. See Serde & JSON |
| Zod, Yup | Your struct’s types + serde do most of the work; validator or garde for rules like “at least 8 characters” |
If the JSON doesn’t match the struct, parsing fails with an error |
| Lodash | Mostly the standard library’s iterators; itertools for extras like unique, sorted_by, and chunk_by |
|
| date-fns, Day.js, Luxon | chrono or jiff |
See Date & Time |
| dotenv | dotenvy |
See Environment & Processes |
| Commander, yargs | clap |
See Project: CLI with clap |
| chalk | colored or owo-colors |
|
| winston, pino | tracing (+ tracing-subscriber), or log + env_logger |
See Logging |
uuid |
uuid |
|
EventEmitter |
Channels (std::sync::mpsc, or tokio::sync::broadcast for many listeners) |
See Channels |
| BullMQ (job queues) | tokio::spawn for in-process tasks; apalis for persistent job queues |
|
| Regular expressions | regex |
No lookahead or backreferences, by design. See Regex |
Frontend, desktop, and Node.js
Section titled “Frontend, desktop, and Node.js”| JavaScript | Rust | Notes |
|---|---|---|
| Speeding up heavy browser code | Compile Rust to WebAssembly with wasm-bindgen and wasm-pack |
See WebAssembly and the section below |
| React, Vue, Svelte | Leptos, Yew, Dioxus (all compile to WebAssembly) |
All before version 1.0; APIs still change |
| Electron | Tauri: your web frontend + a Rust backend, with much smaller apps |
Tauri 2 is stable. See GUI Apps |
| Native Node.js addons (C++ with node-gyp) | napi + napi-derive (napi-rs), or neon |
Ship prebuilt binaries to npm; no C++ needed |
| Phaser, Three.js games | Bevy, macroquad |
Bevy is pre-1.0 and breaks its API between releases. See Game Development |
Side by side: an interface becomes a struct
Section titled “Side by side: an interface becomes a struct”Here is a TypeScript object with an interface, a method-like function, and a spread copy:
interface User { name: string; age: number; admin: boolean;}
const greeting = (u: User) => `Hi, I'm ${u.name} (${u.age})`;
const ada: User = { name: "Ada", age: 36, admin: false };const promoted = { ...ada, admin: true };
console.log(greeting(ada));console.log(promoted);In Rust, the shape becomes a struct, and the function becomes a method in an impl block:
#[derive(Debug, Clone)]struct User { name: String, age: u32, admin: bool,}
impl User { // `&self` means "this method reads the user, it doesn't change it". fn greeting(&self) -> String { format!("Hi, I'm {} ({})", self.name, self.age) }}
fn main() { let ada = User { name: "Ada".to_string(), age: 36, admin: false };
// Like { ...ada, admin: true } in JavaScript. let promoted = User { admin: true, ..ada.clone() };
println!("{}", ada.greeting()); println!("{:?}", promoted);}Output:
Hi, I'm Ada (36)User { name: "Ada", age: 36, admin: true }A few things are different. Every field has an exact type: age is a u32 (a whole number that can’t be negative), not a general number. #[derive(Debug)] is what lets you print the whole struct with {:?}, like console.log(obj). And why .clone()? The spread ..ada would move the name string out of ada, and then we couldn’t use ada anymore. Cloning first keeps ada intact. That’s ownership at work, and you’ll see it a lot.
Side by side: a discriminated union becomes an enum
Section titled “Side by side: a discriminated union becomes an enum”TypeScript developers love discriminated unions: a kind field tells you which shape you have, and switch narrows the type:
type Shape = | { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number };
function area(s: Shape): number { switch (s.kind) { case "circle": return Math.PI * s.radius ** 2; case "rect": return s.width * s.height; }}
const shapes: Shape[] = [ { kind: "circle", radius: 1 }, { kind: "rect", width: 2, height: 3 },];for (const s of shapes) console.log(area(s).toFixed(2));Rust has this built in, and it’s one of the language’s best features. Each enum variant carries its own data, and match takes it apart:
enum Shape { Circle { radius: f64 }, Rect { width: f64, height: f64 },}
fn area(shape: &Shape) -> f64 { match shape { Shape::Circle { radius } => std::f64::consts::PI * radius * radius, Shape::Rect { width, height } => width * height, }}
fn main() { let shapes = vec![ Shape::Circle { radius: 1.0 }, Shape::Rect { width: 2.0, height: 3.0 }, ];
for shape in &shapes { println!("{:.2}", area(shape)); }}Output:
3.146.00No kind string is needed: the variant itself is the tag. And match must cover every variant. If you add Triangle later, every match that forgets it becomes a compile error, which is like TypeScript’s never exhaustiveness trick, but always on. More on this in Pattern Matching.
Side by side: undefined and null become Option
Section titled “Side by side: undefined and null become Option”In TypeScript, optional values show up as undefined, null, or an optional property, and you use ?. and ?? to deal with them:
interface User { name: string; email?: string;}
function findUser(id: number): User | undefined { if (id === 1) return { name: "Ada", email: "ada@example.com" }; if (id === 2) return { name: "Grace" }; return undefined;}
for (const id of [1, 2, 3]) { console.log(`${id}: ${findUser(id)?.email?.toUpperCase() ?? "no email"}`);}In Rust, a value that might be missing has the type Option<T>. It’s either Some(value) or None, and you can’t use it as a plain value until you’ve handled the None case:
struct User { name: String, email: Option<String>,}
fn find_user(id: u32) -> Option<User> { match id { 1 => Some(User { name: "Ada".to_string(), email: Some("ada@example.com".to_string()) }), 2 => Some(User { name: "Grace".to_string(), email: None }), _ => None, }}
// Inside a function that returns Option, `?` works like `?.`:// if the value is None, stop here and return None.fn email_domain(id: u32) -> Option<String> { let email = find_user(id)?.email?; let at = email.find('@')?; Some(email[at + 1..].to_string())}
fn main() { for id in [1, 2, 3] { // Like findUser(id)?.email?.toUpperCase() ?? "no email" let email = find_user(id) .and_then(|user| user.email) .map(|email| email.to_uppercase()) .unwrap_or("no email".to_string()); println!("{id}: {email}"); }
println!("{:?}", email_domain(1)); println!("{:?}", email_domain(2));
if let Some(user) = find_user(2) { println!("found {}", user.name); }}Output:
1: ADA@EXAMPLE.COM2: no email3: no emailSome("example.com")Nonefound GraceThere is no “cannot read properties of undefined” in Rust, because there is no undefined to read from. The compiler simply won’t let you call .to_uppercase() on an Option<String>. And there’s no difference between null and undefined to remember: there’s only None. Learn the helper methods on the Option page, and the short forms on if let.
Side by side: try/catch becomes Result and ?
Section titled “Side by side: try/catch becomes Result and ?”In JavaScript, a function throws, and you catch the error somewhere up the call stack. Nothing in the function’s signature tells you it might throw:
function parseAge(input) { const age = Number(input); if (!Number.isInteger(age)) throw new Error(`'${input}' is not a whole number`); if (age > 150) throw new Error(`${age} is too old`); return age;}
function totalAge(a, b) { return parseAge(a) + parseAge(b);}
for (const [a, b] of [["36", "41"], ["36", "abc"], ["36", "200"]]) { try { console.log(`Total: ${totalAge(a, b)}`); } catch (e) { console.log(`Error: ${e.message}`); }}In Rust, a function that can fail returns a Result: either Ok(value) or Err(error). The ? operator means “if this failed, return the error to my caller right now”. It does the job of letting an exception bubble up, but you can see it in the code:
fn parse_age(input: &str) -> Result<u32, String> { let age: u32 = input .parse() .map_err(|_| format!("'{input}' is not a whole number"))?;
if age > 150 { return Err(format!("{age} is too old")); } Ok(age)}
fn total_age(a: &str, b: &str) -> Result<u32, String> { // Each `?` passes an error up to our caller, like an exception would. let first = parse_age(a)?; let second = parse_age(b)?; Ok(first + second)}
fn main() { for (a, b) in [("36", "41"), ("36", "abc"), ("36", "200")] { match total_age(a, b) { Ok(total) => println!("Total: {total}"), Err(e) => println!("Error: {e}"), } }}Output:
Total: 77Error: 'abc' is not a whole numberError: 200 is too oldThe signature -> Result<u32, String> tells every caller “this can fail”, and the compiler won’t let them ignore it. The same goes for async code: where you’d get a rejected Promise in JavaScript, an async Rust function returns a Result, and you write .await?. For your own error types, see Custom Errors. In applications, the anyhow crate gives you something close to “catch any error” (see anyhow & thiserror).
Side by side: array methods become iterators
Section titled “Side by side: array methods become iterators”The array methods you use every day have direct Rust equivalents:
const orders = [ { customer: "Ada", total: 120, paid: true }, { customer: "Grace", total: 80, paid: false }, { customer: "Linus", total: 200, paid: true },];
const paidNames = orders.filter((o) => o.paid).map((o) => o.customer);const revenue = orders.filter((o) => o.paid).reduce((sum, o) => sum + o.total, 0);const firstBig = orders.find((o) => o.total > 150);
console.log(paidNames.join(", "));console.log(`Revenue: ${revenue}`);console.log(`First big order: ${firstBig?.customer ?? "none"}`);console.log(`Anything unpaid? ${orders.some((o) => !o.paid)}`);The Rust version uses iterators and closures. The closure syntax is |o| o.paid instead of (o) => o.paid:
struct Order { customer: String, total: u32, paid: bool,}
fn main() { let orders = vec![ Order { customer: "Ada".to_string(), total: 120, paid: true }, Order { customer: "Grace".to_string(), total: 80, paid: false }, Order { customer: "Linus".to_string(), total: 200, paid: true }, ];
// filter + map, then collect() turns the result into a Vec. let paid_names: Vec<&str> = orders .iter() .filter(|o| o.paid) .map(|o| o.customer.as_str()) .collect();
// reduce with a starting value is called fold. let revenue = orders .iter() .filter(|o| o.paid) .fold(0, |sum, o| sum + o.total);
// find returns an Option, because there might be no match. let first_big = orders.iter().find(|o| o.total > 150);
println!("{}", paid_names.join(", ")); println!("Revenue: {revenue}"); match first_big { Some(o) => println!("First big order: {}", o.customer), None => println!("First big order: none"), } // some() is called any(). println!("Anything unpaid? {}", orders.iter().any(|o| !o.paid));}Output:
Ada, LinusRevenue: 320First big order: LinusAnything unpaid? trueThere’s one big difference from JavaScript. Each array method in JS builds a whole new array right away. Rust iterators are lazy: filter and map do nothing on their own, and the work happens in one pass when you call something like collect(), sum(), fold(), or a for loop. No temporary arrays are created in between. For the sum alone, you could also write .map(|o| o.total).sum::<u32>(). Sorting lives on Vec (sort, sort_by), not on iterators; see Sorting.
Side by side: Promises become futures + tokio
Section titled “Side by side: Promises become futures + tokio”Here is some async JavaScript. Watch when "fetching 1" is printed:
async function fetchUser(id) { console.log(`fetching ${id}`); await new Promise((resolve) => setTimeout(resolve, 100)); return `user ${id}`;}
const promise = fetchUser(1); // prints "fetching 1" right awayconsole.log("promise created");console.log(await promise);
const [a, b] = await Promise.all([fetchUser(2), fetchUser(3)]);console.log(`${a}, ${b}`);In JavaScript, calling an async function starts it immediately. Rust is different: calling an async fn only creates a future, a description of work. Nothing runs until you .await it. And Rust has no built-in event loop, so you add a runtime: tokio is the most popular one.
use std::time::Duration;
async fn fetch_user(id: u32) -> String { println!("fetching {id}"); tokio::time::sleep(Duration::from_millis(100)).await; format!("user {id}")}
#[tokio::main]async fn main() { // Creating a future does NOT start it. Nothing is printed yet. let future = fetch_user(1); println!("future created"); println!("{}", future.await);
// Like Promise.all: run both at the same time and wait for both. let (a, b) = tokio::join!(fetch_user(2), fetch_user(3)); println!("{a}, {b}");}Output:
future createdfetching 1user 1fetching 2fetching 3user 2, user 3See the order? In JavaScript, “fetching 1” would come before “promise created”. In Rust, it comes after “future created”, because the future only starts at .await. #[tokio::main] sets up the runtime (your event loop) and runs main on it. tokio::join! waits for both futures together, like Promise.all; tokio::spawn starts a task in the background, which is the closest thing to calling an async function without awaiting it. The Async/Await and Async in Depth lessons explain the details.
Rust in your JavaScript project: WebAssembly and Node addons
Section titled “Rust in your JavaScript project: WebAssembly and Node addons”You don’t have to rewrite anything to start using Rust. Two common ways to mix it into a JavaScript project:
WebAssembly in the browser. Mark Rust functions with #[wasm_bindgen], build with wasm-pack, and import the result like any ES module. wasm-pack even writes a .d.ts file, so TypeScript knows your function types:
use wasm_bindgen::prelude::*;
#[wasm_bindgen]pub fn slugify(title: &str) -> String { title .to_lowercase() .split_whitespace() .collect::<Vec<_>>() .join("-")}import init, { slugify } from "./pkg/my_lib.js";
await init(); // download and start the .wasm fileconsole.log(slugify("Hello Rust World")); // "hello-rust-world"The WebAssembly lesson walks you through the whole setup, and explains which values cross the boundary cheaply. A tip: Wasm pays off for heavy computation (image processing, parsing, simulations), not for small functions you call thousands of times, because every call between JS and Wasm has a small cost.
Native Node.js addons. With napi-rs (the napi and napi-derive crates) you mark a Rust function with #[napi], and it becomes a function you can require or import from Node. Many popular npm packages ship prebuilt Rust binaries this way, so users never need a Rust compiler.
Things that will trip you up as a JavaScript developer
Section titled “Things that will trip you up as a JavaScript developer”Everyone coming from JavaScript hits the same bumps. Knowing them in advance helps.
Passing a value to a function moves it. In JavaScript, passing an array to a function passes a reference, and you can keep using the array afterwards. In Rust, passing a Vec or String by value moves ownership into the function, and the original variable can’t be used anymore:
fn print_all(names: Vec<String>) { for name in names { println!("{name}"); }}
fn main() { let names = vec![String::from("Ada"), String::from("Grace")]; print_all(names); println!("{} names", names.len());}error[E0382]: borrow of moved value: `names` --> src/main.rs:10:26 | 8 | let names = vec![String::from("Ada"), String::from("Grace")]; | ----- move occurs because `names` has type `Vec<String>`, which does not implement the `Copy` trait 9 | print_all(names); | ----- value moved here10 | println!("{} names", names.len()); | ^^^^^ value borrowed here after moveThe usual fix is to borrow instead: make the function take names: &[String] and call it with print_all(&names). Read Ownership and Borrowing first; they explain this rule, and you’ll meet it every day.
Numbers have types, and they don’t mix. JavaScript has one number. In Rust, 3 is an integer and 2.5 is a float, and you can’t combine them without a conversion:
fn main() { let items = 3; let price = 2.5; let total = items * price; println!("{total}");}error[E0277]: cannot multiply `{integer}` by `{float}` --> src/main.rs:4:23 |4 | let total = items * price; | ^ no implementation for `{integer} * {float}`Write items as f64 * price, or make items a float from the start (3.0). Also note that integer division drops the remainder: 7 / 2 is 3, not 3.5. See Data Types and Type Casting.
A few more you’ll run into:
- Futures are lazy and need a runtime. As you saw above, nothing happens until
.await, and you need tokio (or another runtime) to run async code at all. Forgetting.awaitgives you a compiler warning, not a running task. - Strings can’t be indexed.
name[0]doesn’t compile, because a RustStringis UTF-8 and one character can take several bytes. Usename.chars().next()or.chars().nth(i). And learn the difference betweenStringand&strearly: functions usually take&str, structs usually storeString. See Strings. - No structural typing and no
any. In TypeScript, any object with the right fields fits an interface. In Rust, a function that takes aUseronly accepts aUser. For shared behavior, use traits. For JSON whose shape you don’t know, useserde_json::Value. - Shared, mutable object graphs are hard. In JavaScript it’s normal to pass one object around and let everyone change it, or to have objects point back to their parent. The borrow checker allows many readers or one writer at a time, never both. Patterns like that need smart pointers (
Rc<RefCell<T>>,Arc<Mutex<T>>) or, better, a different design. Expect a few frustrating weeks. It gets much easier. HashMaphas no order. JavaScriptMaps and objects keep insertion order, so code often relies on it without noticing. A RustHashMapdoesn’t. Sort before printing, or useBTreeMapor theindexmapcrate.- No exceptions. Errors are values, and you’ll write
?a lot. Calling.unwrap()is fine while learning, but it’s the Rust version of TypeScript’s!: “trust me, it’s there”. If you’re wrong, the program panics. - Compile times. There’s no instant reload like
node app.js. A first build with many crates takes a while; later builds are incremental and faster. Usecargo checkwhile you edit, since it skips code generation.
A learning path for JavaScript developers
Section titled “A learning path for JavaScript developers”You don’t need to read this site in order. As a JavaScript or TypeScript developer, this route skips what you already know and spends time where Rust is different:
- Get Started and Rust Tools & Setup: install Rust, set up VS Code with rust-analyzer, run
cargo new. - Variables and Data Types: read these properly. The number types are new to you.
- Ownership, then Borrowing, then Slices: the most important pages for you. Take your time.
- Strings:
Stringvs&str. - Structs and Enums, then match and Pattern Matching: your interfaces and discriminated unions.
- Option and Result, then Custom Errors.
- Closures and Iterators: your array methods.
- Vectors, HashMaps, and HashSet.
- Traits, Generics, and Common Traits.
- Modules, Cargo & Crates, and Testing.
- Lifetimes and Smart Pointers.
- Async/Await and Async in Depth.
- Serde & JSON, then build the Web API project, or try WebAssembly.
Practice
Section titled “Practice”Both exercises give you a JavaScript or TypeScript snippet. Port it to Rust.
1. Count words with a Map
Section titled “1. Count words with a Map”This JavaScript counts how often each word appears, then prints the words from most to least common (ties in alphabetical order). Port it to Rust. Hint: use a HashMap<&str, u32> and *counts.entry(word).or_insert(0) += 1. To sort, collect the map into a Vec of (word, count) pairs and use sort_by.
const text = "the cat and the hat and the bat";
const counts = new Map();for (const word of text.split(" ")) { counts.set(word, (counts.get(word) ?? 0) + 1);}
const sorted = [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));for (const [word, n] of sorted) console.log(`${word}: ${n}`);Show solution
use std::collections::HashMap;
fn main() { let text = "the cat and the hat and the bat";
let mut counts: HashMap<&str, u32> = HashMap::new(); for word in text.split(' ') { *counts.entry(word).or_insert(0) += 1; }
// Like [...counts]: turn the map into a list of (word, count) pairs. let mut sorted: Vec<(&str, u32)> = counts.into_iter().collect(); // Most common first; ties in alphabetical order. sorted.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
for (word, n) in sorted { println!("{word}: {n}"); }}Output:
the: 3and: 2bat: 1cat: 1hat: 1entry(word).or_insert(0) does the counts.get(word) ?? 0 part and the set in one step: it gives you a mutable reference to the count, creating it with 0 if it’s missing. The sorting is essential here, not just nice to have: without it, a HashMap prints in a different order from run to run.
2. Port optional settings with defaults
Section titled “2. Port optional settings with defaults”This TypeScript function takes an options object where every field is optional and fills in defaults with ??. Port it to Rust. Hint: make a struct with Option fields and add #[derive(Default)]. Then ServerOptions { port: Some(3000), ..Default::default() } works like passing { port: 3000 }. For a bool, the default is false.
interface ServerOptions { host?: string; port?: number; verbose?: boolean;}
function describe(opts: ServerOptions): string { const host = opts.host ?? "localhost"; const port = opts.port ?? 8080; const mode = opts.verbose ? "verbose" : "quiet"; return `${host}:${port} (${mode})`;}
console.log(describe({}));console.log(describe({ port: 3000 }));console.log(describe({ host: "example.com", verbose: true }));Show solution
#[derive(Default)]struct ServerOptions { host: Option<String>, port: Option<u16>, verbose: bool,}
fn describe(opts: &ServerOptions) -> String { let host = opts.host.as_deref().unwrap_or("localhost"); let port = opts.port.unwrap_or(8080); let mode = if opts.verbose { "verbose" } else { "quiet" }; format!("{host}:{port} ({mode})")}
fn main() { println!("{}", describe(&ServerOptions::default())); println!("{}", describe(&ServerOptions { port: Some(3000), ..Default::default() })); println!( "{}", describe(&ServerOptions { host: Some("example.com".to_string()), verbose: true, ..Default::default() }) );}Output:
localhost:8080 (quiet)localhost:3000 (quiet)example.com:8080 (verbose)unwrap_or is your ??. as_deref() turns an Option<String> into an Option<&str>, so it can fall back to the plain text "localhost" without copying anything. And ..Default::default() is the Rust way to say “every field I didn’t mention gets its default”, which is exactly what leaving a property out does in JavaScript. A u16 port also means a port like 70000 can’t even be stored by mistake.
- Much of JavaScript and TypeScript carries over: closures, async/await, destructuring, and array-style methods like
map,filter, andfindall have close Rust equivalents. - The big shifts are ownership instead of a garbage collector,
Optioninstead ofundefinedandnull, andResult+?instead ofthrowandtry/catch. - TypeScript interfaces become structs (for data) or traits (for behavior), and discriminated unions become enums with
match. - Rust futures are lazy and need a runtime like tokio;
tokio::join!plays the role ofPromise.all. - Cargo replaces npm,
package.json, and your scripts; clippy and rustfmt replace ESLint and Prettier; testing is built in. - For Express look at axum or actix-web, for
fetchlook at reqwest, for Prisma look at SeaORM or Diesel, and for Electron look at Tauri. You can also bring Rust into a JS project through WebAssembly or napi-rs.
Quick check
Section titled “Quick check”Quick check
1. What does Rust use instead of undefined and null?
2. In JavaScript, calling an async function starts it right away. What happens when you call an async fn in Rust without .await?
3. Your TypeScript code uses a discriminated union with a kind field. What is the closest Rust feature?
4. Your team's backend uses Express. Which Rust crate is the closest match?
Score: 0 / 4
Read more
Section titled “Read more”- The Rust Programming Language: the official book. Chapters 4 (ownership), 6 (enums and pattern matching), and 9 (errors) matter most for JavaScript developers.
- Tokio tutorial: the official guide to async Rust with tokio, from your first task to channels and I/O.
- The wasm-bindgen Guide: how Rust and JavaScript talk to each other in WebAssembly, with many examples.
- Compiling from Rust to WebAssembly (MDN): a step-by-step tutorial from the MDN Web Docs, including publishing to npm.
- napi-rs: the documentation for writing Node.js addons in Rust.