Skip to content
Learn Rust
Theme

Rust for TypeScript developers

You already write types after the name, generics with bounds, arrow functions, destructuring and async/await. This page is about the habits that carry over wrongly. Each one below is a TypeScript snippet that ran, then what Rust does with the same idea: a program that ran, or the real compiler error.

RememberTypeScript types describe your code and are erased before it runs. Rust also tracks who owns each value and who is borrowing it, and much of what follows comes from that.

The TypeScript programs here pass tsc with strict on (the two shown failing on purpose excepted) and run on Node 24. Node runs a .ts file by replacing the types with whitespace, and its docs say no type checking is performed, so the commands below are node h01.ts and tsc is a separate step. This session is that step for every file in the folder:

terminal
$ tsc --version
Version 7.0.2
$ tsc    # type-checks every file in the folder, using its tsconfig.json

The Rust programs are one cargo project. Each habit is a file in src/bin/, and cargo run --bin h01 builds and runs just that one.

Project layout
ts-habits/
├── src/
│   └── bin/                    h01.rs ... h10.rs: the ten habits
│       ├── either.rs
│       ├── emit.rs
│       ├── h01.rs
│       ├── h02.rs
│       ├── h03.rs
│       ├── h04.rs
│       ├── h04b.rs
│       ├── h05.rs
│       ├── h06.rs
│       ├── h07.rs
│       ├── h08.rs
│       ├── h09.rs
│       ├── h10.rs
│       ├── h10b.rs
│       ├── idioms.rs           the table further down
│       └── nodebug.rs
├── Cargo.lock
└── Cargo.toml                  no dependencies
  • Types after names and bounded generics. function largest<T extends number | string>(list: T[]): T becomes fn largest<T: PartialOrd>(list: &[T]) -> &T. The bound names an ability, not a list of types, so it is not the same set of types as the union (Reading signatures, Generics).
  • Discriminated unions are enums. A union of { kind: ... } objects and the switch that narrows it is an enum and a match, with the tag kept for you (Enums).
  • Arrow functions are closures. (x) => x * factor is |x| x * factor, and map/filter chains read the same (Closures, Iterators).
  • Destructuring is patterns. const { x, y } = p is let Point { x, y } = p;, and patterns also work in match (Patterns).
  • A strict compiler as a design tool. You already fix errors before running. Rust does more of that, so the error messages are worth reading.

1. const fixes the name; let fixes the value

Section titled “1. const fixes the name; let fixes the value”

Your habit: declare with const and still change what is inside.

TypeScripth01.ts
const scores = [90, 85];
scores.push(70); // allowed: const fixes the name, not the array
console.log(scores);
$ node h01.ts
[ 90, 85, 70 ]

Rust: a plain let stops changes inside the value too. What TypeScript calls let is let mut.

Rustsrc/bin/h01.rs
fn main() {
let scores = vec![90, 85];
scores.push(70);
println!("{scores:?}");
}
$ cargo run --bin h01
error[E0596]: cannot borrow `scores` as mutable, as it is not declared as mutable
 --> src/bin/h01.rs:3:5
  |
3 |     scores.push(70);
  |     ^^^^^^ cannot borrow as mutable
  |
help: consider changing this to be mutable
  |
2 |     let mut scores = vec![90, 85];
  |         +++

Why: whether a value can change is part of how you bind it, so you can see from the declaration whether a later line may change it (Variables).

2. const b = a shares the array; let b = a moves it

Section titled “2. const b = a shares the array; let b = a moves it”

Your habit: assign an object and treat both names as the same thing.

TypeScripth02.ts
const a = [1, 2, 3];
const b = a; // a second name for the same array
b.push(4);
console.log(a);
$ node h02.ts
[ 1, 2, 3, 4 ]

Rust: the vector has one owner, and let mut b = a makes b the owner. a is unusable afterwards, and the compiler’s help suggests a clone.

Rustsrc/bin/h02.rs
fn main() {
let a = vec![1, 2, 3];
let mut b = a; // moves the vector: b owns it now
b.push(4);
println!("{a:?}");
}
$ cargo run --bin h02
error[E0382]: borrow of moved value: `a`
 --> src/bin/h02.rs:5:16
  |
2 |     let a = vec![1, 2, 3];
  |         - move occurs because `a` has type `Vec<i32>`, which does not implement the `Copy` trait
3 |     let mut b = a; // moves the vector: b owns it now
  |                 - value moved here
4 |     b.push(4);
5 |     println!("{a:?}");
  |                ^ value borrowed here after move
  |
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let mut b = a.clone(); // moves the vector: b owns it now
  |                  ++++++++

Why: with one owner, the memory can be freed when that owner goes out of scope, with no garbage collector. Ownership draws it, and Copy or reference compares it with what you are used to.

3. readonly is one name’s promise; & is everyone’s

Section titled “3. readonly is one name’s promise; & is everyone’s”

Your habit: use readonly for safety.

TypeScripth03.ts
const nums: number[] = [1, 2, 3];
const view: readonly number[] = nums; // read-only through this name only
nums.push(4);
console.log(view.length);
$ node h03.ts
4

view is readonly, but another name pushed to the same array and view saw it. Rust:

Rustsrc/bin/h03.rs
fn main() {
let mut nums = vec![1, 2, 3];
let view = &nums; // a shared borrow: nobody may change nums while it lives
nums.push(4);
println!("{}", view.len());
}
$ cargo run --bin h03
error[E0502]: cannot borrow `nums` as mutable because it is also borrowed as immutable
 --> src/bin/h03.rs:4:5
  |
3 |     let view = &nums; // a shared borrow: nobody may change nums while it lives
  |                ----- immutable borrow occurs here
4 |     nums.push(4);
  |     ^^^^^^^^^^^^ mutable borrow occurs here
5 |     println!("{}", view.len());
  |                    ---- immutable borrow later used here

Why: while a & reference exists, nobody changes the value through another name, except types built for it such as Cell and RefCell (Smart pointers). &mut is the one-writer side of the same rule (Borrowing, Mutable borrows).

4. undefined comes from anywhere; None is in the type

Section titled “4. undefined comes from anywhere; None is in the type”

Your habit: index past the end, or read a missing key, and get undefined. Under strict the compiler accepts line 2 below (noUncheckedIndexedAccess would flag it; not run). || also replaces 0, where ?? does not.

TypeScripth04.ts
const names = ["ana", "bo"];
console.log(names[5]); // no error, no check
console.log(names[5]?.toUpperCase() ?? "none");
const port = 0;
console.log(port || 8080, port ?? 8080);
$ node h04.ts
undefined
none
8080 0

Rust: get returns an Option, and unwrap_or behaves like ??. An index with [] that is out of range panics (the second run below). The number after thread 'main' is an operating-system thread id and changes from run to run.

Rustsrc/bin/h04.rs
fn main() {
let names = vec!["ana", "bo"];
println!("{:?}", names.get(5)); // None: the type says it may be missing
let upper = names.get(5).map(|n| n.to_uppercase());
println!("{}", upper.unwrap_or("none".to_string()));
let port: Option<u16> = Some(0);
println!("{}", port.unwrap_or(8080)); // only None falls back, like ??
}
terminal
$ cargo run --bin h04
None
none
0
$ cargo run --bin h04b
thread 'main' (408) panicked at src/bin/h04b.rs:3:25:
index out of bounds: the len is 2 but the index is 5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

This output changes from run to run. This is one real run.

Why: a missing value is a value of another type, so the compiler makes you handle it before you use it. One trap in the other direction: u?.name is valid Rust, and it means “return early from this function if u is None or an Err” (What Rust lacks runs it). Option is the lesson, and the same lesson lists the null family.

5. A matching shape is enough; Rust wants the impl

Section titled “5. A matching shape is enough; Rust wants the impl”

Your habit: write a class or an object literal with the right methods and pass it where an interface is expected.

TypeScripth05.ts
interface Speaker {
speak(): string;
}
class Robot {
speak() {
return "beep";
}
}
function say(s: Speaker) {
console.log(s.speak());
}
say(new Robot()); // Robot never mentions Speaker, and it fits
say({ speak: () => "hi" }); // so does an object literal
$ node h05.ts
beep
hi

Rust: a type fits a trait when it says so with impl Speaker for Robot. Having a method of the same name does not count.

Rustsrc/bin/h05.rs
trait Speaker {
fn speak(&self) -> String;
}
struct Robot;
impl Robot {
fn speak(&self) -> String {
"beep".to_string()
}
}
fn say(s: &impl Speaker) {
println!("{}", s.speak());
}
fn main() {
say(&Robot); // Robot has a speak method, but never said impl Speaker
}
$ cargo run --bin h05
error[E0277]: the trait bound `Robot: Speaker` is not satisfied
  --> src/bin/h05.rs:18:9
   |
18 |     say(&Robot); // Robot has a speak method, but never said impl Speaker
   |     --- ^^^^^^ unsatisfied trait bound
   |     |
   |     required by a bound introduced by this call
   |
help: the trait `Speaker` is not implemented for `Robot`
  --> src/bin/h05.rs:5:1
   |
 5 | struct Robot;
   | ^^^^^^^^^^^^
help: this trait has no implementations, consider adding one
  --> src/bin/h05.rs:1:1
   |
 1 | trait Speaker {
   | ^^^^^^^^^^^^^
note: required by a bound in `say`
  --> src/bin/h05.rs:13:17
   |
13 | fn say(s: &impl Speaker) {
   |                 ^^^^^^^ required by this bound in `say`

Why: the impl is where a type states that it means to do what the trait asks, and the compiler checks the methods against the trait (Traits).

Your habit: parse something, then tell the compiler what it is.

TypeScripth06.ts
interface User {
name: string;
age: number;
}
const u = JSON.parse('{"name":"ana"}') as User; // the compiler believes you
console.log(u.age + 1, typeof u.age);
$ node h06.ts
NaN undefined

The as User is erased, so nothing checked the data. This is the JavaScript tsc wrote for the same file:

terminal
$ tsc --noEmit false --outDir out    # the tsconfig says noEmit; this run writes the JavaScript
$ cat out/h06.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const u = JSON.parse('{"name":"ana"}'); // the compiler believes you
console.log(u.age + 1, typeof u.age);

Rust: as between number types changes the value, and text becomes a number through a parse that returns a Result.

Rustsrc/bin/h06.rs
fn main() {
let big: i32 = 300;
println!("{}", big as u8); // `as` changes the value
println!("{:?}", u8::try_from(big)); // the checked way
// Text becomes a number only through a parse that can fail.
println!("{:?}", "300".parse::<u8>());
println!("{:?}", "ana".parse::<u8>());
}
$ cargo run --bin h06
44
Err(TryFromIntError(PosOverflow))
Err(ParseIntError { kind: PosOverflow })
Err(ParseIntError { kind: InvalidDigit })

Why: TypeScript’s types are a claim about code, and an as can lie. A Rust value has the type it was built with, so a conversion either changes the value (as) or says it can fail (try_from, parse). Types covers as. For JSON in particular, the serde guide checks the data against your struct as it reads it.

7. A switch can skip a new case; a match lists it

Section titled “7. A switch can skip a new case; a match lists it”

Your habit: add a member to a union and rely on the switch.

TypeScripth07.ts
type Shape =
| { kind: "circle"; r: number }
| { kind: "square"; side: number }
| { kind: "triangle" };
function describe(s: Shape): void {
switch (s.kind) {
case "circle":
console.log("circle", 3 * s.r * s.r);
break;
case "square":
console.log("square", s.side * s.side);
break;
}
}
describe({ kind: "circle", r: 1 });
describe({ kind: "square", side: 2 });
describe({ kind: "triangle" });
console.log("done");
$ node h07.ts
circle 3
square 4
done

tsc accepted this, and the triangle printed nothing. TypeScript can catch it if you add a line that assigns the leftover value to never:

TypeScriptnever.ts
type Shape =
| { kind: "circle"; r: number }
| { kind: "square"; side: number }
| { kind: "triangle" };
function describe(s: Shape): void {
switch (s.kind) {
case "circle":
console.log("circle", 3 * s.r * s.r);
break;
case "square":
console.log("square", s.side * s.side);
break;
default: {
const missing: never = s; // fails to compile while a member is left over
return missing;
}
}
}
describe({ kind: "square", side: 2 });
terminal
$ tsc
never.ts(15,13): error TS2322: Type '{ kind: "triangle"; }' is not assignable to type 'never'.
$ node never.js    # tsc still wrote the file
square 4

Rust does that check by itself:

Rustsrc/bin/h07.rs
enum Shape {
Circle { r: f64 },
Square { side: f64 },
Triangle,
}
fn describe(s: &Shape) {
match s {
Shape::Circle { r } => println!("circle {}", 3.0 * r * r),
Shape::Square { side } => println!("square {}", side * side),
}
}
fn main() {
describe(&Shape::Circle { r: 1.0 });
describe(&Shape::Square { side: 2.0 });
describe(&Shape::Triangle);
println!("done");
}
$ cargo run --bin h07
error[E0004]: non-exhaustive patterns: `&Shape::Triangle` not covered
  --> src/bin/h07.rs:8:11
   |
 8 |     match s {
   |           ^ pattern `&Shape::Triangle` not covered
   |
note: `Shape` defined here
  --> src/bin/h07.rs:1:6
   |
 1 | enum Shape {
   |      ^^^^^
...
 4 |     Triangle,
   |     -------- not covered
   = note: the matched value is of type `&Shape`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
   |
10 ~         Shape::Square { side } => println!("square {}", side * side),
11 ~         &Shape::Triangle => todo!(),

Why: the check is part of match and needs no extra line. A _ arm turns it off, as default: does (the third task below tries that). Enums and Match explain it.

Your habit: parseInt gives NaN and the maths carries on, or a function throws and the type says nothing.

TypeScripth08.ts
console.log(parseInt("abc", 10) + 1);
function parsePort(s: string): number {
const n = Number(s);
if (!Number.isInteger(n) || n < 0 || n > 65535) throw new Error(`bad port: ${s}`);
return n;
}
console.log(parsePort("8080"));
try {
console.log(parsePort("80x"));
} catch (e) {
console.log("caught:", e instanceof Error ? e.message : e);
}
$ node h08.ts
NaN
8080
caught: bad port: 80x

Rust: the failure is a value in the return type, and ? passes it up.

Rustsrc/bin/h08.rs
use std::num::ParseIntError;
fn parse_port(s: &str) -> Result<u16, ParseIntError> {
s.parse::<u16>()
}
fn double_port(s: &str) -> Result<u16, ParseIntError> {
let port = parse_port(s)?; // on Err, return it to the caller
Ok(port * 2)
}
fn main() {
println!("{:?}", "abc".parse::<i32>());
println!("{:?}", double_port("80"));
match double_port("80x") {
Ok(p) => println!("port {p}"),
Err(e) => println!("caught: {e}"),
}
}
$ cargo run --bin h08
Err(ParseIntError { kind: InvalidDigit })
Ok(160)
caught: invalid digit found in string

Why: the signature says which functions can fail, and the Err is a value you match on or pass up with ?. Errors is the lesson.

9. A promise starts when you call; a future starts when you poll

Section titled “9. A promise starts when you call; a future starts when you poll”

Your habit: call an async function and expect the work to be under way.

TypeScripth09.ts
async function fetchName(): Promise<string> {
console.log("fetching");
return "ana";
}
async function main() {
const p = fetchName(); // the body has already started
console.log("created");
console.log(await p);
}
main();
$ node h09.ts
fetching
created
ana

Rust: calling an async fn builds a future and runs nothing. The standard library has no runtime, so this program has a small block_on (poll once) in place of one. In real code you add a runtime crate, and the tokio guide below uses tokio.

Rustsrc/bin/h09.rs
use std::future::Future;
use std::pin::pin;
use std::task::{Context, Poll, Waker};
async fn fetch_name() -> String {
println!("fetching");
"ana".to_string()
}
// A runtime's job in miniature: poll once. This future never waits, so one poll is enough.
fn block_on<F: Future>(fut: F) -> F::Output {
let mut fut = pin!(fut);
let mut cx = Context::from_waker(Waker::noop());
match fut.as_mut().poll(&mut cx) {
Poll::Ready(v) => v,
Poll::Pending => panic!("this future waits; use a real runtime"),
}
}
fn main() {
let p = fetch_name(); // builds a future; the body has not started
println!("created");
println!("{}", block_on(p));
}
$ cargo run --bin h09
created
fetching
ana

Why: Node’s event loop comes with Node, and Rust’s is a library you choose, so a future is a value that a runtime drives. Async basics builds this up, and Async with Tokio is the real runtime.

10. Spread and options objects become .. and Default

Section titled “10. Spread and options objects become .. and Default”

Your habit: { ...user, age: 31 } for a changed copy, and an options object with defaults for optional settings.

TypeScripth10.ts
interface User {
name: string;
age: number;
admin: boolean;
}
const ana: User = { name: "ana", age: 30, admin: false };
const older = { ...ana, age: 31 }; // copies the fields, one level deep
console.log(ana.age, older.age, older.name);
function connect(opts: { retries?: number; verbose?: boolean } = {}) {
const { retries = 3, verbose = false } = opts;
console.log({ retries, verbose });
}
connect();
connect({ verbose: true });
$ node h10.ts
30 31 ana
{ retries: 3, verbose: false }
{ retries: 3, verbose: true }

Rust: ..ana takes the remaining fields from ana, and that moves them, so the String is gone from ana. (The age is a number and is copied, which is why the print of ana.age is accepted.)

Rustsrc/bin/h10.rs
#[derive(Debug)]
struct User {
name: String,
age: u32,
admin: bool,
}
fn main() {
let ana = User { name: "ana".to_string(), age: 30, admin: false };
let older = User { age: 31, ..ana }; // takes the remaining fields from ana
println!("{} {}", ana.age, older.age);
println!("{}", ana.name);
}
$ cargo run --bin h10
error[E0382]: borrow of moved value: `ana.name`
  --> src/bin/h10.rs:12:20
   |
10 |     let older = User { age: 31, ..ana }; // takes the remaining fields from ana
   |                 ----------------------- value moved here
11 |     println!("{} {}", ana.age, older.age);
12 |     println!("{}", ana.name);
   |                    ^^^^^^^^ value borrowed here after move
   |
   = note: move occurs because `ana.name` has type `String`, which does not implement the `Copy` trait

For optional settings, write a Default and fill in the fields you care about with ..Default::default():

Rustsrc/bin/h10b.rs
struct Options {
retries: u32,
verbose: bool,
}
impl Default for Options {
fn default() -> Self {
Options { retries: 3, verbose: false }
}
}
fn connect(opts: Options) {
println!("retries {}, verbose {}", opts.retries, opts.verbose);
}
fn main() {
connect(Options::default());
connect(Options { verbose: true, ..Default::default() });
}
$ cargo run --bin h10b
retries 3, verbose false
retries 3, verbose true

Why: a struct update is a move of whatever is not Copy. Use ..ana.clone() if you still need ana. Structs and Common traits cover both.

Each row is one line of ts/idioms.ts and one line of src/bin/idioms.rs, and both programs print the same row numbers. The Rust cells compile, and the TypeScript cells ran.

# You write In Rust
1 `${n} items` format!("{n} items")
2 x ?? 5 x.unwrap_or(5)
3 u?.name u.as_ref().map(|u| &u.name)
4 xs.map((x) => x * 2) xs.iter().map(|x| x * 2).collect::<Vec<_>>()
5 xs.filter((x) => x % 2 === 0) xs.iter().copied().filter(|x| x % 2 == 0).collect::<Vec<_>>()
6 xs.reduce((a, b) => a + b, 0) xs.iter().fold(0, |a, b| a + b)
7 xs.find((x) => x > 2) xs.iter().find(|&&x| x > 2)
8 xs.includes(3) xs.contains(&3)
9 xs.slice(1, 3) &xs[1..3]
10 [...xs, ...ys] [xs.as_slice(), ys.as_slice()].concat()
11 [10, 9, 1].sort() v.sort()
12 a === b a == b
13 s.length, [...s].length s.len(), s.chars().count()
14 m.set("a", 1), m.get("b") ?? 0 m.insert("a", 1), m.get("b").copied().unwrap_or(0)
15 for (const [i, x] of xs.entries()) for (i, x) in xs.iter().enumerate()
16 parseInt(num, 10) num.parse::<i32>()
17 const { x: px, y: py } = p; let Point { x: px, y: py } = p;
18 (x: number) => x * 2 |x: i32| x * 2
19 Array.from({ length: 3 }, (_, i) => i * i) (0..3).map(|i| i * i).collect::<Vec<_>>()
20 xs.every((x) => x > 0) xs.iter().all(|&x| x > 0)
The two programs and what each printed
TypeScriptidioms.ts
// One line per row of the page's table: row number, then the value.
function row(n: number, v: unknown) {
console.log(String(n).padStart(2), v);
}
const n = 3;
const x: number | undefined = undefined;
const u: { name: string } | undefined = { name: "ana" };
const xs = [1, 2, 3, 4];
const ys = [5, 6];
const s = "héllo🙂";
const num = "12";
const m = new Map<string, number>();
const p = { x: 1, y: 2 };
row(1, `${n} items`);
row(2, x ?? 5);
row(3, u?.name);
row(4, xs.map((x) => x * 2));
row(5, xs.filter((x) => x % 2 === 0));
row(6, xs.reduce((a, b) => a + b, 0));
row(7, xs.find((x) => x > 2));
row(8, xs.includes(3));
row(9, xs.slice(1, 3));
row(10, [...xs, ...ys]);
row(11, [10, 9, 1].sort());
const a = [1, 2];
const b = [1, 2];
row(12, a === b);
row(13, [s.length, [...s].length]);
m.set("a", 1);
row(14, m.get("b") ?? 0);
const pairs: [number, number][] = [];
for (const [i, x] of xs.entries()) pairs.push([i, x]);
row(15, pairs);
row(16, [parseInt(num, 10), parseInt("x", 10)]);
const { x: px, y: py } = p;
row(17, [px, py]);
const double = (x: number) => x * 2;
row(18, double(21));
row(19, Array.from({ length: 3 }, (_, i) => i * i));
row(20, xs.every((x) => x > 0));
$ node idioms.ts
 1 3 items
 2 5
 3 ana
 4 [ 2, 4, 6, 8 ]
 5 [ 2, 4 ]
 6 10
 7 3
 8 true
 9 [ 2, 3 ]
10 [ 1, 2, 3, 4, 5, 6 ]
11 [ 1, 10, 9 ]
12 false
13 [ 7, 6 ]
14 0
15 [ [ 0, 1 ], [ 1, 2 ], [ 2, 3 ], [ 3, 4 ] ]
16 [ 12, NaN ]
17 [ 1, 2 ]
18 42
19 [ 0, 1, 4 ]
20 true
Rustsrc/bin/idioms.rs
use std::collections::HashMap;
use std::fmt::Debug;
// One line per row of the page's table: row number, then the value.
fn row<T: Debug>(n: u32, v: T) {
println!("{n:>2} {v:?}");
}
struct Point {
x: i32,
y: i32,
}
struct User {
name: String,
}
fn main() {
let n = 3;
let x: Option<i32> = None;
let u: Option<User> = Some(User { name: "ana".to_string() });
let xs = vec![1, 2, 3, 4];
let ys = vec![5, 6];
let s = "héllo🙂";
let num = "12";
let mut m = HashMap::new();
let p = Point { x: 1, y: 2 };
row(1, format!("{n} items"));
row(2, x.unwrap_or(5));
row(3, u.as_ref().map(|u| &u.name));
row(4, xs.iter().map(|x| x * 2).collect::<Vec<_>>());
row(5, xs.iter().copied().filter(|x| x % 2 == 0).collect::<Vec<_>>());
row(6, xs.iter().fold(0, |a, b| a + b));
row(7, xs.iter().find(|&&x| x > 2));
row(8, xs.contains(&3));
row(9, &xs[1..3]);
row(10, [xs.as_slice(), ys.as_slice()].concat());
let mut v = vec![10, 9, 1];
v.sort();
row(11, &v);
let a = vec![1, 2];
let b = vec![1, 2];
row(12, a == b);
row(13, (s.len(), s.chars().count()));
m.insert("a", 1);
row(14, m.get("b").copied().unwrap_or(0));
let mut pairs = Vec::new();
for (i, x) in xs.iter().enumerate() {
pairs.push((i, *x));
}
row(15, pairs);
row(16, (num.parse::<i32>(), "x".parse::<i32>()));
let Point { x: px, y: py } = p;
row(17, (px, py));
let double = |x: i32| x * 2;
row(18, double(21));
row(19, (0..3).map(|i| i * i).collect::<Vec<_>>());
row(20, xs.iter().all(|&x| x > 0));
}
$ cargo run --bin idioms
 1 "3 items"
 2 5
 3 Some("ana")
 4 [2, 4, 6, 8]
 5 [2, 4]
 6 10
 7 Some(3)
 8 true
 9 [2, 3]
10 [1, 2, 3, 4, 5, 6]
11 [1, 9, 10]
12 true
13 (10, 6)
14 0
15 [(0, 1), (1, 2), (2, 3), (3, 4)]
16 (Ok(12), Err(ParseIntError { kind: InvalidDigit }))
17 (1, 2)
18 42
19 [0, 1, 4]
20 true

Everything else differs only in how console.log and {:?} draw the same value: quotes around a string (row 1), spacing and brackets ([ 2, 3 ] against [2, 3]), and tuples where TypeScript prints arrays (rows 13, 15, 16, 17). The places that differ in meaning:

  • Rows 3 and 7 print ana and 3 in TypeScript, and Some("ana") and Some(3) in Rust: the Rust value is still an Option.
  • Row 11: sort() with no function compares numbers as text, which is why TypeScript printed [ 1, 10, 9 ]. Rust sorts numbers as numbers.
  • Row 12: two arrays with the same contents are not ===. The two vectors are == by value (Common traits).
  • Row 13: length counts UTF-16 units, and len() counts UTF-8 bytes, so the same text gives 7 and 10. Counting code points is chars().count(), 6 here, and TypeScript’s [...s].length agrees (Strings).
  • Row 16: a bad number is NaN in TypeScript and an Err in Rust.

The rows with .iter() and .collect() show where the two languages differ: Rust iterators are lazy and nothing happens until collect, where Array.prototype.map builds a new array each time (Iterators).

Job TypeScript and Node Rust
Add a dependency npm install is-number@7.0.0 cargo add itoa@1.0.15
Project file package.json, plus tsconfig.json for the compiler Cargo.toml
Lock file package-lock.json Cargo.lock
Check types tsc cargo check
Run node file.ts cargo run
Test node --test (the runner built into Node) cargo test
Format Prettier (named, not run) cargo fmt
Lint ESLint (named, not run) cargo clippy
Docs TypeDoc (named, not run) cargo doc
Debugger node --inspect with an editor or browser DevTools (named, not run) rust-gdb or rust-lldb (not run: the command -v line below found the two wrappers, and no gdb or lldb)

The image has these versions, and the last command lists which debugger and other tools it found (it prints only those it finds):

terminal
$ node --version
v24.22.1
$ npm --version
11.19.1
$ tsc --version
Version 7.0.2
$ cargo --version
cargo 1.99.1 (5f94df478 2026-08-27)
$ rustc --version
rustc 1.99.1 (58cf8f9c3 2026-10-13)
$ cargo fmt --version
rustfmt 1.10.0-stable (58cf8f9c35 2026-10-13)
$ cargo clippy --version
clippy 0.1.99 (58cf8f9c35 2026-10-13)
$ command -v gdb lldb rust-gdb rust-lldb || true
/opt/cargo/bin/rust-gdb
/opt/cargo/bin/rust-lldb
The npm and cargo commands, run (these two need network)

npm init -y wrote "type": "commonjs", and the test file uses import, so the second command switches the project to ES modules. npm then wrote a package-lock.json, and node --test found the two tests in slugify.test.ts:

terminal
$ npm init -y
Wrote to /home/you/ts-habits/slugify/package.json:

{
  "name": "slugify",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "commonjs"
}
$ npm pkg set type=module
$ npm install is-number@7.0.0    # needs network
$ cat package.json
{
  "name": "slugify",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "type": "module",
  "dependencies": {
    "is-number": "^7.0.0"
  }
}
$ ls
node_modules  package-lock.json  package.json  slugify.test.ts	slugify.ts
$ node --test
✔ joins words (0.584379ms)
✔ drops punctuation (0.056626ms)
ℹ tests 2
ℹ suites 0
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 57.176661

This output changes from run to run. This is one real run.

cargo new writes a Cargo.toml and cargo add adds the dependency line:

terminal
$ cargo new hello --vcs none
    Creating binary (application) `hello` package
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
$ cargo add itoa@1.0.15    # needs network
    Updating crates.io index
      Adding itoa v1.0.15 to dependencies
             Features:
             - no-panic
    Updating crates.io index
     Locking 1 package to highest Rust 1.99.1 compatible version
$ cat Cargo.toml
[package]
name = "hello"
version = "0.1.0"
edition = "2024"

[dependencies]
itoa = "1.0.15"

The same two tests in Rust, a doc-test, and the formatter and linter:

Rustsrc/lib.rs
/// Turns a title into a URL slug: lowercase words joined by `-`.
///
/// ```
/// assert_eq!(slug::slugify("Hello, World"), "hello-world");
/// ```
pub fn slugify(title: &str) -> String {
title
.split(|c: char| !c.is_alphanumeric())
.filter(|w| !w.is_empty())
.map(|w| w.to_lowercase())
.collect::<Vec<_>>()
.join("-")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn joins_words() {
assert_eq!(slugify("Rust for TypeScript developers"), "rust-for-typescript-developers");
}
#[test]
fn drops_punctuation() {
assert_eq!(slugify(" Hello, World! "), "hello-world");
}
}
terminal
$ cargo check
    Checking slug v0.1.0 (/home/you/ts-habits)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s
$ cargo fmt --check
Diff in /home/you/ts-habits/src/lib.rs:18:
 
     #[test]
     fn joins_words() {
-        assert_eq!(slugify("Rust for TypeScript developers"), "rust-for-typescript-developers");
+        assert_eq!(
+            slugify("Rust for TypeScript developers"),
+            "rust-for-typescript-developers"
+        );
     }
 
     #[test]
$ cargo fmt
$ cargo fmt --check
$ cargo clippy
$ cargo test
running 2 tests
test tests::drops_punctuation ... ok
test tests::joins_words ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests slug

running 1 test
test src/lib.rs - slugify (line 3) ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

all doctests ran in 0.15s; merged doctests compilation took 0.15s
$ cargo doc --no-deps
 Documenting slug v0.1.0 (/home/you/ts-habits)

   Generated target/doc/slug/index.html

This output changes from run to run. This is one real run.

Two differences show in the commands above. In the TypeScript column the compiler, the runtime and the package manager are three commands (tsc, node, npm), and in the Rust column the build, test, format, lint and docs commands all start with cargo. And cargo check stops before generating code: the Cargo docs say it checks the code and its dependencies for errors without producing an executable, which fits the moments when you only want to know whether the code compiles.

You know the syntax, so start with the lessons where your instincts mislead you.

  1. Rust at a glance: a page of code to read, with your TypeScript beside it.
  2. Stack and heap and Copy or reference: an object variable holds a reference, and a Rust variable holds the value itself (for a Vec, a small handle to its heap buffer).
  3. Ownership: habits 1 and 2, with the memory drawn.
  4. Borrowing and Mutable borrows: habit 3, and the rule you will meet most.
  5. Option and Errors: habits 4 and 8.
  6. Enums and Match: habit 7, and the home of your discriminated unions.
  7. Traits and Generics: habit 5.
  8. Closures and Iterators: the chains from the table above.
  9. Borrow checker: the ways out of the errors in habits 2 and 3.
  10. Async basics, then Async with Tokio: habit 9.
  11. JSON with serde: typed data from outside, the answer to habit 6.
  12. Modules and Using crates: import/export and npm on the Rust side.
  13. Calling Rust from Node and Rust and WebAssembly: the two ways your Rust can run inside a JavaScript program.
  14. Idioms and design patterns: what to write once the rules stop surprising you.

Each of these was run in the image, or cites its source.

  • Running code that does not type-check. By default tsc reports an error and still writes the JavaScript, so you can run the program anyway (here n * 2 gave NaN); the noEmitOnError option in the tsconfig reference turns that off (not run). cargo build writes no executable when a build fails. The two sources:

    TypeScriptemit.ts
    const n: number = "five";
    console.log(n * 2);
    Rustsrc/bin/emit.rs
    fn main() {
    let n: i32 = "five";
    println!("{}", n * 2);
    }
    terminal
    $ tsc
    emit.ts(1,7): error TS2322: Type 'string' is not assignable to type 'number'.
    $ ls
    emit.js  emit.ts  tsconfig.json
    $ node emit.js
    NaN
    
    terminal
    $ cargo build --bin emit
    error[E0308]: mismatched types
     --> src/bin/emit.rs:2:18
      |
    2 |     let n: i32 = "five";
      |            ---   ^^^^^^ expected `i32`, found `&str`
      |            |
      |            expected due to this
    $ ls target/debug/emit
    ls: cannot access 'target/debug/emit': No such file or directory
    
  • Printing anything. console.log(p) shows any object. In Rust, {:?} needs a type that derives or implements Debug, and the compiler says so when it does not.

    TypeScriptinspect.ts
    const p = { x: 1, y: 2 };
    console.log(p);
    $ node inspect.ts
    { x: 1, y: 2 }
    Rustsrc/bin/nodebug.rs
    struct Point {
    x: i32,
    y: i32,
    }
    fn main() {
    let p = Point { x: 1, y: 2 };
    println!("{p:?}");
    println!("{}", p.x + p.y);
    }
    $ cargo run --bin nodebug
    error[E0277]: `Point` doesn't implement `Debug`
     --> src/bin/nodebug.rs:8:15
      |
    8 |     println!("{p:?}");
      |               ^^^^^ `Point` cannot be formatted using `{:?}` because it doesn't implement `Debug`
      |
      = help: the trait `Debug` is not implemented for `Point`
    help: consider annotating `Point` with `#[derive(Debug)]`
      |
    1 + #[derive(Debug)]
    2 | struct Point {
  • A union written where you need it. string | number in a parameter is one line. In Rust you name an enum and wrap each value, as habit 7 did.

    TypeScriptunion.ts
    function show(x: string | number) {
    console.log(typeof x === "string" ? x.toUpperCase() : x.toFixed(2));
    }
    show("ana");
    show(2);
    $ node union.ts
    ANA
    2.00
    Rustsrc/bin/either.rs
    enum Input {
    Text(String),
    Number(f64),
    }
    fn show(x: Input) {
    match x {
    Input::Text(s) => println!("{}", s.to_uppercase()),
    Input::Number(n) => println!("{n:.2}"),
    }
    }
    fn main() {
    show(Input::Text("ana".to_string()));
    show(Input::Number(2.0));
    }
    $ cargo run --bin either
    ANA
    2.00
  • A REPL in the toolchain. node starts one. The components installed in the image are below, and none is a REPL. A community one, Evcxr, exists (not run).

    terminal
    $ rustup component list --installed
    cargo-aarch64-unknown-linux-gnu
    clippy-aarch64-unknown-linux-gnu
    rust-std-aarch64-unknown-linux-gnu
    rustc-aarch64-unknown-linux-gnu
    rustfmt-aarch64-unknown-linux-gnu
    
  • An event loop that comes with the runtime. async fn main does not compile without a runtime crate, and Async basics shows the error.

  • JSON.parse of any shape. Rust needs a type for the data, or serde_json::Value when you do not have one (serde guide).

The session below ran these three changes in the image.

  1. In h01.rs, change let scores to let mut scores. It builds and prints [90, 85, 70].
  2. In h02.rs, change let mut b = a; to let mut b = a.clone(); and print both. The build succeeds and a and b print as different lists.
  3. In h07.rs, add _ => {} as a last arm. The error is gone, and the triangle prints nothing, as in TypeScript’s switch. This is what a wildcard costs you.
terminal
$ cargo run --bin h01
[90, 85, 70]
$ cargo run --bin h02
[1, 2, 3] [1, 2, 3, 4]
$ cargo run --bin h07
circle 3
square 4
done