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:
$ 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.
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
What carries over
Section titled “What carries over”- Types after names and bounded generics.
function largest<T extends number | string>(list: T[]): Tbecomesfn 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 theswitchthat narrows it is anenumand amatch, with the tag kept for you (Enums). - Arrow functions are closures.
(x) => x * factoris|x| x * factor, andmap/filterchains read the same (Closures, Iterators). - Destructuring is patterns.
const { x, y } = pislet Point { x, y } = p;, and patterns also work inmatch(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.
Ten habits to unlearn
Section titled “Ten habits to unlearn”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.
const scores = [90, 85];scores.push(70); // allowed: const fixes the name, not the arrayconsole.log(scores);[ 90, 85, 70 ]
Rust: a plain let stops changes inside the value too. What TypeScript calls let is let mut.
fn main() { let scores = vec![90, 85]; scores.push(70); println!("{scores:?}");}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.
const a = [1, 2, 3];const b = a; // a second name for the same arrayb.push(4);console.log(a);[ 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.
fn main() { let a = vec![1, 2, 3]; let mut b = a; // moves the vector: b owns it now b.push(4); println!("{a:?}");}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.
const nums: number[] = [1, 2, 3];const view: readonly number[] = nums; // read-only through this name onlynums.push(4);console.log(view.length);4
view is readonly, but another name pushed to the same array and view saw it. Rust:
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());}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.
const names = ["ana", "bo"];console.log(names[5]); // no error, no checkconsole.log(names[5]?.toUpperCase() ?? "none");
const port = 0;console.log(port || 8080, port ?? 8080);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.
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 ??}$ 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.
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 fitssay({ speak: () => "hi" }); // so does an object literalbeep 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.
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}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).
6. as User promises; as u8 converts
Section titled “6. as User promises; as u8 converts”Your habit: parse something, then tell the compiler what it is.
interface User { name: string; age: number;}const u = JSON.parse('{"name":"ana"}') as User; // the compiler believes youconsole.log(u.age + 1, typeof u.age);NaN undefined
The as User is erased, so nothing checked the data. This is the JavaScript tsc wrote for the same file:
$ 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.
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>());}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.
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");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:
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 });$ 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:
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");}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.
8. throw and NaN become Result and ?
Section titled “8. throw and NaN become Result and ?”Your habit: parseInt gives NaN and the maths carries on, or a function throws and the type says nothing.
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);}NaN 8080 caught: bad port: 80x
Rust: the failure is a value in the return type, and ? passes it up.
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}"), }}Err(ParseIntError { kind: InvalidDigit })
Ok(160)
caught: invalid digit found in stringWhy: 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.
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();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.
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));}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.
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 deepconsole.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 });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.)
#[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);}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():
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() });}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.
Idiom by idiom
Section titled “Idiom by idiom”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
// 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));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
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));} 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 trueEverything 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
anaand3in TypeScript, andSome("ana")andSome(3)in Rust: the Rust value is still anOption. - 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:
lengthcounts UTF-16 units, andlen()counts UTF-8 bytes, so the same text gives 7 and 10. Counting code points ischars().count(), 6 here, and TypeScript’s[...s].lengthagrees (Strings). - Row 16: a bad number is
NaNin TypeScript and anErrin 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).
Toolchain map
Section titled “Toolchain map”| Job | TypeScript and Node | Rust |
|---|---|---|
| Add a dependency | npm install [email protected] |
cargo add [email protected] |
| 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):
$ 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:
$ 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 [email protected] # 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:
$ 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 [email protected] # 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:
/// 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"); }}$ 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.
A reading path
Section titled “A reading path”You know the syntax, so start with the lessons where your instincts mislead you.
- Rust at a glance: a page of code to read, with your TypeScript beside it.
- 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). - Ownership: habits 1 and 2, with the memory drawn.
- Borrowing and Mutable borrows: habit 3, and the rule you will meet most.
- Option and Errors: habits 4 and 8.
- Enums and Match: habit 7, and the home of your discriminated unions.
- Traits and Generics: habit 5.
- Closures and Iterators: the chains from the table above.
- Borrow checker: the ways out of the errors in habits 2 and 3.
- Async basics, then Async with Tokio: habit 9.
- JSON with serde: typed data from outside, the answer to habit 6.
- Modules and Using crates:
import/exportandnpmon the Rust side. - Calling Rust from Node and Rust and WebAssembly: the two ways your Rust can run inside a JavaScript program.
- Idioms and design patterns: what to write once the rules stop surprising you.
What you will miss
Section titled “What you will miss”Each of these was run in the image, or cites its source.
-
Running code that does not type-check. By default
tscreports an error and still writes the JavaScript, so you can run the program anyway (heren * 2gaveNaN); thenoEmitOnErroroption in the tsconfig reference turns that off (not run).cargo buildwrites no executable when a build fails. The two sources:TypeScriptemit.tsconst n: number = "five";console.log(n * 2);Rustsrc/bin/emit.rsfn 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 implementsDebug, and the compiler says so when it does not.TypeScriptinspect.tsconst p = { x: 1, y: 2 };console.log(p);$ node inspect.ts{ x: 1, y: 2 }Rustsrc/bin/nodebug.rsstruct Point {x: i32,y: i32,}fn main() {let p = Point { x: 1, y: 2 };println!("{p:?}");println!("{}", p.x + p.y);}$ cargo run --bin nodebugerror[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 | numberin a parameter is one line. In Rust you name an enum and wrap each value, as habit 7 did.TypeScriptunion.tsfunction show(x: string | number) {console.log(typeof x === "string" ? x.toUpperCase() : x.toFixed(2));}show("ana");show(2);$ node union.tsANA 2.00
Rustsrc/bin/either.rsenum 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 eitherANA 2.00
-
A REPL in the toolchain.
nodestarts 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 maindoes not compile without a runtime crate, and Async basics shows the error. -
JSON.parseof any shape. Rust needs a type for the data, orserde_json::Valuewhen you do not have one (serde guide).
Try it yourself
Section titled “Try it yourself”The session below ran these three changes in the image.
- In
h01.rs, changelet scorestolet mut scores. It builds and prints[90, 85, 70]. - In
h02.rs, changelet mut b = a;tolet mut b = a.clone();and print both. The build succeeds andaandbprint as different lists. - In
h07.rs, add_ => {}as a last arm. The error is gone, and the triangle prints nothing, as in TypeScript’sswitch. This is what a wildcard costs you.
$ 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
Read more
Section titled “Read more”- Node.js: Modules: TypeScript and Node.js: test runner, the two Node pages used above
- The TypeScript Handbook: Type Compatibility, the structural typing of habit 5, and the tsconfig reference
- The Rust Book, chapter 4: ownership, chapter 6: enums and chapter 9: error handling
- The Cargo Book: getting started
Learn Rust is an independent project, not affiliated with the Rust Project or the Rust Foundation. Code and text are under the MIT licence.Source on GitHub.