Rust vs TypeScript
Side-by-side comparison of Rust and TypeScript — syntax, ownership, error handling, and the gotchas that catch TS developers.
Both Rust and TypeScript bring strong type systems, but they sit on opposite ends of the memory management spectrum. TypeScript is a gradual type layer over JavaScript with a garbage collector; Rust is a systems language with compile-time memory safety via ownership. The type systems look similar on the surface, but the mental models diverge fast.
| Feature | Rust | TypeScript |
|---|---|---|
| Paradigm | Multi-paradigm (imperative, functional, OO) | Multi-paradigm (OO, functional) |
| Typing | Static, compiled, inferred | Static, transpiled, structural |
| Memory | Ownership / borrow checker (no GC) | Garbage collected |
| Variable | let x = 42; (immutable by default) |
const x = 42; / let x = 42 |
| Mutable variable | let mut x = 42; |
let x = 42; x = 99; |
| Function | fn add(a: i32, b: i32) -> i32 { a + b } |
function add(a: number, b: number): number { return a + b; } |
| String | String (heap) vs &str (borrowed slice) |
string (primitive, no distinction) |
| Array | let v: Vec<i32> = vec![1, 2, 3]; |
const v: number[] = [1, 2, 3]; |
| Tuple | let pair = ("Ada", 30); |
const pair: [string, number] = ["Ada", 30]; |
| Struct | struct User { name: String } |
interface User { name: string } |
| Enum | enum Color { Red, Blue } with variants carrying data |
enum Color { Red = "red", Blue = "blue" } (nominal constants) |
| Null / undefined | Option<T> (Some(v) / None) |
`T |
| Error handling | Result<T, E> (Ok(v) / Err(e)) |
try / catch (exceptions) |
| Pattern matching | match (exhaustive, with destructuring) |
switch (falls through, no exhaustiveness) |
| Traits / Interfaces | trait Drawable { fn draw(&self); } |
interface Drawable { draw(): void; } |
| Generics | fn first<T>(v: &[T]) -> Option<&T> |
function first<T>(v: T[]): T | undefined |
| Closures | |x| x * 2 (captures by ref/move) |
(x) => x * 2 (captures by reference) |
| Concurrency | Threads + Send/Sync (fearless) |
Single-threaded event loop / Workers |
| Module | mod + use + pub (visibility) |
import / export (file-based) |
| Package manager | Cargo | npm / pnpm / yarn |
| Testing | #[test] built-in |
Jest / Vitest / etc. |
| Compilation | rustc → native binary |
tsc → JavaScript |
| Runtime | None (compiled) | Node.js / Deno / Bun |
The Big Gotchas for TypeScript Developers
These are the traps that catch every TS developer learning Rust.
1. Immutability Is the Default
In TypeScript, let means mutable and const means immutable. In Rust, let is immutable by default — you must opt into mutability with let mut.
// TypeScript
let x = 42; // mutable
const y = 42; // immutable
x = 99; // fine
// Rust
let x = 42; // immutable — will NOT compile if you reassign
let mut y = 42; // mutable
y = 99; // fine
Trap: You reach for let in Rust expecting it to work like TS let. It won’t — Rust’s let is more like TS’s const.
2. There Are Two String Types
TypeScript has one string type. Rust has String (heap-allocated, owned, growable) and &str (borrowed string slice, often a view into existing data).
// TypeScript — just one string type
const name: string = "Ada";
const greeting = `Hello, ${name}`;
// Rust — two string types
let name: String = String::from("Ada"); // owned, heap
let slice: &str = "Ada"; // borrowed, static or view
let greeting = format!("Hello, {}", name); // returns String
Trap: You’ll fight the compiler passing a String where &str is expected (or vice versa). Use .as_str() to borrow from String, and .to_string() or .to_owned() to create a String from &str.
3. Ownership and Borrowing Replace the Garbage Collector
This is the biggest mental model shift. In TypeScript, any variable can be shared freely — the GC cleans up. In Rust, only one owner exists for each value, and the borrow checker enforces the rules at compile time.
// TypeScript — both references valid, GC handles cleanup
const data = { name: "Ada" };
const ref1 = data;
const ref2 = data;
console.log(ref1.name); // fine
console.log(ref2.name); // fine
// Rust — ownership transferred
let data = String::from("Ada");
let ref1 = data; // data is MOVED to ref1
// println!("{}", data); // ERROR: data was moved!
println!("{}", ref1); // fine
// Borrow instead of move
let data2 = String::from("Ada");
let ref2 = &data2; // borrow (immutable reference)
let ref3 = &data2; // multiple immutable borrows: fine
println!("{} {}", ref2, ref3); // fine
println!("{}", data2); // fine — still the owner
Trap: Passing a value to a function moves it by default. If you need it afterward, pass a reference with &.
4. The Borrow Rules Are Strict
You can have either multiple immutable references (&T) or one mutable reference (&mut T), but never both at the same time.
let mut v = vec![1, 2, 3];
let first = &v[0]; // immutable borrow
v.push(4); // ERROR: mutable borrow while immutable borrow exists!
// println!("{}", first); // the compiler catches this
Trap: In TypeScript you freely read and mutate — no rules. In Rust, the compiler will reject code that could lead to data races, even in single-threaded contexts. This is the borrow checker doing its job.
5. Enums Are Algebraic Data Types, Not Named Constants
TypeScript enums are just named constants. Rust enums can carry data and are closer to TS discriminated unions.
// TypeScript — discriminated union (the closest analog)
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rect"; w: number; h: number };
function area(s: Shape): number {
switch (s.kind) {
case "circle": return Math.PI * s.radius ** 2;
case "rect": return s.w * s.h;
}
}
// Rust — enum variants carry data directly
enum Shape {
Circle { radius: f64 },
Rect { w: f64, h: f64 },
}
fn area(s: &Shape) -> f64 {
match s {
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
Shape::Rect { w, h } => w * h,
}
}
Trap: Don’t reach for TypeScript-style enums with string values in Rust. Rust enums are far more powerful — they’re sum types that hold data.
6. No Null or Undefined — Use Option<T>
TypeScript uses null and undefined. Rust has neither — it uses Option<T> to represent the possibility of absence.
// TypeScript
function findUser(id: number): User | undefined {
return users.find(u => u.id === id); // returns undefined if not found
}
const user = findUser(1);
console.log(user?.name); // optional chaining
// Rust
fn find_user(id: u32) -> Option<&User> {
users.iter().find(|u| u.id == id) // returns Some(user) or None
}
match find_user(1) {
Some(user) => println!("{}", user.name),
None => println!("not found"),
}
// or use if let / unwrap
if let Some(user) = find_user(1) {
println!("{}", user.name);
}
Trap: You cannot use an Option<T> as if it were T. You must explicitly handle the None case. The compiler forces you to deal with it — no more “cannot read property of undefined.”
7. No Exceptions — Use Result<T, E>
TypeScript throws exceptions. Rust returns Result<T, E> — errors are values you must handle explicitly.
// TypeScript — exceptions, may or may not be caught
function readFile(path: string): string {
const content = fs.readFileSync(path); // throws if file missing
return content.toString();
}
try {
const data = readFile("config.json");
} catch (err) {
console.error(err);
}
// Rust — errors are values
use std::fs;
fn read_file(path: &str) -> Result<String, std::io::Error> {
fs::read_to_string(path) // returns Ok(content) or Err(error)
}
match read_file("config.json") {
Ok(data) => println!("{}", data),
Err(e) => eprintln!("Error: {}", e),
}
// The ? operator propagates errors concisely
fn read_config() -> Result<Config, std::io::Error> {
let data = fs::read_to_string("config.json")?; // early return on Err
Ok(parse_config(&data))
}
Trap: You cannot ignore a Result. The ? operator is your best friend — it propagates errors up the call stack, similar to try/catch but explicit and type-checked.
8. Structs + impl vs Classes
Rust has no classes. You define data with struct and behavior with impl blocks. There’s no inheritance — use composition and traits instead.
// TypeScript — class with inheritance
class Animal {
constructor(public name: string) {}
speak(): string { return `${this.name} makes a sound`; }
}
class Dog extends Animal {
speak(): string { return `${this.name} barks`; }
}
// Rust — struct + impl + trait (no inheritance)
struct Animal {
name: String,
}
trait Speaks {
fn speak(&self) -> String;
}
impl Speaks for Animal {
fn speak(&self) -> String {
format!("{} makes a sound", self.name)
}
}
// No inheritance — implement the trait for each type
struct Dog { name: String }
impl Speaks for Dog {
fn speak(&self) -> String {
format!("{} barks", self.name)
}
}
Trap: Don’t try to build deep class hierarchies in Rust. Favor composition over inheritance — implement traits for individual types.
9. Match Is Exhaustive, Switch Is Not
Rust’s match checks that every possible case is handled. TypeScript’s switch silently falls through.
// TypeScript — no exhaustiveness check by default
type Direction = "up" | "down" | "left" | "right";
function move(dir: Direction): void {
switch (dir) {
case "up": break;
// Oops, forgot "down", "left", "right" — no compiler error
}
}
// Rust — the compiler forces you to handle every case
enum Direction { Up, Down, Left, Right }
fn r#move(dir: Direction) {
match dir {
Direction::Up => {},
// ERROR: non-exhaustive patterns: `Down`, `Left`, `Right` not covered
}
}
Trap: Trust the Rust compiler here. If you forget a case, it won’t compile. In TypeScript, always add a default case and consider using discriminated unions with exhaustive switch patterns.
10. .clone() Is a Code Smell, Not a Solution
When the borrow checker rejects your code, it’s tempting to slap .clone() everywhere. Resist that urge — it usually means you should rethink your ownership design.
// LAZY — clones everywhere, defeats the purpose of Rust's memory model
fn process(data: &Vec<String>) -> Vec<String> {
let mut result = data.clone(); // unnecessary clone
result.push("new".to_string());
result
}
// BETTER — take ownership when you need to modify
fn process(mut data: Vec<String>) -> Vec<String> {
data.push("new".to_string());
data
}
Trap: .clone() is the “just make it compile” button. It works, but it copies data unnecessarily. Learn to read the borrow checker’s error messages — they’re genuinely helpful and will teach you the ownership model.
Concurrency Comparison
TypeScript — Event Loop (Single-threaded)
// Node.js — async/await on a single thread
async function fetchAll(urls: string[]): Promise<string[]> {
const results = await Promise.all(
urls.map(url => fetch(url).then(r => r.text()))
);
return results;
}
TypeScript is single-threaded with an event loop. Concurrency is cooperative — async/await for I/O, Worker threads for CPU work.
Rust — Fearless Concurrency
use std::thread;
use std::sync::mpsc;
fn main() {
let (tx, rx) = mpsc::channel();
let handles: Vec<_> = (0..4)
.map(|i| {
let tx = tx.clone();
thread::spawn(move || {
tx.send(format!("result from thread {}", i)).unwrap();
})
})
.collect();
for h in handles {
h.join().unwrap();
}
while let Ok(msg) = rx.try_recv() {
println!("{}", msg);
}
}
Rust threads are OS-level and truly parallel. The Send and Sync traits are enforced at compile time — if your types don’t support safe sharing across threads, it won’t compile. This is “fearless concurrency”: data races are impossible.
When to Choose Rust
- You need maximum performance and minimal memory usage
- You’re building systems software, CLIs, or WebAssembly
- You want compile-time guarantees about memory safety and data-race freedom
- You’re willing to invest in learning the ownership model upfront
- You need fearless concurrency with no runtime cost
When to Choose TypeScript
- You’re building web applications (frontend or Node.js backend)
- Developer velocity matters more than raw performance
- You want a large ecosystem (npm) and rapid prototyping
- Your team already knows JavaScript/TypeScript
- You need flexible, gradual typing with escape hatches (
any)