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

When to Choose TypeScript