Output & Running
Hello, World — and no entry point
A JavaScript file has no
main. The file is the program: statements run top to bottom the moment the engine reaches them, which is closer to a Rust doctest or a build.rs than to a binary crate.fn main() {
println!("Hello, World!");
}console.log("Hello, World!");There is no compile step and no linker, so the first thing you learn about a mistake is when execution reaches it.
console.log is println! with automatic Display-or-Debug for anything you hand it, and it takes many arguments, separating them with spaces. It writes to stdout; console.error writes to stderr, which is where a Node CLI should put diagnostics.Formatting: no format string, and no Debug
Template literals interpolate any expression, so the captured-identifier form of
println! carries over. What does not carry over is the format specification after the colon.fn main() {
let name = "Ada";
let scores = vec![90, 85];
println!("{name} scored {:?}", scores);
println!("{:>8.2}", 3.14159);
}const name = "Ada";
const scores = [90, 85];
console.log(`${name} scored ${JSON.stringify(scores)}`);
console.log((3.14159).toFixed(2).padStart(8));JavaScript has no
{:>8.2}: width, precision, alignment and fill are all method calls — toFixed, padStart, toLocaleString — applied to the value before it reaches the string. And there is no Debug: console.log of an object prints an engine-specific rendering that differs between Node and browsers, so anything you actually depend on should go through JSON.stringify. Nothing is derived, because nothing is typed.Nothing checks the program before it runs
This is the change of habit everything else follows from. Rust decides at compile time what JavaScript decides at the moment each line executes — including whether a method exists at all.
fn main() {
let count = 3;
// println!("{}", count + "one"); // uncomment: mismatched types, no binary
println!("{count}");
}const count = 3;
console.log(count + "one"); // "3one" — a defined operation, not an error
try {
count.toFixd(2); // the typo is invisible until this line runs
} catch (error) {
console.log(error.constructor.name + ": count.toFixd is not a function");
}The C# and Java pages on this site describe a compiler that checks less than Rust's; JavaScript checks nothing. A misspelled method is a
TypeError reached only if that branch runs, and adding a number to a string is a defined operation rather than an error. Two consequences for a Rust author: your test coverage is now the type checker, and TypeScript exists precisely to give some of it back — it is the same language with a checker bolted on top, erased before execution.Arguments and the environment
There is no single standard library, because there is no single host. Arguments, the environment, the filesystem and the clock come from whatever is running the code.
fn main() {
let arguments: Vec<String> = std::env::args().collect();
println!("{} argument(s)", arguments.len().saturating_sub(1));
println!("{:?}", std::env::var("MISSING_VARIABLE").ok());
}// Node only — a browser has neither of these.
const userArguments = process.argv.slice(2);
console.log(`${userArguments.length} argument(s)`);
console.log(process.env.MISSING_VARIABLE);In Node they are on
process; in a browser none of them exist, and reaching for them is how a shared module breaks. process.argv starts with the node binary and the script path, so the user arguments begin at index 2. A missing environment variable is undefined rather than an Err, and every value is a string. The portable subset is smaller than you would like: JSON, Math, Date, Intl, fetch, URL, TextEncoder and the collections — which is exactly the set worth restricting a library to.Ownership Meets a Collector
Nothing is moved
Assignment in JavaScript is neither a move nor a copy nor a borrow. Both names refer to the same object, both may mutate it, and the object lives until nothing refers to it.
fn main() {
let first = vec![1, 2, 3];
let second = first; // first is MOVED
// println!("{:?}", first); // uncomment: use of moved value
println!("{:?}", second);
}const first = [1, 2, 3];
const second = first; // both names, one array
second.push(4);
console.log(first.length, second.length);So the mental model to switch off is the one that asks "who owns this?" — nobody does. There is no
Clone, no Copy, no lifetime, and no way to state that a function will not keep a reference. Aliasing is normal and unmarked, which means the defensive habits are inverted: in Rust you clone to satisfy the borrow checker, in JavaScript you copy ([...items], structuredClone) to defend against a caller who might mutate what you handed them.Two mutable references at once, all the time
The rule that shapes every Rust API — one mutable reference, or many shared ones — simply does not exist. Any number of holders may mutate the same object at the same time.
fn main() {
let mut totals = vec![1, 2, 3];
let first = &mut totals;
// let second = &mut totals; // uncomment: second mutable borrow
first.push(4);
println!("{:?}", first);
}const totals = [1, 2, 3];
const first = totals;
const second = totals; // a second mutable "reference"
first.push(4);
second.push(5);
console.log(totals.join(","));That sounds like a recipe for data races, and it is not, for one reason: JavaScript is single-threaded, so no two of those mutations can interleave mid-statement. What you get instead is the logical half of the problem — an object changed under a function that was reading it, an array mutated while something iterates it. The defence is convention (return new arrays, do not mutate arguments) and, when it matters,
Object.freeze, which is a shallow run-time check rather than a compile-time guarantee.There is no Drop, and no scope-bound cleanup
RAII has no counterpart. An object is collected at some unspecified later time by the garbage collector, and nothing runs when a binding goes out of scope.
struct Connection { name: String }
impl Drop for Connection {
fn drop(&mut self) {
println!("closing {}", self.name);
}
}
fn main() {
{
let _connection = Connection { name: String::from("db") };
println!("working");
}
println!("after the scope");
}class Connection {
constructor(name) { this.name = name; }
close() { console.log(`closing ${this.name}`); }
}
{
const connection = new Connection("db");
console.log("working");
connection.close(); // you call it, or it never happens
}
console.log("after the scope");So every resource — a file handle, a socket, a database connection, a WebGL context — must be released explicitly, usually in a
try/finally. There is FinalizationRegistry, and the specification is explicit that it may never call you, so it is for diagnostics only. This is the single most important thing to know when designing a wasm API: a JavaScript caller will not drop your Rust object, so a free() method must exist and the docs must say to call it — which is exactly what wasm-bindgen generates.Values & Types
const is not immutability
The keywords line up misleadingly well.
let and let mut map to const and let — with the names the other way round — and neither JavaScript keyword says anything about the value.fn main() {
let total = 1;
let mut count = 0;
count += 1;
let items = vec![1, 2];
// items.push(3); // uncomment: items is not mutable
println!("{total} {count} {:?}", items);
}const total = 1;
let count = 0;
count += 1;
const items = [1, 2];
items.push(3); // legal: the BINDING is const, not the array
console.log(total, count, items.join(","));const means the binding cannot be reassigned. The object it points at is as mutable as ever, so const items still accepts push. There is nothing in the language that makes a value immutable; Object.freeze is shallow and silent (it throws only in strict-mode assignment). Use const by default anyway — it is the idiomatic choice and the linter default — but do not read it as let without mut.The whole type list
JavaScript has seven primitive types in total:
number, string, boolean, undefined, null, symbol and bigint. Everything else — arrays, functions, dates, errors — is an object.fn main() {
let integer: i32 = 42;
let floating: f64 = 3.15;
let flag: bool = true;
let letter: char = 'A';
let nothing: () = ();
println!("{integer} {floating} {flag} {letter} {:?}", nothing);
}const integer = 42; // number
const floating = 3.15; // number — the same type
const flag = true; // boolean
const letter = "A"; // string; there is no char
const nothing = undefined; // the closest thing to ()
console.log(integer, floating, flag, letter, nothing);There is no integer type, no character type, no unit type, and no user-defined primitive.
typeof is the run-time question you can ask, and its answers are famously imperfect: typeof null is "object", a fifteen-year-old bug that cannot be fixed without breaking the web, and typeof [] is also "object" (use Array.isArray). A Rust reader should expect to spend their type-thinking budget on shapes of objects rather than on scalars.Two equality operators, and neither is PartialEq
Rust's
== is PartialEq, which for a Vec means element-by-element. JavaScript has no such trait and no structural comparison for objects at all.fn main() {
let first = vec![1, 2, 3];
let second = vec![1, 2, 3];
println!("{}", first == second); // derived PartialEq: contents
println!("{}", std::ptr::eq(&first, &second));
}const first = [1, 2, 3];
const second = [1, 2, 3];
console.log(first === second); // identity, not contents
console.log(JSON.stringify(first) === JSON.stringify(second));
console.log(0 == "0", 0 === "0"); // == converts; === does not=== on two objects asks whether they are the same object, so two identical arrays are unequal. The usual workaround is JSON.stringify on both, which is wrong for key order and for anything JSON cannot represent — a real deep-equal comes from a library or from node:assert. And ==, the two-character version, converts its operands before comparing; the rule is to never write it, exactly as you would never reach for mem::transmute.Shadowing, and the temporal dead zone
Rust's shadowing — rebinding a name to a new value of a new type in the same scope — has no counterpart. Redeclaring a
let or const in the same block is a SyntaxError.fn main() {
let value = "5";
let value = value.parse::<i32>().unwrap(); // shadowed, and a new type
println!("{}", value + 1);
println!("{value}");
}const value = "5";
{
const inner = Number(value); // a NEW name; const cannot be redeclared
console.log(inner + 1);
}
console.log(value);
try { console.log(later); } catch (error) { console.log(error.constructor.name); }
let later = 1;Shadowing in an inner block works as you would expect. The unfamiliar part is the last two lines:
let and const declarations are hoisted to the top of their block but stay uninitialised until the declaration runs, so touching one earlier is a ReferenceError — the "temporal dead zone". The older var hoists and initialises to undefined, is function-scoped rather than block-scoped, and is the reason both other keywords exist. Do not write var.One Number Type
Every number is an f64
There is one numeric type and it is IEEE 754 double precision. Integer division, integer types, and the distinction between
7 / 2 and 7.0 / 2.0 all disappear.fn main() {
let count: i32 = 7;
println!("{}", count / 2);
println!("{}", count as f64 / 2.0);
println!("{}", i32::MAX as i64 + 1);
}const count = 7;
console.log(Math.trunc(count / 2));
console.log(count / 2);
console.log(2 ** 31);Division always produces a float, so integer division is
Math.trunc(a / b) — or Math.floor, which differs for negatives exactly as Rust's div_euclid does. Integers are exact up to 2⁵³ (Number.MAX_SAFE_INTEGER); past that, additions silently round, which is a quiet correctness bug rather than the wrap or panic Rust would give you. The bitwise operators are the strangest corner: |, & and << convert to 32-bit signed integers first, so 2**31 | 0 is negative.BigInt is a separate type that does not mix
JavaScript grew a second numeric type in 2020, and it is deliberately not interchangeable with the first.
fn main() {
let big: u64 = 18_446_744_073_709_551_615;
println!("{big}");
println!("{}", big / 2);
}const big = 18446744073709551615n; // the n suffix makes a BigInt
console.log(big.toString());
console.log((big / 2n).toString());
// console.log(big + 1); // TypeError: cannot mix BigInt and numberA
BigInt is arbitrary-precision and integral, so it is where a u64 or i64 has to land. Mixing it with a number in arithmetic is a TypeError rather than a coercion — a rare piece of strictness, and a welcome one. It cannot be JSON.stringifyd without a custom replacer, and Math.* does not accept it. This is the type that makes u64 awkward across the wasm boundary, which the last section of this page returns to.No overflow, no panic, no wrapping
The whole vocabulary of
checked_, wrapping_ and saturating_ exists because Rust integers have a width. JavaScript numbers do not.fn main() {
let value: u8 = 250;
println!("{}", value.checked_add(10).map_or(String::from("overflow"), |sum| sum.to_string()));
println!("{}", value.wrapping_add(10));
println!("{}", value.saturating_add(10));
}const value = 250;
console.log(value + 10); // 260 — nothing is 8 bits wide
console.log(new Uint8Array([value + 10])[0]); // 4 — wrapping, if you ask for it
console.log(Math.min(value + 10, 255)); // 255 — saturating, by handSo there is nothing to overflow until 2⁵³, at which point you get silent rounding rather than any of the three behaviours above. Fixed-width arithmetic is available only through the typed arrays —
Uint8Array, Int32Array, BigInt64Array — which are also exactly what you will be sharing with wasm memory, so they are worth knowing well.Strings
One string type, and it is UTF-16
There is no
String/&str split, because there is no ownership to encode: a string is a primitive value, immutable, and copied by value semantics wherever it goes.fn main() {
let owned: String = String::from("naïve");
let borrowed: &str = &owned;
println!("{} bytes, {} chars", borrowed.len(), borrowed.chars().count());
}const word = "naïve";
console.log(word.length, [...word].length);
console.log("🦀".length, [...("🦀")].length);The encoding is the part that bites. A JavaScript string is a sequence of UTF-16 code units, so
.length is neither bytes nor characters: "naïve" is 5, but "🦀" is 2, because an emoji outside the Basic Multilingual Plane takes two units. Iterating with [...string] or for...of walks code points, which is the closest thing to .chars(). Grapheme clusters need Intl.Segmenter. And since wasm memory holds UTF-8, every string crossing the boundary is transcoded and copied.Strings are immutable, so everything returns a new one
Every string operation returns a new string. There is no
push_str, no String::with_capacity, and no way to mutate a string in place.fn main() {
let mut greeting = String::from(" hello ");
greeting = greeting.trim().to_uppercase();
greeting.push_str("!");
println!("{greeting}");
println!("{}", "a,b,c".split(',').collect::<Vec<_>>().join("-"));
}let greeting = " hello ";
greeting = greeting.trim().toUpperCase();
greeting += "!"; // a NEW string; nothing was appended in place
console.log(greeting);
console.log("a,b,c".split(",").join("-"));Building one in a loop with
+= is nonetheless fine in practice — engines special-case it with an internal rope representation — but the array-plus-join idiom is still the one to reach for when the pieces are many. The method names are close enough to guess: trim, toUpperCase, startsWith, includes, replaceAll, padStart, slice. Note slice takes code-unit indices and will happily split a surrogate pair, the same class of hazard as slicing a Rust &str at a non-boundary — except that JavaScript gives you a lone surrogate instead of a panic.Regex is in the language
A regular expression is a literal with its own syntax, compiled by the engine, with no crate to add.
fn main() {
// Rust: regex is a crate, compiled once, ideally in a LazyLock.
let text = "order 42 shipped";
let digits: String = text.chars().filter(|character| character.is_ascii_digit()).collect();
println!("{digits}");
}const text = "order 42 shipped";
const match = text.match(/order (\d+)/);
console.log(match[1]);
console.log(text.replace(/\d+/g, "N"));
console.log(/^\w+$/u.test("order"));The engine is backtracking, so unlike the
regex crate it supports backreferences and lookaround — and unlike the regex crate it has no linear-time guarantee, which makes catastrophic backtracking a real denial-of-service risk on user-supplied patterns or inputs. Flags go after the closing slash: g for every match, i for case-insensitive, u for correct Unicode handling (worth defaulting to), s, m, and y. A g regex object carries mutable state in lastIndex, so reusing one across calls to test gives alternating answers — a genuine trap.Collections
Vec against Array
An
Array is growable like a Vec and heterogeneous like a tuple, and it does not check its bounds.fn main() {
let mut numbers: Vec<i32> = vec![1, 2, 3];
numbers.push(4);
println!("{:?} len={}", numbers, numbers.len());
println!("{:?}", numbers.get(10));
}const numbers = [1, 2, 3];
numbers.push(4);
console.log(numbers.join(","), "len=" + numbers.length);
console.log(numbers[10]); // undefined — no bounds check, no panic
numbers[10] = 99; // legal: the array grows, with holesReading past the end gives
undefined rather than a panic or an Option, and writing past the end extends the array with holes — a sparse array whose missing slots are skipped by forEach but counted by length. That last behaviour has no analogue anywhere in Rust and is worth avoiding entirely. There is no capacity to reserve and no with_capacity; new Array(1000) creates a thousand holes rather than reserving space.HashMap against Map and the plain object
There are two mapping types and the newer one is the one you want. A plain object
{} was the only choice for years; Map arrived in ES2015 and is what a HashMap should become.use std::collections::HashMap;
fn main() {
let mut ages: HashMap<String, i32> = HashMap::new();
ages.insert(String::from("ada"), 36);
println!("{:?}", ages.get("ada"));
println!("{:?}", ages.get("nobody"));
println!("{}", ages.contains_key("ada"));
}const ages = new Map();
ages.set("ada", 36);
console.log(ages.get("ada"));
console.log(ages.get("nobody")); // undefined, not None
console.log(ages.has("ada"));
const asObject = { ada: 36 }; // the older, string-keys-only way
console.log(asObject.ada, asObject.nobody);Map takes any value as a key (including objects, compared by identity), preserves insertion order, has a real size, and has no inherited keys to collide with. A plain object coerces every key to a string, so 1 and "1" are the same entry, and it carries prototype properties — which is why Object.create(null) exists. Neither returns an Option: a missing key gives undefined, indistinguishable from a key whose value is undefined, which is what has is for.Sorting compares strings by default
This is the most famous footgun in the standard library, and a Rust reader will walk straight into it:
sort() with no comparator converts every element to a string and sorts lexicographically.fn main() {
let mut numbers = vec![10, 9, 100];
numbers.sort();
println!("{:?}", numbers);
let mut words = vec!["pear", "Apple"];
words.sort_by_key(|word| word.to_lowercase());
println!("{:?}", words);
}const numbers = [10, 9, 100];
numbers.sort(); // [10, 100, 9] — stringified!
console.log(numbers.join(","));
numbers.sort((left, right) => left - right);
console.log(numbers.join(","));
const words = ["pear", "Apple"];
words.sort((left, right) => left.toLowerCase().localeCompare(right.toLowerCase()));
console.log(words.join(","));So
[10, 9, 100].sort() gives [10, 100, 9]. Always pass a comparator for numbers. The comparator returns a negative number, zero or a positive one — the same contract as Ordering, without the type. sort mutates in place and also returns the array, so a chain silently modifies the original; toSorted() (ES2023) is the non-mutating version, along with toReversed, toSpliced and with. Sorting has been stable since ES2019.HashSet becomes Set, and there are no tuples
A
Set holds unique values in insertion order and compares them by identity, which for objects means the same object rather than equal contents.use std::collections::HashSet;
fn main() {
let first: HashSet<i32> = [1, 2, 3].into_iter().collect();
let second: HashSet<i32> = [3, 4].into_iter().collect();
let mut union: Vec<i32> = first.union(&second).copied().collect();
union.sort();
println!("{:?}", union);
let pair: (i32, &str) = (1, "one");
println!("{} {}", pair.0, pair.1);
}const first = new Set([1, 2, 3]);
const second = new Set([3, 4]);
const union = [...new Set([...first, ...second])].sort((a, b) => a - b);
console.log(union.join(","));
const pair = [1, "one"]; // a two-element array IS the tuple
const [number, word] = pair;
console.log(number, word);Set algebra is newer than the type:
union, intersection, difference and isSubsetOf landed in 2024 and are not yet everywhere, so the spread-and-rebuild form above is what most code still does. Tuples do not exist — a fixed-length heterogeneous array stands in, with no type-level length and no .0/.1, so destructuring is how you name the parts. Records and Tuples as real immutable value types were a TC39 proposal and were withdrawn in 2025; do not wait for them.Iterators
Array methods are eager
The chain looks identical and behaves differently in one important way: each step materialises a whole new array before the next one starts.
fn main() {
let numbers = vec![1, 2, 3, 4, 5, 6];
let total: i32 = numbers
.iter()
.filter(|number| *number % 2 == 0)
.map(|number| number * number)
.sum();
println!("{total}");
}const numbers = [1, 2, 3, 4, 5, 6];
const total = numbers
.filter((number) => number % 2 === 0) // builds an array
.map((number) => number * number) // builds another array
.reduce((running, number) => running + number, 0);
console.log(total);There is no adaptor, no laziness and no fusion — three passes over the data and two intermediate arrays where Rust does one pass and allocates once at the
collect. For six elements that is irrelevant; for a million it is the reason someone reaches for a transducer library or writes the loop. There is also no sum(): reduce with an explicit initial value covers it, and omitting the initial value on an empty array throws.Generators are the lazy iterator
When you genuinely need laziness — an infinite sequence, a stream you do not want in memory — the tool is a generator function, marked with
* and suspended at each yield.fn main() {
let first_ten: Vec<u64> = std::iter::successors(Some((0u64, 1u64)), |(current, next)| {
Some((*next, current + next))
})
.map(|(current, _)| current)
.take(10)
.collect();
println!("{:?}", first_ten);
}function* fibonacci() {
let [current, next] = [0, 1];
while (true) {
yield current;
[current, next] = [next, current + next];
}
}
const firstTen = [];
for (const value of fibonacci()) {
if (firstTen.length === 10) break;
firstTen.push(value);
}
console.log(firstTen.join(","));A generator implements the iteration protocol, so it works with
for...of and spreading, and it is the closest thing to impl Iterator. What it is not is composable: there is no .take(10) or .filter() on a generator, which is why the example counts by hand. The Iterator Helpers proposal adds exactly those methods and has shipped in current Node and Chrome; until it is everywhere, lazy pipelines are hand-written or from a library.Three loops, and only one of them is for_each
Two loop keywords differ by one word and mean entirely different things, which is the trap this row exists for.
fn main() {
let colours = vec!["red", "green"];
for colour in &colours {
println!("{colour}");
}
for (index, colour) in colours.iter().enumerate() {
println!("{index} {colour}");
}
}const colours = ["red", "green"];
for (const colour of colours) console.log(colour);
for (const [index, colour] of colours.entries()) console.log(index, colour);
for (const index in colours) console.log(index, typeof index); // keys, as STRINGSfor...of walks values and is the one you want. for...in walks enumerable keys, including inherited ones, and on an array those keys are the strings "0", "1" — so it is almost always a bug on an array and merely a hazard on an object. colours.entries() is enumerate(). And forEach exists but cannot be broken out of, and skips holes, so a plain for...of is usually better.What replaces collect
There is no
collect, because there is no laziness to end and no target type to infer. Each conversion is its own named function.use std::collections::HashMap;
fn main() {
let pairs = vec![("ada", 36), ("grace", 45)];
let ages: HashMap<&str, i32> = pairs.iter().copied().collect();
println!("{}", ages["ada"]);
let names: Vec<&str> = pairs.iter().map(|(name, _)| *name).collect();
println!("{}", names.join(","));
}const pairs = [["ada", 36], ["grace", 45]];
const ages = Object.fromEntries(pairs);
console.log(ages.ada);
const names = pairs.map(([name]) => name);
console.log(names.join(","));
const grouped = Object.groupBy([1, 2, 3, 4], (n) => (n % 2 ? "odd" : "even"));
console.log(grouped.odd.join(","));The four to know are
Object.fromEntries and its inverse Object.entries, Array.from (which takes anything iterable, plus an optional mapping function), and Object.groupBy / Map.groupBy (2024), which is itertools-style grouping without the crate. The turbofish has no analogue and needs none: the function you call determines the type, since there is no type to annotate.Option, Result, null, undefined
Two empties where Option has one None
JavaScript has two values for "nothing" and they are not interchangeable:
undefined is what you get from a missing property, a missing argument or a function with no return, while null is what a programmer writes to mean "deliberately empty".fn main() {
let host: Option<&str> = None;
println!("{}", host.unwrap_or("localhost"));
let port: Option<u16> = Some(5432);
println!("{}", port.unwrap_or(5432));
// There is one None. The other column has two of them.
}const config = { host: null, port: 5432 };
console.log(config.host ?? "localhost"); // null → fallback
console.log(config.port ?? 5432); // present → itself
console.log(config.missing ?? "localhost"); // undefined → fallback too
console.log(typeof null, typeof undefined);Neither is a wrapper, so nothing forces you to unwrap:
config.host.length compiles fine and throws at run time. ?? is unwrap_or and treats both empties alike, which is what you want; || is the older idiom and also falls back on 0 and "", which is the bug it causes. ?. is the nullish member access — order?.customer?.name stops at the first empty — and is the closest thing to a chain of and_then.Result against exceptions
Failure is not in the return type. A function that can fail looks exactly like one that cannot, and the only way to know is documentation or the source.
fn parse_port(text: &str) -> Result<u16, std::num::ParseIntError> {
let port: u16 = text.parse()?;
Ok(port)
}
fn main() {
for text in ["8080", "http"] {
match parse_port(text) {
Ok(port) => println!("port {port}"),
Err(error) => println!("bad port: {error}"),
}
}
}function parsePort(text) {
const port = Number.parseInt(text, 10);
if (Number.isNaN(port)) throw new TypeError(`bad port: ${text}`);
return port;
}
try {
console.log("port " + parsePort("8080"));
parsePort("http");
} catch (error) {
console.log(error.message);
}That is the single biggest loss coming from Rust, and it has three consequences worth internalising. Nothing warns you about an unhandled failure — no
#[must_use], no unused-Result lint. Any expression can throw, including a property access on undefined, so try blocks are about regions of code rather than about specific calls. And you can throw anything at all — a string, a number, undefined — so a catch block must not assume it received an Error. The nearest thing to ? is early throw, and the nearest thing to Result in an API is returning { ok, value, error } by convention.What replaces unwrap and expect
Every API invents its own way of saying "not found", because there is no
Option to return.fn main() {
let maybe_name: Option<String> = None;
let name = maybe_name.unwrap_or_else(|| String::from("anonymous"));
println!("{name}");
let list = vec![1, 2, 3];
println!("{:?}", list.first().copied());
println!("{:?}", list.iter().position(|value| *value == 9));
}const maybeName = null;
const name = maybeName ?? "anonymous";
console.log(name);
const list = [1, 2, 3];
console.log(list.at(0)); // undefined if empty
console.log(list.indexOf(9)); // -1, not undefined, not None
console.log(list.find((value) => value === 9)); // undefinedThree conventions coexist and you have to remember which is which:
undefined from find, at and Map.get; -1 from indexOf and search; and null from String.match and a good deal of the DOM. The sentinel -1 is the dangerous one, since it is a valid index into the end of an array when passed to at. Guard with === -1 explicitly rather than with truthiness, since 0 is a legitimate index and also falsy.Pattern Matching
There is no match, and switch is not one
Nothing in JavaScript matches on structure.
switch compares one value with ===, falls through unless you break, is a statement rather than an expression, and checks nothing for exhaustiveness.fn describe(code: u16) -> &'static str {
match code {
200 | 201 => "ok",
400..=499 => "client error",
_ => "something else",
}
}
fn main() {
println!("{} {} {}", describe(201), describe(404), describe(500));
}function describe(code) {
switch (true) { // the idiom for ranges
case code === 200 || code === 201: return "ok";
case code >= 400 && code <= 499: return "client error";
default: return "something else";
}
}
console.log(describe(201), describe(404), describe(500));The
switch (true) trick above is a real and common idiom, and it exists because the language has no way to express a range or an alternation as a pattern. In practice most JavaScript prefers an if/else chain or a lookup object (const handlers = { 200: ..., 404: ... }), which is what a Rust reader should reach for when the arms are simple values. The TC39 pattern-matching proposal has been at stage 1 for years; do not plan around it.Destructuring is the half that did arrive
Binding patterns are one place JavaScript is genuinely comfortable for a Rust reader — and they go further, since a destructuring pattern may carry default values.
struct Point { x: i32, y: i32 }
fn main() {
let Point { x, y } = Point { x: 1, y: 2 };
println!("{x} {y}");
let numbers = vec![1, 2, 3, 4];
if let [first, .., last] = numbers.as_slice() {
println!("{first} {last}");
}
}const { x, y } = { x: 1, y: 2 };
console.log(x, y);
const [first, ...rest] = [1, 2, 3, 4];
console.log(first, rest.at(-1));
const { host = "localhost", port = 5432 } = { host: "db" };
console.log(host, port);Object patterns bind by name, array patterns by position, both nest, both work in function parameters, and
...rest collects what is left. What is missing is the matching half: a pattern that does not fit does not fail, it binds undefined. There is no slice pattern with a middle ([first, .., last] has no spelling), and no if let — the equivalent is destructuring followed by an explicit check.if let and while let
Both constructs collapse to an explicit test plus an assignment, because there is no
Option to destructure and no pattern to bind through.fn main() {
let maybe_name: Option<&str> = Some("Ada");
if let Some(name) = maybe_name {
println!("hello {name}");
}
let mut stack = vec![1, 2, 3];
while let Some(top) = stack.pop() {
print!("{top} ");
}
println!();
}const maybeName = "Ada";
if (maybeName != null) {
console.log("hello", maybeName);
}
const stack = [1, 2, 3];
let top;
while ((top = stack.pop()) !== undefined) {
process.stdout.write(top + " ");
}
console.log();Note the
!= on the first check: it is the one place the loose operator is idiomatic, because x != null is true for exactly null and undefined and nothing else — the standard "is there anything here" test. The while loop shows the shape a Rust reader should be suspicious of: assignment inside a condition, and a sentinel that would be a real value if the array could contain undefined. Prefer while (stack.length > 0).Structs, Enums & Objects
A struct is an object literal with no declaration
There are two ways to make a record and the lightweight one dominates: an object literal needs no type, no declaration and no constructor, and most JavaScript data is exactly that.
#[derive(Debug, Clone)]
struct Account {
owner: String,
balance: i64,
}
impl Account {
fn new(owner: &str) -> Self {
Account { owner: owner.to_string(), balance: 0 }
}
fn deposit(&mut self, amount: i64) {
self.balance += amount;
}
}
fn main() {
let mut account = Account::new("Ada");
account.deposit(100);
println!("{:?}", account);
}class Account {
#balance = 0;
constructor(owner) { this.owner = owner; }
deposit(amount) { this.#balance += amount; }
get balance() { return this.#balance; }
}
const account = new Account("Ada");
account.deposit(100);
console.log(account.owner, account.balance);
const asPlainObject = { owner: "Ada", balance: 100 }; // no class needed at all
console.log(asPlainObject.owner);class is sugar over prototypes and is what you want when there is behaviour attached. Note what it does not give you: no Debug, no Clone, no structural equality, and no field list the compiler knows about — a typo in account.balnce is undefined, not an error. Private fields exist now (the # prefix) and are genuinely private, unlike the older underscore convention. There is no impl block separate from the type, and no way to add a method to a type you do not own except by touching its prototype, which is frowned on.An enum with data becomes a tagged object
The sum type has no counterpart, so the pattern is a plain object carrying a
kind (or type, or tag) property that you switch on. It is a convention, held together by discipline.#[derive(Debug)]
enum Shape {
Circle { radius: f64 },
Square { side: f64 },
}
fn area(shape: &Shape) -> f64 {
match shape {
Shape::Circle { radius } => 3.14 * radius * radius,
Shape::Square { side } => side * side,
}
}
fn main() {
println!("{} {}", area(&Shape::Circle { radius: 1.0 }), area(&Shape::Square { side: 2.0 }));
}const circle = { kind: "circle", radius: 1 };
const square = { kind: "square", side: 2 };
function area(shape) {
switch (shape.kind) {
case "circle": return 3.14 * shape.radius * shape.radius;
case "square": return shape.side * shape.side;
default: throw new Error(`unknown shape: ${shape.kind}`);
}
}
console.log(area(circle), area(square));Nothing stops a third kind appearing, nothing checks that the payload matches the tag, and nothing warns when a
switch misses a case — hence the default that throws, which is the manual replacement for exhaustiveness. TypeScript turns this exact pattern into a checked discriminated union, which is much of why a Rust author who cares about correctness ends up writing TypeScript. A C-like enum, with no data, is usually just a frozen object of string constants.serde against JSON.parse
Deserialisation does not validate.
JSON.parse returns whatever was in the text, as plain objects and arrays, and hands it back with no shape checked and no type attached.fn main() {
let text = r#"{"name":"Ada","age":36}"#;
// With serde: let person: Person = serde_json::from_str(text)?;
// The shape is checked while parsing, and a missing field is an Err.
println!("{}", text.contains("Ada"));
}const text = '{"name":"Ada","age":36}';
const person = JSON.parse(text);
console.log(person.name, person.age);
console.log(person.missing); // undefined — no error, no warning
console.log(JSON.stringify(person));So the thing serde does for free — refusing input that does not match the struct — has to be done by hand or by a library (Zod, Valibot, Ajv) that describes the shape at run time. A Rust reader should treat every parsed payload as
serde_json::Value and validate at the boundary. Note also what JSON.stringify drops silently: undefined values, functions, and Symbols vanish from objects, NaN and Infinity become null, a BigInt throws, and a cycle throws.Traits & Prototypes
No traits: if it has the method, it works
There is no trait, no
impl and no dyn. A value is acceptable to a function if it happens to have what that function uses, checked at the instant of the call.trait Speak {
fn speak(&self) -> String;
}
struct Dog;
struct Robot;
impl Speak for Dog { fn speak(&self) -> String { String::from("Woof") } }
impl Speak for Robot { fn speak(&self) -> String { String::from("Beep") } }
fn main() {
let speakers: Vec<Box<dyn Speak>> = vec![Box::new(Dog), Box::new(Robot)];
for speaker in &speakers {
println!("{}", speaker.speak());
}
}const dog = { speak: () => "Woof" };
const robot = { speak: () => "Beep" };
for (const speaker of [dog, robot]) {
console.log(speaker.speak());
}That is the whole mechanism, and it removes both the ceremony and the guarantee. The two pieces of Rust design it invalidates are worth naming: there is nothing to make generic over, so a "generic function" is just a function; and the coherence rules that stop you implementing a foreign trait for a foreign type have no analogue, since anything can be given any method at any time. What replaces a trait bound in practice is a documented shape, a run-time check (
typeof speaker.speak === "function"), or TypeScript.Prototypes: the chain behind every object
Method lookup walks a chain of objects at run time.
person.greet is not on person; it is found on Person.prototype, and if it were not there the search would continue to Object.prototype.trait Greet { fn greet(&self) -> String; }
struct Person { name: String }
impl Greet for Person {
fn greet(&self) -> String { format!("hi, {}", self.name) }
}
fn main() {
let person = Person { name: String::from("Ada") };
println!("{}", person.greet());
}class Person {
constructor(name) { this.name = name; }
greet() { return `hi, ${this.name}`; }
}
const person = new Person("Ada");
console.log(person.greet());
console.log(Object.getPrototypeOf(person) === Person.prototype);
console.log(Object.hasOwn(person, "greet")); // false — greet lives on the prototypeThis is dynamic dispatch with no vtable and no fixed layout: a method can be added to the prototype after the objects exist, and every one of them gains it. Inheritance is one prototype pointing at another, so
class B extends A is a chain of two. The practical consequences for someone used to Rust are that a method call costs a lookup (engines cache it heavily), that this is decided by the call site rather than the definition, and that monkey-patching a built-in is possible, common in old code, and a good way to break the web.this is bound by the call, not the definition
A Rust method receives
self because you called it on something. A JavaScript method receives this from how it was called — and pulling the method out of the object loses the receiver entirely.struct Counter { count: i32 }
impl Counter {
fn increment(&mut self) -> i32 {
self.count += 1;
self.count
}
}
fn main() {
let mut counter = Counter { count: 0 };
println!("{}", counter.increment());
}class Counter {
count = 0;
increment() { this.count += 1; return this.count; }
}
const counter = new Counter();
console.log(counter.increment());
const loose = counter.increment; // just the function, no receiver
try { loose(); } catch (error) { console.log(error.constructor.name); }
console.log(counter.increment.bind(counter)());So passing
counter.increment as a callback gives a function whose this is undefined, and the failure appears wherever the callback is eventually invoked. The fixes are bind, an arrow wrapper (() => counter.increment()), or defining the method as a class field holding an arrow function, which captures this lexically. An arrow function has no this of its own at all, which is exactly why it became the default for callbacks.Display, Debug and toString
The nearest thing to a trait implementation is defining a specially-named method that the language already looks for.
use std::fmt;
struct Money { cents: i64 }
impl fmt::Display for Money {
fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
write!(formatter, "${}.{:02}", self.cents / 100, self.cents % 100)
}
}
fn main() {
println!("{}", Money { cents: 725 });
}class Money {
constructor(cents) { this.cents = cents; }
toString() {
return `$${Math.trunc(this.cents / 100)}.${String(this.cents % 100).padStart(2, "0")}`;
}
toJSON() { return { cents: this.cents }; }
}
const money = new Money(725);
console.log(`${money}`);
console.log(JSON.stringify({ price: money }));toString is Display and is called by string interpolation and by +; toJSON is consulted by JSON.stringify, which is as close as JavaScript gets to Serialize; Symbol.iterator is IntoIterator; Symbol.toPrimitive covers the numeric-conversion cases. There is no Debug and no #[derive] — console.log of an object prints an engine-chosen rendering that ignores toString entirely, which is why a value looks different depending on whether you logged it or interpolated it.Closures & Functions
One kind of closure, capturing by reference
There is no
Fn/FnMut/FnOnce distinction, no move, and no borrow to satisfy. A closure captures the enclosing variables themselves and can read and write them freely.fn main() {
let mut count = 0;
let mut increment = || {
count += 1;
count
};
println!("{} {} {}", increment(), increment(), increment());
}let count = 0;
const increment = () => {
count += 1;
return count;
};
console.log(increment(), increment(), increment());Every closure is effectively
FnMut with an unlimited lifetime: the captured variables are kept alive by the closure for as long as it exists, which is what makes them the standard way to hold private state. The costs are the ones the borrow checker was preventing — two closures can capture and mutate the same variable, and a closure passed into an event handler can keep a large object alive indefinitely, which is the shape of most JavaScript memory leaks.Arguments: defaults yes, overloads no, arity never checked
JavaScript has the default parameters and variadics that Rust deliberately omits, and it also has no arity check at all.
fn connect(host: &str, port: u16) -> String {
format!("{host}:{port}")
}
fn main() {
println!("{}", connect("db.example.com", 5432));
// println!("{}", connect("db.example.com")); // uncomment: wrong arity
}function connect(host, port = 5432, ...extras) {
return `${host}:${port} (${extras.length} extra)`;
}
console.log(connect("db.example.com"));
console.log(connect("db.example.com", 6000, "a", "b"));
console.log(connect()); // undefined:5432 — no errorCalling with too few arguments binds the missing ones to
undefined; calling with too many silently ignores the extras unless a ...rest collects them. There are no overloads, so a function that behaves differently for different shapes inspects its arguments by hand. Named arguments do not exist either — the options-object idiom (connect({ host, port })) is the substitute, and destructuring with defaults in the parameter list is what makes it readable.Functions are values, with no Box<dyn Fn>
Returning a closure needs no
impl Trait, no Box, no move and no lifetime. A function is an ordinary object that happens to be callable.fn make_adder(amount: i32) -> impl Fn(i32) -> i32 {
move |value| value + amount
}
fn apply(function: &dyn Fn(i32) -> i32, value: i32) -> i32 {
function(value)
}
fn main() {
let add_ten = make_adder(10);
println!("{}", apply(&add_ten, 5));
}const makeAdder = (amount) => (value) => value + amount;
const apply = (fn, value) => fn(value);
const addTen = makeAdder(10);
console.log(apply(addTen, 5));
console.log(addTen.length, addTen.name); // arity and name, at run timeBeing an object, it has properties:
length is the declared arity, name is the name it was defined or assigned with, and you may attach your own. It also means every function allocates, and that the distinction Rust draws between a zero-cost impl Fn and a heap-allocated Box<dyn Fn> simply does not arise — everything is the second one. Passing a method as a callback loses this, which the earlier row covers and which is the one thing to watch here.Cold Futures, Hot Promises
A promise is already running
This is the async difference that actually changes designs. A Rust future is inert until something polls it; calling an
async function in JavaScript starts the work immediately and hands you a handle to the result.async fn work(number: i32) -> i32 {
number * 2
}
fn main() {
let future = work(21); // nothing has happened yet
println!("created, not polled");
let answer = futures::executor::block_on(future);
println!("{answer}");
}(async () => {
const work = async (number) => number * 2;
const promise = work(21); // ALREADY running
console.log("created, and running");
console.log(await promise);
})();So there is no executor to choose, no runtime to depend on, and no
block_on — the event loop is built into the host. Three practical consequences: creating a promise you never await still does the work; await on an already-settled promise still yields to the microtask queue, so ordering is not what a synchronous reading suggests; and cancellation does not exist — dropping a future cancels it in Rust, while dropping a promise does nothing at all. AbortController is the by-convention replacement, and only for APIs that accept a signal.join! against Promise.all
Running several things concurrently is the same shape in both languages, and the failure behaviour is the part to check.
async fn fetch_one(id: i32) -> i32 { id * 10 }
fn main() {
let results: Vec<i32> = futures::executor::block_on(async {
futures::future::join_all((1..=3).map(fetch_one)).await
});
println!("{:?}", results);
}(async () => {
const fetchOne = async (id) => id * 10;
const results = await Promise.all([1, 2, 3].map(fetchOne));
console.log(results.join(","));
const settled = await Promise.allSettled([Promise.reject(new Error("no"))]);
console.log(settled[0].status);
})();Promise.all rejects as soon as any input rejects, and the others keep running with their results discarded — there is no cancellation to propagate. Promise.allSettled is the "collect every outcome" version and never rejects, which is usually what you want in a batch. Promise.race settles with the first outcome of any kind, and Promise.any with the first success. Because JavaScript is single-threaded, all of this is concurrency and none of it is parallelism: only I/O actually overlaps, and CPU work needs a Worker — or your wasm module.One thread, and blocking it stops everything
There are no threads in the language. One call stack, one heap, one event loop — and a tight loop occupies all of it until it finishes.
use std::thread;
fn main() {
let worker = thread::spawn(|| {
let mut total: u64 = 0;
for value in 0..5_000_000u64 { total += value; }
total
});
println!("main is free while that runs");
println!("{}", worker.join().unwrap());
}let total = 0;
for (let value = 0; value < 5_000_000; value++) total += value;
console.log("nothing else could run during that loop");
console.log(total);In a browser that means the page stops responding; in Node it means every other request waits. The escape hatches are Web Workers and Node's
worker_threads, which are separate JavaScript realms that communicate by message passing — structured-cloned copies, not shared references, so Arc<Mutex<T>> has no counterpart. SharedArrayBuffer plus Atomics is the exception and is what wasm threads are built on, gated behind cross-origin isolation headers. This is the strongest practical argument for putting the heavy loop in your Rust module.The order things actually run in
One thread means everything is a queue, and there are two of them with different priorities. This ordering is worth memorising, because it explains most "why did that run then?" confusion.
fn main() {
println!("1 sync");
let handle = std::thread::spawn(|| "from the thread");
println!("2 sync");
println!("3 {}", handle.join().unwrap());
}(async () => {
console.log("1 sync");
setTimeout(() => console.log("4 macrotask"), 0);
Promise.resolve().then(() => console.log("3 microtask"));
console.log("2 sync");
await new Promise((resolve) => setTimeout(resolve, 5));
})();Synchronous code runs to completion first. Then the microtask queue drains completely — promise callbacks,
await resumptions, queueMicrotask — and only then does one macrotask run: a setTimeout, an I/O callback, a UI event. So a promise chain always beats a zero-delay timer, and a microtask that schedules another microtask can starve the timer queue entirely. setTimeout(..., 0) is the idiom for "let the browser paint first", and it is clamped to about 4ms after a few nested calls.Panics & Exceptions
panic! against throw
A
throw is an ordinary control-flow mechanism, not the last resort panic! is. The standard library throws for parse errors, for network failures and for calling a method on undefined.fn main() {
std::panic::set_hook(Box::new(|_| {})); // silence the default report
let outcome = std::panic::catch_unwind(|| {
panic!("something broke");
});
let message = outcome.unwrap_err().downcast::<&str>().map(|text| *text).unwrap_or("unknown");
println!("caught: {message}");
println!("still running");
}try {
throw new Error("something broke");
} catch (error) {
console.log("caught:", error.message);
}
console.log("still running");So
catch is not catch_unwind — catching is normal, expected and cheap, and there is no distinction between "recoverable" and "the program is broken". The uncaught case is what differs by host: in a browser it stops the current task and logs, leaving the page alive; in Node it terminates the process, and an unhandled promise rejection does too. finally is the closest thing to Drop, and it is the only cleanup guarantee available.Custom error types
Extending
Error is the convention, and the name assignment is not optional boilerplate — without it the class name does not appear in the message or the stack trace.#[derive(Debug)]
struct InsufficientFunds { shortfall: i64 }
impl std::fmt::Display for InsufficientFunds {
fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(formatter, "short by {}", self.shortfall)
}
}
impl std::error::Error for InsufficientFunds {}
fn main() {
let error = InsufficientFunds { shortfall: 25 };
println!("{error} / {:?}", error);
}class InsufficientFunds extends Error {
constructor(shortfall) {
super(`short by ${shortfall}`);
this.name = "InsufficientFunds";
this.shortfall = shortfall;
}
}
try {
throw new InsufficientFunds(25);
} catch (error) {
if (error instanceof InsufficientFunds) {
console.log(error.message, error.shortfall);
}
}There is no
Error trait to implement, no source() chain by default (though the cause option and error.cause now cover it), and no ? to convert between error types. instanceof is how you discriminate, and it fails across realms — an error from an iframe or a worker is not instanceof your Error — which is why library code often checks error.name instead. Stack traces are a non-standard property that every engine happens to provide.Modules, Cargo & npm
One module per file, and two module systems
A file is a module, and there is no
mod keyword and no module tree to declare — the filesystem path in the import is the path.mod geometry {
pub fn area(width: f64, height: f64) -> f64 {
width * height
}
}
use geometry::area;
fn main() {
println!("{}", area(3.0, 4.0));
}// geometry.js: export function area(width, height) { return width * height; }
// main.js: import { area } from "./geometry.js";
import { area } from "./geometry.js";
console.log(area(3, 4));Everything is private until
exported, which matches pub. The complication is that there are two systems in the wild: ESM (import/export, the standard, statically analysable, what browsers use) and CommonJS (require/module.exports, Node's original, dynamic and synchronous). A package declares which it is in package.json, and interop between them is the single most tedious part of Node development. This example cannot run here, because the suite executes examples through node -e as CommonJS.Cargo against npm
The package manager is the familiar half. The unfamiliar half is that it is only a package manager.
fn main() {
// cargo add serde_json → Cargo.toml + Cargo.lock
// cargo build --release → target/
// cargo test / cargo fmt / cargo clippy / cargo doc — all built in
println!("one tool, one lockfile, a small tree");
}// npm install lodash → package.json + package-lock.json
// npm run build → whichever bundler you chose
// test: vitest or jest; format: prettier; lint: eslint; types: tsc
console.log("one tool for packages, and a separate one for everything else");npm installs and runs scripts; it does not build, test, format, lint, document or generate. Each of those is a separate dependency you choose, configure and keep in step — which is why a new JavaScript project starts with more decisions than a new Rust one. Dependency trees are an order of magnitude larger, since the standard library covers far less. Semantic versioning is honoured with a caret by default, so npm install can resolve differently tomorrow unless the lockfile is committed and npm ci is used in CI — the equivalent of --locked.Tests live somewhere else
There is no
#[cfg(test)] and no dead-code elimination for tests, so a test beside the implementation would ship to the browser. Tests go in a separate file, by convention name.test.js.fn double(value: i32) -> i32 { value * 2 }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_doubles() {
assert_eq!(double(21), 42);
}
}
fn main() {
println!("{}", double(21));
}const assert = require("node:assert");
function double(value) { return value * 2; }
// In a real project this lives in double.test.js and runs under
// `node --test`, vitest or jest — never in the shipped module.
assert.strictEqual(double(21), 42);
console.log(double(21));Node has had a built-in runner since 18 (
node --test, with node:assert), which is the closest thing to cargo test; most projects still use vitest or jest for the watch mode, mocking and browser environments. There is no doctest, so examples in documentation are unverified prose — the one genuinely missing safety net. Nor is there a cargo doc: JSDoc comments are read by editors and by TypeScript, and rendered docs need a separate tool.The wasm-bindgen Boundary
What #[wasm_bindgen] generates
The generated JavaScript is a wrapper, not a binding: it allocates, copies your arguments into wasm memory, calls the export, reads the result back out and frees what it allocated.
// Cargo.toml: crate-type = ["cdylib"], wasm-bindgen = "0.2"
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn greet(name: &str) -> String {
format!("Hello, {name}!")
}
// wasm-pack build --target web → pkg/your_crate.js + your_crate_bg.wasm// import init, { greet } from "./pkg/your_crate.js";
// await init();
// console.log(greet("Ada"));
console.log("the JS side sees an ordinary function that returns a string");So
greet("Ada") costs two string transcodings — UTF-16 to UTF-8 on the way in, back again on the way out — plus two allocations. That is nothing for one call and everything for a call in a loop, which is the single most common performance mistake in a wasm integration. Design the API so the boundary is crossed rarely with large payloads rather than often with small ones, exactly as you would with a syscall.What survives the boundary, and what does not
The type table is the practical heart of a wasm integration, and the
u64 row is the one that surprises people: it does not become a number.fn main() {
// Crosses cleanly: i32/u32, f32/f64, bool, String, &str, Vec<u8>,
// Option<T> (as null), and any #[wasm_bindgen] struct (as a handle).
// Crosses as BigInt: i64/u64 — a DIFFERENT JS type from number.
// Does not cross: references with lifetimes, generics, trait objects,
// HashMap (use serde-wasm-bindgen), and anything borrowing from JS memory.
let value: u64 = u64::MAX;
println!("{value} becomes a BigInt on the other side");
}const fromRust = 18446744073709551615n; // a u64 arrives as BigInt
console.log(typeof fromRust);
// console.log(fromRust + 1); // TypeError: cannot mix with number
console.log(Number(fromRust)); // lossy above 2**53, silentlyA 64-bit integer arrives as a
BigInt, which cannot be mixed with ordinary numbers in arithmetic and cannot be JSON.stringifyd. Converting with Number() silently loses precision above 2⁵³. If the value genuinely fits in 53 bits, return f64 or u32 and save every caller the trouble. The other rule worth internalising: nothing may borrow across the boundary. A &str parameter is copied in, and a struct is handed to JavaScript as an opaque handle whose free() the caller must remember to call, because there is no Drop on that side.Sharing bytes instead of copying them
Byte arrays are where a wasm integration either flies or crawls, and the difference is whether the bytes are copied or shared.
fn main() {
// #[wasm_bindgen]
// pub fn checksum(bytes: &[u8]) -> u32 {
// bytes.iter().map(|byte| *byte as u32).sum()
// }
// A &[u8] parameter is COPIED into wasm memory by the wrapper.
let bytes: Vec<u8> = vec![1, 2, 3];
println!("{}", bytes.iter().map(|byte| *byte as u32).sum::<u32>());
}const bytes = new Uint8Array([1, 2, 3]);
console.log(bytes.reduce((running, byte) => running + byte, 0));
// The zero-copy form: ask the module for its memory and write into it directly —
// const view = new Uint8Array(wasmModule.memory.buffer, pointer, length);
// and re-create the view after any allocation, since growth detaches it.
console.log(bytes.byteLength);&[u8] and Vec<u8> parameters are copied by the generated wrapper, which is fine until the buffer is an image or a file. The zero-copy route is to allocate inside wasm, hand the pointer out, and have JavaScript build a Uint8Array over memory.buffer at that offset. 🚨 The trap: growing wasm memory detaches every existing view, so a Uint8Array created before an allocation silently becomes empty. Re-create the view after any call that might allocate.A panic across the boundary
This is the first thing to set up in a wasm crate, and the reason is that a panic loses everything on the way out.
fn main() {
// In a wasm build, a panic aborts the module and surfaces in JS as
// "unreachable executed" — with no message and no stack, unless you add:
// console_error_panic_hook::set_once();
// in your #[wasm_bindgen(start)] or at the top of each entry point.
let values: Vec<i32> = vec![1, 2, 3];
println!("get(10) is {:?}, not a panic — prefer this at the boundary", values.get(10));
}// Without the hook: RuntimeError: unreachable executed
// With the hook: panicked at 'index out of bounds: ...', src/lib.rs:12
try {
null.length;
} catch (error) {
console.log(error.constructor.name, "— JS errors keep their message and stack");
}A panic in wasm becomes a trap, which the JavaScript side sees as
RuntimeError: unreachable executed — no message, no location, no backtrace. console_error_panic_hook costs a few kilobytes and prints the real panic message to the console instead; add it once and never debug blind. Beyond that, the module is poisoned after a trap: its memory is in an unknown state and it must be re-instantiated, so a panic is not something to recover from and carry on. Return Result across the boundary (it becomes a thrown JavaScript error) rather than panicking.