The compiler is no longer advisory
You have had a compile step for years. What changes is what happens when it objects, and what an annotation is still doing once the program runs.
You'll be able to: Create, build, lint and run a Cargo project, and state what rustc enforces that tsc only reports.
How this course sits beside the Book
This course is a companion to The Rust Programming Language, by Steve Klabnik, Carol Nichols and Chris Krycho, known simply as the Book. The Book is where you learn Rust, and nothing here replaces a chapter of it.
What the Book cannot do is know what you already know. It assumes you have written code in another language without assuming which one. The word TypeScript does not appear in it, and JavaScript comes up in three of its sections. So each lesson assumes you have just read its chapters and does what the Book has no reason to:
- says what carries over from TypeScript, so you can move faster through what you already understand
- shows where the analogy breaks, with both languages compiled side by side
- names the habit to drop, and what a Rust programmer would do instead
The lessons follow the Book's order, so nothing here depends on a chapter you have not reached. Each one opens by telling you what to read and closes by telling you where to go next, and the contents list shows each lesson's chapters.
| Unit | Read in the Book | Lessons |
|---|---|---|
| The ground moves | Chapter 1 to Chapter 3 | 1–2 |
| Ownership | Chapter 4 | 3–5 |
| Modelling data | Chapter 5 to Chapter 9 | 6–10 |
| Abstraction | Chapter 10 to Chapter 14 | 11–15 |
| Sharing | Chapter 15 to Chapter 17 | 16–18 |
| Fluency | Chapter 18 to Chapter 21 | 19–21 |
§1.2 warns readers of dynamic languages such as JavaScript that they may not be used to compiling and running as separate steps. That sentence is not for you. You have run a compiler for years and you read types fluently, and all of that carries over. What you need to learn is a compile step that means something different.
What happens when the compiler objects
Here is the same mistake in both languages:
const total: number = "12";
console.log(total + 1);
fn main() {
let total: i32 = "12";
println!("{}", total + 1);
}
Give each one to its compiler. TypeScript first, using npx tsc if it is not on your path:
$ tsc --strict main.ts
main.ts(1,7): error TS2322: Type 'string' is not assignable to type 'number'.
$ echo $?
2
$ node main.js
121
tsc found the bug, reported it, and exited with an error status. Then it wrote main.js anyway, and that file runs. It prints 121, because + on a string concatenates, which is exactly the mistake the annotation forbade. Look at what it wrote and you can see why nothing stopped it:
$ cat main.js
"use strict";
const total = "12";
console.log(total + 1);
The annotation is gone. Nothing of it survives to run time, so there is nothing left to enforce. Node 24 goes further and never runs the type checker at all. It strips the types and runs what is left:
$ node main.ts
121
Now the same mistake in Rust:
$ rustc main.rs
error[E0308]: mismatched types
--> main.rs:2:22
|
2 | let total: i32 = "12";
| --- ^^^^ expected `i32`, found `&str`
| |
| expected due to this
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0308`.
$ echo $?
1
$ ls
main.js
main.rs
main.ts
Read that ls closely. There is a main.js from tsc, and no main from rustc. A program with a type error does not exist in Rust, so there is nothing to ship, run or forget about.
You can close part of this gap. --noEmitOnError makes tsc write nothing when it reports an error, and failing CI on type errors works well too.
$ rm main.js
$ tsc --strict --noEmitOnError main.ts
main.ts(1,7): error TS2322: Type 'string' is not assignable to type 'number'.
$ ls
main.rs
main.ts
What that cannot give you is the next section: however strict the check, it finishes before the program starts.
An annotation that changes what runs
Here is a line from §2 of the Book, where the guessing game turns its input into a number:
let guess: u32 = guess.trim().parse().expect("Please type a number!");
§2 Comparing the Guess to the Secret Number says you must tell Rust the exact number type you want, and it is easy to read past how strange that is if you come from TypeScript. parse is told what to produce by the annotation on the variable receiving its result. The type chooses the code that runs: annotate u32 and you get the parser for u32.
TypeScript cannot do that, because by the time anything runs the annotation has been erased. Compare the nearest equivalent:
const age: number = JSON.parse('"forty"');
console.log(age + 1);
fn main() {
let age: u32 = "forty".parse().expect("not a number");
println!("{}", age + 1);
}
forty1
thread 'main' (30896226) panicked at src/main.rs:2:36:
not a number: ParseIntError { kind: InvalidDigit }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Neither compiler could have caught this, because neither can see the input. The difference is where the failure lands. TypeScript accepted a string into a variable declared number, and the value travelled on until + quietly did the wrong thing, somewhere that could be far from the cause. Rust failed at the line where the bad value tried to enter, and said why.
You already know the TypeScript answer to this, which is a runtime validator such as Zod at the boundary. That is the right habit, and it is telling that you have to add it. In Rust there is no way to get a u32 out of parse without the check having happened.
What a clean compile promises
The Book's introduction describes the compiler's job this way:
In Rust, the compiler plays a gatekeeper role by refusing to compile code with these elusive bugs, including concurrency bugs.
"These elusive bugs" is doing some work there. The passage is about the subtle bugs of low-level code, not about every bug you can write. A clean compile in Rust rules out whole categories of mistake, and the lab below is there to stop you concluding more than that: some bugs still wait for run time, one gets only a warning, and one kind neither compiler can ever see.
Where your tools went
These are for finding the tool. The analogies are this course's own reading rather than the Book's, and they are close enough to navigate by, not to predict behaviour with.
| You reach for | In Rust | Where the Book covers it |
|---|---|---|
package.json | Cargo.toml | §1.3 |
package-lock.json | Cargo.lock | §2 |
npm install a package | a line under [dependencies] | §2 |
tsc --noEmit | cargo check | §1.3 |
tsc && node dist/main.js | cargo run | §1.3 |
| a production build | cargo build --release | §1.3 |
| ESLint | cargo clippy | Appendix D |
| Prettier | cargo fmt | Appendix D |
Getting past the compiler. In TypeScript you can keep working with type errors on screen, and as or any makes one go away when you are in a hurry. Rust gives you no build to keep working with, so try the reverse: run cargo check often, as §1.3 says many Rust programmers do, and treat each error as the next step rather than an obstacle.
Watch for the Rust version of the same reflex, which is .expect() and .unwrap() everywhere. They compile, and they move the failure back to run time. The Book uses expect in Chapter 2 deliberately:
in our case we just want to crash this program when a problem occurs
That is a fair call in a guessing game, and you will learn the alternative in Chapter 9. Until then, read each expect as a place you have decided a failure should crash the program.
Pick a bug, predict when each language first objects, then see what the tools really did. Some of these go Rust's way and some do not.
| First objection | TypeScript | Rust |
|---|
This page has no compiler in it. These results were recorded in advance by running these exact programs, and the course's checks fail if that ever stops being true.
Across this course you will build anyscan, a small command-line tool with no dependencies that scans TypeScript for its escape hatches: any, as, the non-null !, and @ts-ignore. It is a useful tool to have, and its hard parts arrive just as the Book teaches them. For now, it only needs to read a path.
$ cargo new anyscan
Creating binary (application) `anyscan` package
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
The TypeScript you would reach for looks fine, and tsc --strict agrees:
const path = process.argv[2];
if (path.endsWith(".ts")) console.log(`scanning ${path}`);
Run with no argument, that crashes, because process.argv[2] is typed string whether or not it exists. Replace src/main.rs with this instead:
use std::env;
fn main() {
let path = env::args().nth(1).expect("usage: anyscan <file>");
println!("scanning {path}");
}
nth(1) skips the program's own name and returns an Option, which you will meet properly in Chapter 6. It might hold a value or not, and the compiler will not let you use it as if it does, so you have to say what happens if it does not. For now expect says: crash with this message. Lesson 10 replaces that with a proper error.
$ cd anyscan
$ cargo run -- src/main.rs
Compiling anyscan v0.1.0 (/path/to/anyscan)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.21s
Running `target/debug/anyscan src/main.rs`
scanning src/main.rs
$ cargo run
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s
Running `target/debug/anyscan`
thread 'main' (30896226) panicked at src/main.rs:4:35:
usage: anyscan <file>
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
$ cargo clippy
Checking anyscan v0.1.0 (/path/to/anyscan)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.05s
$ cargo fmt --check
Checkpoint: with a path it prints that path, and with none it stops with your usage message. cargo clippy reports no warnings, and cargo fmt --check prints nothing.
tsc --strict main.ts reports TS2322 and exits with a non-zero status. With no other flags, what is on disk afterwards?
You port const port: number = JSON.parse(text) to let port: u16 = text.parse().expect("bad port");. At run time, text holds "70000", which does not fit in a u16. What happens?
number was hiding a lot
Chapter 3 is the Book's gentlest chapter, and most of it will feel like a rest. Four things in it look exactly like TypeScript and are not.
You'll be able to: Choose a numeric type, predict debug and release overflow, convert between types safely, and write if and blocks as expressions.
Most of this chapter carries straight over, and you can read it quickly: functions, if, loops, comments, and the idea of a type annotation after a colon. Slow down at let, at numbers, at quotation marks and at semicolons. Each one looks like something you already know, and each one means something else.
let means what you thought const meant
TypeScript's const protects the variable, not the value. It always did, and you learned this long ago. Rust's plain let protects both:
const scores = [1, 2];
scores[0] = 9;
console.log(scores);
fn main() {
let scores = [1, 2];
scores[0] = 9;
println!("{scores:?}");
}
So the words line up differently from how they look. Rust's const is a third thing again, as §3.1 Declaring Constants explains: a value fixed at compile time, with a type you must write.
| You want | TypeScript | Rust |
|---|---|---|
| a name you can rebind, contents you can change | let | let mut |
| a name you cannot rebind, contents you can change | const | no direct equivalent |
| neither can change | const plus as const or Readonly | let |
| a value known at compile time | no direct equivalent | const |
The second row has no equivalent because Rust does not separate the two ideas here. Mutability belongs to the whole value you reach through that binding. Lesson 4 picks this up, because it is the start of something much bigger.
§3.1 Shadowing has no TypeScript equivalent either, and it will look like a mistake at first:
const spaces = " ";
const spaces = spaces.length;
console.log(spaces);
fn main() {
let spaces = " ";
let spaces = spaces.len();
println!("{spaces}");
}
This is the same move as let guess: u32 = guess.trim().parse() in §2. In TypeScript you would invent spacesCount or parsedGuess. In Rust, reusing the name is the normal way to say "this is the same thing, converted", and the old value can no longer be reached by mistake.
number becomes a decision
TypeScript has one number type, and it is a 64-bit float. Rust makes you choose, and §3.2 Integer Types lists the choices: signed i8 to i128, unsigned u8 to u128, and isize and usize, which match the machine's pointer size. If you do not choose, Rust infers i32 for integers and f64 for floats, and those are good defaults. Use usize for anything that counts or indexes into memory, because that is what len() returns and what indexing takes.
That choice comes with a rule your TypeScript instincts will trip over at once. Numeric types do not mix, not even when the conversion would be safe:
fn main() {
let small: i32 = 1;
let large: i64 = 2;
println!("{}", small + large);
}
fn main() {
let small: i32 = 1;
let large: i64 = 2;
println!("{}", i64::from(small) + large);
}
i64::from is there because every i32 fits in an i64, so the conversion cannot fail. That brings us to the false friend with the highest stakes in this chapter.
as does the opposite of what it does in TypeScript
In TypeScript, as changes nothing at run time. It is an instruction to the type checker and it vanishes on the way to JavaScript. In Rust, as is a real conversion that runs, and it can lose data without saying so:
const text = "12" as unknown as number;
console.log(text + 1);
fn main() {
let big: i32 = 300;
let small = big as u8;
println!("{small}");
println!("{:?}", u8::try_from(big));
}
121
44
Err(TryFromIntError(PosOverflow))
300 does not fit in a u8, so as kept the low eight bits and gave you 44. No error, no warning, and Clippy's default lints have nothing to say about it either. u8::try_from returns a Result, the same type read_line returned in §2, so the failure is something you have to handle.
Overflow depends on how you built
You saw in Lab 1 that overflow behaves differently across builds. The note on §3.2 Integer Types explains that debug builds panic and release builds wrap, and it does not leave you wondering which one to rely on:
Relying on integer overflow’s wrapping behavior is considered an error.
So a program that overflows in release has a bug that your debug tests would have shown you, if they reached that number. When wrapping or running out of range is a real possibility, use the methods the Book lists, which behave the same in every build. The lab shows all of them.
Quotation marks mean something
In TypeScript, 'a', "a" and the backtick form are the same type. In Rust, single quotes hold exactly one character, of type char, and double quotes hold a string:
fn main() {
let greeting = 'hello';
println!("{greeting}");
}
Almost everything is an expression
§3.3 Statements and Expressions calls Rust an expression-based language. In practice, blocks and if produce values, so you do not need a ternary, and the language does not have one:
const count: number = 3;
const label = count === 1 ? "line" : "lines";
console.log(label);
fn main() {
let count = 3;
let label = if count == 1 { "line" } else { "lines" };
println!("{label}");
}
A function works the same way. Its last expression is its result, and a semicolon turns an expression into a statement that produces nothing. That is the source of a confusing error you are certain to meet, and it is worth reading once now:
fn plus_one(x: i32) -> i32 {
x + 1;
}
fn main() {
println!("{}", plus_one(5));
}
error[E0308]: mismatched types
--> src/main.rs:1:24
|
1 | fn plus_one(x: i32) -> i32 {
| -------- ^^^ expected `i32`, found `()`
| |
| implicitly returns `()` as its body has no tail or `return` expression
2 | x + 1;
| - help: remove this semicolon to return this value
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0308`.
Notice what the compiler did there. It worked out what you probably meant and suggested the fix. Rust's errors are often worth reading to the end, more so than you may be used to.
Two more differences from TypeScript sit nearby. A function's signature must state its parameter and return types, because Rust's inference stops at function boundaries, so there is no inferred return type like the one TypeScript gives you. And if wants a bool. §3.5 if Expressions names JavaScript directly on this point, and if (count) becomes if count != 0.
Three reflexes. let mut by default: start with let and add mut when the compiler tells you to, so mut marks the things that really change. return x; on the last line: write x, and save return for leaving early, as Clippy will tell you. And as between numbers: use from when it cannot fail and try_from when it can, and treat as as a decision to truncate. That last one is this course's own reading rather than a rule of the language.
fn total(prices: [i32; 3]) -> i32 {
let mut sum = 0;
for i in 0..prices.len() {
sum = sum + prices[i];
}
return sum;
}
fn main() {
println!("{}", total([1, 2, 3]));
}
fn total(prices: [i32; 3]) -> i32 {
let mut sum = 0;
for price in prices {
sum += price;
}
sum
}
fn main() {
println!("{}", total([1, 2, 3]));
}
mut sum stays, because that value really does change. §3.5 makes the same point about for over an index loop, and Lesson 14 goes further with iterators, where this whole function becomes one line.
Choose a type and push it past its edge. Try u8 near the top first, then u64 near the top to find where a JavaScript number stops being exact.
Every value here is exact, computed with arbitrary-precision integers against the type's real bounds, and the table was compared with rustc output when this lesson was written. It shows the build-dependent behaviour the Book describes, and the four families of methods that remove that dependency.
Make anyscan read the file. Add a small TypeScript file to scan, with every hatch in it. You will use it for the rest of the course.
// A small file with every escape hatch in it.
const config = JSON.parse(raw) as Config;
const name: any = config.name;
// @ts-ignore
const port = config.ports![0];
console.log("any of these words is fine in a string", name, port);
Then replace src/main.rs. Note if as an expression, and usize from count(), which you did not have to write down.
use std::{env, fs};
fn main() {
let path = env::args().nth(1).expect("usage: anyscan <file>");
let source = fs::read_to_string(&path).expect("could not read the file");
let lines = source.lines().count();
let label = if lines == 1 { "line" } else { "lines" };
println!("{path}: {lines} {label}, {} bytes", source.len());
}
The & in &path is next lesson's subject, so take it on trust for now.
$ cd anyscan
$ cargo run -- sample.ts
Compiling anyscan v0.1.0 (/path/to/anyscan)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.21s
Running `target/debug/anyscan sample.ts`
sample.ts: 6 lines, 232 bytes
Checkpoint: it prints the line and byte counts. Point it at a file that does not exist and read how expect reports the failure. Lesson 10 makes that better.
const items = [1, 2]; items[0] = 9; compiles and runs in TypeScript. You port it as let items = [1, 2]; items[0] = 9;. Why does Rust reject it?
A u8 holds 250 at run time, and you add 10 to it with +. What happens in a debug build and in a release build?
Assignment is not aliasing
In TypeScript an object can have as many names as you like, and a garbage collector cleans up after them all. Rust has no collector, and the rule that replaces it changes what = means.
You'll be able to: Predict which bindings stay valid after assigning, passing and returning a value, for Copy and non-Copy types.
The Book opens Chapter 4 by saying what is at stake:
Ownership is Rust’s most unique feature and has deep implications for the rest of the language.
That is not an exaggeration, and it is why the next three lessons all come from one chapter. Most of what will frustrate you in Rust for the first few weeks starts here.
You already know half of this, because you already treat two kinds of value differently. Numbers and booleans copy when you assign them. Objects share, so b and a end up as two names for one thing. Rust keeps the first half exactly as you expect. The second half is the part that changes.
The problem a garbage collector was solving
In TypeScript you never have to think about when memory gets freed. The collector works it out, by finding what is still reachable. That is why sharing is free: it does not matter how many names an object has, because the collector will wait until the last one is gone.
Rust has no collector, and it does not make you call free either. It has to know at compile time exactly when each value should be cleaned up, and the Book's §4.1 Memory and Allocation explains why getting this wrong by hand is so hard:
Doing this correctly has historically been a difficult programming problem.
Rust's answer is the three §4.1 Ownership Rules: each value has an owner, there can be only one at a time, and the value is dropped when its owner goes out of scope. One owner means one moment to free, known at compile time, with no collector and no bookkeeping at run time. Everything else in this lesson follows from that.
Some of this will come back to you. With manual retain and release you tracked who owned what by convention, and with ARC you still thought about strong and weak references. Rust's single owner is that same discipline, checked by the compiler, and Rc and Weak in Lesson 16 will feel familiar. This comparison is the course's own reading.
The same line, two meanings
const a = ["hi"];
const b = a;
b.push("!");
console.log(a);
fn main() {
let a = String::from("hi");
let b = a;
println!("{a} {b}");
}
[ 'hi', '!' ]
error[E0382]: borrow of moved value: `a`
--> src/main.rs:4:16
|
2 | let a = String::from("hi");
| - move occurs because `a` has type `String`, which does not implement the `Copy` trait
3 | let b = a;
| - value moved here
4 | println!("{a} {b}");
| ^ value borrowed here after move
|
help: consider cloning the value if the performance cost is acceptable
|
3 | let b = a.clone();
| ++++++++
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0382`.
If both names stayed valid in Rust, both would try to free the same memory at the end of the scope, which the Book calls a double free. So after let b = a, Rust treats a as gone. Nothing was copied except a pointer, a length and a capacity. That is why the Book calls this a move rather than a shallow copy, and why it costs almost nothing.
Look again at that error message. It names the type, explains why it moved, marks the line where it happened, and proposes a fix. You will read a lot of these.
What moves and what copies
Your instinct about primitives holds, but one type will catch you out:
| Type | On assignment | In TypeScript terms |
|---|---|---|
i32, f64, bool, char | copies | like number and boolean |
| tuples and arrays of those | copies | no equivalent: yours would share |
&str | copies | the closest thing to a string |
String, Vec | moves | like an object, except for the second name |
The third and fourth rows are the trap. A TypeScript string is immutable and free to copy around, so const b = a is always safe. A Rust String owns a growable buffer on the heap, so it moves. §4.1 Stack-Only Data: Copy gives the rule: a type can be Copy only if it needs no cleanup.
Passing is assigning
A function call is one more assignment, and this is where most TypeScript programmers first hit a move:
function shout(words: string[]) {
console.log(words.join("!"));
}
const a = ["hi", "there"];
shout(a);
console.log(a.length);
fn shout(words: String) {
println!("{words}!");
}
fn main() {
let a = String::from("hi there");
shout(a);
println!("{}", a.len());
}
shout took ownership, and its parameter went out of scope when it returned, so the string has already been freed. A function can hand ownership back by returning the value, as §4.1 Return Values and Scope shows, and the Book immediately calls that too much ceremony. References are the real answer, and they are the next lesson.
clone is structuredClone, not a spread
When you do want two independent values, ask for them:
const a = ["hi"];
const b = structuredClone(a);
b.push("!");
console.log(a, b);
fn main() {
let a = String::from("hi");
let mut b = a.clone();
b.push('!');
println!("{a} {b}");
}
It’s a visual indicator that something different is going on.
There is a second reason to like that. In TypeScript, whether b shares with a depends on the method you used, and a spread copies only one level. In Rust, a deep copy is always written clone, so you can search for your allocations.
Cleanup you can predict
Dropping happens at a known place, the closing brace, rather than whenever a collector runs. If you have used TypeScript's using declarations you already know the feeling, and the difference is that in Rust every value works this way. You do not opt in:
function openFile(): Disposable {
console.log("open");
return { [Symbol.dispose]: () => console.log("closed") };
}
{
using file = openFile();
console.log("reading", file !== null);
}
console.log("after");
struct File;
impl Drop for File {
fn drop(&mut self) {
println!("closed");
}
}
fn main() {
{
let _file = File;
println!("reading");
}
println!("after");
}
open
reading true
closed
after
reading
closed
after
impl Drop belongs to §15.3, so you do not need to follow it yet. What matters is the output. A file handle or a lock can close itself at a point you can read off the page, and that is why Rust programmers rarely write the finally block you would write.
Handing a value over and assuming you still have it. In TypeScript, passing an object costs nothing and changes nothing about who can use it, so you never had to decide. In Rust you decide every time: does this function need to keep the value, or only look at it? A function that stores or consumes a value takes it. A function that only looks borrows it, which is next lesson.
While you are learning, reach for .clone() when you are stuck and move on. It is always safe, and a working program teaches more than an hour spent fighting one error. Then come back later and ask whether each one is needed. That advice is this course's own reading rather than the Book's, and it is common among people who teach Rust.
Step through each program and watch both memory models change. Start with "Assign an object", and do not skip "The trap".
TypeScript, in memory
Rust, in memory
The memory pictures are a model written for this course, simplified to variables and the heap. The verdict at the end of each program is what tsc and rustc really did with it.
Move the counting into a function. A first attempt runs straight into a move, because count_lines takes ownership and main still wants source afterwards:
use std::{env, fs};
fn count_lines(source: String) -> usize {
source.lines().count()
}
fn main() {
let path = env::args().nth(1).expect("usage: anyscan <file>");
let source = fs::read_to_string(&path).expect("could not read the file");
let lines = count_lines(source);
println!("{path}: {lines} lines, {} bytes", source.len());
}
Add a .clone(), which is the fix the compiler itself suggests. It is wasteful, because it copies the whole file just to count its lines. You will remove it in the next lesson, and that is the point.
use std::{env, fs};
fn count_lines(source: String) -> usize {
source.lines().count()
}
fn main() {
let path = env::args().nth(1).expect("usage: anyscan <file>");
let source = fs::read_to_string(&path).expect("could not read the file");
let lines = count_lines(source.clone());
println!("{path}: {lines} lines, {} bytes", source.len());
}
$ cd anyscan
$ cargo run -- sample.ts
Compiling anyscan v0.1.0 (/path/to/anyscan)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.21s
Running `target/debug/anyscan sample.ts`
sample.ts: 6 lines, 232 bytes
Checkpoint: it compiles and prints the same counts as before. You can also explain why path did not need the same treatment: &path borrows it.
After let a = String::from("x"); let b = a;, why does Rust treat a as invalid?
Which line fails to compile? let n = 5; let s = String::from("hi"); show_number(n); show_text(s); println!("{n}"); println!("{s}"); Both functions take their argument by value.
Readonly<T> is not &T
TypeScript's Readonly limits what you can do through one name. A Rust reference promises something about the whole program, and that difference is the borrow checker.
You'll be able to: State the rule that references follow, and predict E0502 and E0499 from where a program's borrows begin and end.
You pass references all day, so the mechanics will feel familiar: & lets a function use a value without taking it, and the caller keeps it afterwards. That part carries over. What does not is what a reference promises. Everything you have learned to expect from an object reference in TypeScript is weaker than this.
What Readonly does not promise
Read-only access in TypeScript restricts one name. It says nothing about anyone else holding the same object:
type User = { name: string };
const user: User = { name: "Ada" };
const view: Readonly<User> = user;
user.name = "Grace";
console.log(view.name);
fn main() {
let mut name = String::from("Ada");
let view = &name;
name.push_str(" Lovelace");
println!("{view}");
}
The TypeScript prints Grace. view could not change the user, but the value changed underneath it anyway, and the types had nothing to say. The Book describes the Rust side in one line:
Users of an immutable reference don’t expect the value to suddenly change out from under them!
So a Rust &T means something much stronger than "I will not change this". It means "nothing will change this while I am holding it". That is a claim about the whole program, and the compiler checks it. Readonly<T> is a restriction on one name, and it is easy to get around:
type User = { name: string };
const view: Readonly<User> = { name: "Ada" };
const writable: User = view;
writable.name = "Grace";
console.log(view.name);
fn main() {
let name = String::from("Ada");
let view: &String = &name;
let writable: &mut String = view;
writable.push_str(" Lovelace");
}
The rule
§4.2 The Rules of References fits in two lines: at any given time you can have either one mutable reference or any number of immutable ones, and references must always be valid. People often shorten the first to aliasing xor mutation. A value can have many names, or it can be changing, but never both at once.
You have written bugs this rule would have caught. Here is one:
const list = [1, 2];
for (const x of list) {
if (x < 3) list.push(x + 10);
}
console.log(list);
fn main() {
let mut list = vec![1, 2];
for x in &list {
if *x < 3 {
list.push(x + 10);
}
}
}
In TypeScript this is merely surprising: the loop picks up the new items. In Rust it would be a memory error, and Lesson 9 shows you why. For now, notice that the rule is not arbitrary fussiness. It catches this whole family of bugs by refusing to let anything change while something else is looking.
A borrow lasts until its last use
Do not read a reference's scope off the braces. §4.2 Mutable References explains that a reference's scope runs from where it is introduced through the last time it is used. Moving one line can be all it takes to fix a borrow error:
fn main() {
let mut s = String::from("hi");
let r = &s;
s.push('!');
println!("{r}");
}
fn main() {
let mut s = String::from("hi");
let r = &s;
println!("{r}");
s.push('!');
println!("{s}");
}
Mutation shows at the call site
Here is a benefit you will come to appreciate. In TypeScript, nothing at a call site tells you whether your array is about to change, and you have to remember that sort mutates while toSorted does not. In Rust the mutation is written at both ends:
fn add_exclamation(text: &mut String) {
text.push('!');
}
fn main() {
let mut greeting = String::from("hi");
add_exclamation(&mut greeting);
println!("{greeting}");
}
What keeps a reference valid
The rule's second line, that references must always be valid, is something your garbage collector has always handled for you. A TypeScript function can return anything it created, and the collector keeps it alive. In Rust, a value is freed at the end of its owner's scope, so this has to be refused:
function greeting(): string[] {
const words = ["hello"];
return words;
}
console.log(greeting());
fn greeting() -> &String {
let words = String::from("hello");
&words
}
fn main() {
println!("{}", greeting());
}
The fix is to return the String itself and move it out, as §4.2 Dangling References shows. The error asks for a lifetime specifier, and you should resist that for now. Adding one will not help here, because the problem is that nothing outlives the function. Lesson 13 explains what lifetimes really are.
Keeping a reference to something while you change it. In TypeScript it is normal to hold onto an element of a list, or a field of an object, and then update the container. In Rust that is the most common borrow error there is, and the instinct to fight it with clone or a clever annotation usually misses the point. Try these first, in this order:
- Reorder the lines, so the read finishes before the write starts.
- Copy out what you need, if it is
Copy, rather than holding a reference to it. - Split the work into two steps: work out what to change, then change it.
When a borrow error feels impossible to fix, the design is often telling you that two parts of your program have unclear ownership of the same data. That is usually worth listening to, and it is not always true: the borrow checker does reject some programs that would be perfectly safe. This is the course's own reading of a common experience, not a rule.
Predict whether each program compiles, then reveal its borrows. Compare "Finish reading, then write" with "Read, write, read again": it is the same code in a different order.
The bars are a simplified model of where each borrow begins and ends. The page checks that model against the real result for every program as it loads, and fails the course's browser checks if the two ever disagree. The verdicts themselves come from rustc.
Remove last lesson's .clone(). count_lines only looks at the source, so it should borrow it. Add a first naive scan as well, using &mut to add to a running total.
use std::{env, fs};
fn count_lines(source: &str) -> usize {
source.lines().count()
}
fn tally(source: &str, word: &str, total: &mut usize) {
for token in source.split_whitespace() {
if token == word {
*total += 1;
}
}
}
fn main() {
let path = env::args().nth(1).expect("usage: anyscan <file>");
let source = fs::read_to_string(&path).expect("could not read the file");
let mut hatches = 0;
tally(&source, "any", &mut hatches);
tally(&source, "as", &mut hatches);
let lines = count_lines(&source);
println!("{path}: {lines} lines, {hatches} hatches");
}
The parameter type is &str, which you will meet properly next lesson. &source works even though source is a String. Everything main needs afterwards is still there, because the functions only borrowed it.
$ cd anyscan
$ cargo run -- sample.ts
Compiling anyscan v0.1.0 (/path/to/anyscan)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.21s
Running `target/debug/anyscan sample.ts`
sample.ts: 6 lines, 2 hatches
Checkpoint: it reports 2, and sample.ts has four hatches. This scan does not look for ! or @ts-ignore, and it has a subtler problem. Add a line reading const y = x as any; and the total goes up by one when it should go up by two, because splitting on whitespace leaves any; with its semicolon attached. Lesson 13 fixes all of this with a real lexer.
If &mut on a counter feels heavy, you are right: returning a number would be more natural for one count. It earns its place here because one total is being added to across several calls, and Lesson 9 replaces it with something better.
const view: Readonly<User> = user; and let view = &name; look like the same idea. What does the Rust version guarantee that the TypeScript one does not?
This program is rejected with E0502: let mut s = String::from("hi"); let r = &s; s.push('!'); println!("{r}");. What is the smallest change that makes it compile and still prints the string without the !?
héllo🦀 has length 7, 6 or 10
TypeScript has one string type, and its length is a number you have probably never questioned. Rust has two string types, and asks which length you mean.
You'll be able to: Choose between String and &str, use slices as views rather than copies, and cut text safely on UTF-8 boundaries.
Your intuition that a slice is a part of something carries over. Your intuition that it is a new, independent thing does not. And TypeScript's one immutable string type has hidden a decision from you all along: who owns this text, and how is it stored?
Three answers to one question
const s = "héllo🦀";
console.log(s.length);
console.log([...s].length);
console.log(new TextEncoder().encode(s).length);
fn main() {
let s = "héllo🦀";
println!("{}", s.len());
println!("{}", s.chars().count());
}
7
6
10
10
6
JavaScript strings are sequences of UTF-16 code units, so length counts those: seven, because the crab takes two. Rust strings are UTF-8 bytes, so len() is ten: é takes two and the crab takes four. Neither is what a person would call the number of characters, which is six here and gets harder from there. §8.2 Bytes, Scalar Values, and Grapheme Clusters walks through why.
Two string types, because of ownership
Lesson 3's trap comes back here, and with it your first Rust string error:
fn main() {
let name: String = "Ada";
println!("{name}");
}
fn main() {
let name: String = String::from("Ada");
let view: &str = &name;
let literal: &str = "Ada";
println!("{name} {view} {literal}");
}
String | &str | |
|---|---|---|
| What it is | an owned, growable buffer | a view of some text owned elsewhere |
| Can it change? | yes, if mut | no |
| On assignment | moves | copies |
| Typical use | fields, return values, building text | parameters, literals, parts of a string |
| Size on the stack (64-bit) | 24 bytes | 16 bytes |
§4.3 String Slices as Parameters gives the rule you will use most: take &str in parameters. A &String works only with a String, whereas a &str works with both, and Clippy will tell you so.
fn shout(text: &String) -> String {
text.to_uppercase()
}
fn main() {
println!("{}", shout(&String::from("hi")));
}
fn shout(text: &str) -> String {
text.to_uppercase()
}
fn main() {
println!("{}", shout(&String::from("hi")));
println!("{}", shout("hi"));
}
You cannot index a string
This is the one you will try first:
fn main() {
let s = String::from("héllo");
println!("{}", s[0]);
}
It is refused on purpose. A byte offset is not a character, and returning the first byte would give you half of a é further along. Slicing by byte range is allowed, because it is explicit, and it panics if you cut through a character:
const s = "héllo🦀";
console.log(s.slice(0, 2));
console.log(s.slice(0, 6));
fn main() {
let s = "héllo🦀";
println!("{}", &s[0..2]);
}
hé
héllo�
thread 'main' (31153464) panicked at src/main.rs:3:22:
end byte index 2 is not a char boundary; it is inside 'é' (bytes 1..3 of string)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Notice the TypeScript's second line. It cut the crab in half and printed a broken character without any complaint. Rust refuses to produce an invalid string, and s.get(0..2) gives you None instead of a panic if you would rather handle the case. Prefer chars() or char_indices() for walking through text, and remember that chars().nth(i) has to walk from the start each time, unlike indexing into an array.
A slice is a view, not a copy
TypeScript's slice copies. Rust's does not, and this is the lesson's biggest payoff:
const list = [1, 2, 3];
const part = list.slice(1);
list[1] = 99;
console.log(part);
fn main() {
let mut list = [1, 2, 3];
let part = &list[1..];
list[1] = 99;
println!("{part:?}");
}
That is more than a safety feature. No copying means a slice costs almost nothing, so you can pass parts of a large file around freely. It also solves the problem §4.3 opens with: a stored index silently goes stale when its string changes, whereas a slice is tied to the string, so the compiler rejects the change. Here is the Book's first_word:
fn first_word(s: &str) -> &str {
Its result borrows from its input, so holding on to the first word keeps the whole string immutable. When you build the lexer in Lesson 13, this is what will make anyscan fast: its tokens will be views of the source and will copy nothing.
Treating a position in a string as a character. s[i], s.length, and loops over indices all assume every character has the same width. They were never quite right in TypeScript either, and the crab above shows it. In Rust, iterate with chars() or char_indices(), and treat len() as a byte count.
Reaching for String everywhere. .to_string() on every value will make the compiler stop complaining, and it copies text each time. Take &str in parameters, and own text only where something really has to keep it.
Type anything into the box. Then drag the slice point across the bytes of héllo🦀 and see which positions a Rust string can be cut at. Try the combining accent and the flag: they are where even chars() stops matching what a reader sees.
&s[0..n]. Only some positions are places a Rust string can be cut.This lab measures your text directly: the UTF-8 bytes from TextEncoder, and the UTF-16 units from JavaScript's own string. Rust's standard library has no grapheme clusters. For those you would add a crate, and in JavaScript Intl.Segmenter gives you the last row.
anyscan should report where it found something. Give sample.ts a line that makes bytes and characters disagree:
// A small file with every escape hatch in it.
const config = JSON.parse(raw) as Config;
const name: any = config.name;
const naïve: any = "🦀";
// @ts-ignore
const port = config.ports![0];
console.log("any of these words is fine in a string", name, port);
Then replace src/main.rs. match_indices gives byte offsets, so line_and_column slices the text before each match and counts from there. Because it only borrows, none of this copies the file.
use std::{env, fs};
/// Line and column of a byte offset, both counted from 1.
fn line_and_column(source: &str, offset: usize) -> (usize, usize) {
let before = &source[..offset];
let line = before.matches('\n').count() + 1;
let this_line = before.rsplit('\n').next().unwrap_or(before);
(line, this_line.chars().count() + 1)
}
fn main() {
let path = env::args().nth(1).expect("usage: anyscan <file>");
let source = fs::read_to_string(&path).expect("could not read the file");
for (offset, word) in source.match_indices("any") {
let (line, column) = line_and_column(&source, offset);
println!("{path}:{line}:{column} {word}");
}
}
$ cd anyscan
$ cargo run -- sample.ts
Compiling anyscan v0.1.0 (/path/to/anyscan)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.21s
Running `target/debug/anyscan sample.ts`
sample.ts:3:13 any
sample.ts:4:14 any
sample.ts:7:14 any
Checkpoint: line 4 reports the character column, even with a two-byte ï before it. It also reports any inside the string on the last line, which is wrong. Columns are a choice, by the way: this counts Unicode scalar values, and editors and tsc have their own conventions.
Why does let name: String = "Ada"; fail to compile?
A function needs to read a user's name, and it will be called with both string literals and String values. Which signature would an experienced Rust programmer write?
Classes dissolve
A TypeScript class bundles data and behaviour into one thing with one shape. Rust takes it apart, and then asks about ownership at every method.
You'll be able to: Split a TypeScript class into a struct and impl, and choose self, &self or &mut self for each method.
A lot carries over. Field init shorthand is the same as in an object literal. Methods read the way you expect. A struct looks like a type declaration with a keyword change, so you will write correct-looking code straight away. The two surprises are that a matching shape no longer means a matching type, and that every method now says what it does to ownership.
A class, taken apart
Here is the Book's Rectangle, from Listing 5-13, beside the class you would write:
class Rectangle {
constructor(
public width: number,
public height: number,
) {}
area() {
return this.width * this.height;
}
}
const rect = new Rectangle(30, 50);
console.log(rect.area());
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn area(&self) -> u32 {
self.width * self.height
}
}
fn main() {
let rect = Rectangle {
width: 30,
height: 50,
};
println!("{}", rect.area());
}
The struct says what the data is. The impl says what it can do, and you can have several of them, as §5.3 Multiple impl Blocks shows. That separation will matter in Lesson 12, when a type you did not write can gain behaviour it did not start with.
There is no constructor either. A struct literal has to set every field, and new is only a naming convention for an associated function, which §5.3 Associated Functions describes. So instead of overloading one constructor you write as many named ones as you need, such as the Book's Rectangle::square.
The receiver is the method's ownership decision
In TypeScript, this is always the same thing. In Rust, the first parameter states what the method does with the value:
| Receiver | What it does | What it means for the caller |
|---|---|---|
&self | reads | can keep using it, and a plain let is enough |
&mut self | changes | needs let mut, and nothing else can be looking during the call |
self | takes ownership | the value is gone afterwards |
The last one has no TypeScript equivalent, and it is one of Rust's quieter powers. A method that takes self can use up its value, which lets a type enforce that something happens only once:
struct Upload {
bytes: usize,
}
impl Upload {
fn finish(self) -> usize {
self.bytes
}
}
fn main() {
let upload = Upload { bytes: 10 };
println!("{}", upload.finish());
println!("{}", upload.finish());
}
In TypeScript you would check a flag at run time and throw. Here, the second call does not compile. Lesson 19 takes this idea much further.
A matching shape is not a matching type
This is the deepest difference in this lesson. TypeScript asks what shape a value has, and Rust asks what it was declared to be:
class Meters {
constructor(public value: number) {}
}
class Seconds {
constructor(public value: number) {}
}
function pace(distance: Meters) {
return distance.value;
}
console.log(pace(new Seconds(5)));
struct Meters(f64);
struct Seconds(f64);
fn pace(distance: Meters) -> f64 {
distance.0
}
fn main() {
println!("{}", pace(Seconds(5.0)));
}
If you wrote C#, this will feel like coming home, since types match by name there too. Those are tuple structs, from §5.1 Creating Different Types with Tuple Structs. If you have ever written a branded type in TypeScript, such as string & { __brand: "UserId" }, you were working around this. In Rust, a one-field struct gets you the same thing for free, and Rust programmers use it constantly.
The cost is real, and worth saying plainly. You lose the convenience of defining a shape on the fly, and of passing anything that fits. There is no Pick<User, "id"> here. If a function needs two fields, you pass the two fields, or define a type that means something. Most of the time that turns out to be clearer, and sometimes you will miss what you had.
Struct update syntax is not a spread
.. looks exactly like ..., and it moves:
type User = { name: string; email: string };
const a: User = { name: "Ada", email: "a@example.com" };
const b: User = { ...a, email: "b@example.com" };
console.log(a.name, b.name);
struct User {
name: String,
email: String,
}
fn main() {
let a = User {
name: String::from("Ada"),
email: String::from("a@example.com"),
};
let b = User {
email: String::from("b@example.com"),
..a
};
println!("{} {}", a.name, b.name);
}
§5.1 Creating Instances with Struct Update Syntax spells this out. name moved into b, and a.name is gone. a.email is still usable, because that field was not taken. Rust tracks this field by field.
console.log needs permission
You can print any object in TypeScript. In Rust, a type prints only if it says how:
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect = Rectangle {
width: 30,
height: 50,
};
println!("{rect:?}");
}
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
fn main() {
let rect = Rectangle {
width: 30,
height: 50,
};
println!("{rect:?}");
println!("{}", rect.width * rect.height);
}
Rectangle { width: 30, height: 50 }
1500
This is your first sight of a derive, which §5.2 Adding Functionality with Derived Traits introduces. You will put #[derive(Debug)] on nearly every type you write.
Writing a class by reflex, with private fields and accessors. In TypeScript, a class with a getter for each field is often just good manners. In Rust, a plain struct with public fields is normal when any combination of values is valid. Reach for private fields and methods when there is a rule to protect, the way §5.3 Method Syntax describes getters: they are useful because they let you make a field private but the method public.
Choosing a receiver by habit. The default is &self. Use &mut self if the method changes something, and use self only if it transforms or finishes the value.
Each of the three receivers meets three ways of calling it. Predict which ones compile. The grid of results is the ownership table above, proven by the compiler.
Nine real programs, each compiled when this course is built. The method bodies barely matter here. The receiver decides nearly everything.
Give what you find a type. word is an owned String for now, and that costs a copy per finding. Lesson 13 removes it.
use std::{env, fs};
#[derive(Debug)]
struct Finding {
line: usize,
column: usize,
word: String,
}
impl Finding {
fn locate(source: &str, offset: usize, word: &str) -> Finding {
let before = &source[..offset];
let this_line = before.rsplit('\n').next().unwrap_or(before);
Finding {
line: before.matches('\n').count() + 1,
column: this_line.chars().count() + 1,
word: word.to_string(),
}
}
fn describe(&self, path: &str) -> String {
format!("{path}:{}:{} {}", self.line, self.column, self.word)
}
}
fn main() {
let path = env::args().nth(1).expect("usage: anyscan <file>");
let source = fs::read_to_string(&path).expect("could not read the file");
for (offset, word) in source.match_indices("any") {
let finding = Finding::locate(&source, offset, word);
println!("{}", finding.describe(&path));
}
}
$ cd anyscan
$ cargo run -- sample.ts
Compiling anyscan v0.1.0 (/path/to/anyscan)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.21s
Running `target/debug/anyscan sample.ts`
sample.ts:3:13 any
sample.ts:4:14 any
sample.ts:7:14 any
Checkpoint: the same output as last lesson, from better-organised code. Notice that locate has no self, which makes it an associated function, and it is called with ::. describe only reads, so it takes &self.
Two Rust structs have identical fields: struct Meters(f64); and struct Seconds(f64);. A function takes Meters. What happens if you pass it a Seconds?
You are writing a Connection type. send writes bytes and updates a counter, is_open reports its state, and close shuts it down, after which it must never be used again. Which receivers fit?
Your discriminated unions, enforced
You already model data this way, and you know why it is good. Rust makes it the language's central idea, and closes each gap you have been working around.
You'll be able to: Translate a discriminated union into an enum with exhaustive match, and replace null with Option.
This is where your TypeScript pays off most. If you write discriminated unions and narrow them with switch, you already think in the way this chapter teaches. The Book spends time persuading its readers that enums holding data are a good idea. You do not need persuading. Read this lesson for what gets better, and for the one thing you give up.
The same model, without the tag field
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rect"; width: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return 3.14 * shape.radius ** 2;
case "rect":
return shape.width * shape.height;
}
}
console.log(area({ kind: "rect", width: 2, height: 3 }));
enum Shape {
Circle { radius: f64 },
Rect { width: f64, height: f64 },
}
fn area(shape: &Shape) -> f64 {
match shape {
Shape::Circle { radius } => 3.14 * radius * radius,
Shape::Rect { width, height } => width * height,
}
}
fn main() {
let circle = Shape::Circle { radius: 1.0 };
let rect = Shape::Rect {
width: 2.0,
height: 3.0,
};
println!("{} {}", area(&circle), area(&rect));
}
The shape of the idea is the same. The narrowing you are used to happens in the pattern, which pulls the fields out by name. There is no kind field to misspell, because the variant is not a string, and the fields of a variant cannot be reached without matching on it first.
Exhaustiveness is no longer a trick
In TypeScript you probably have an assertNever somewhere, because switch will not always tell you about a missing case. The return type caught it above, but only because the function returns a value. Take that away, and it does not:
type Status = "ok" | "late" | "lost";
function report(s: Status) {
switch (s) {
case "ok":
console.log("fine");
break;
case "late":
console.log("delayed");
break;
}
}
report("lost");
console.log("done");
enum Status {
Ok,
Late,
Lost,
}
fn report(s: Status) {
match s {
Status::Ok => println!("fine"),
Status::Late => println!("delayed"),
}
}
fn main() {
report(Status::Lost);
println!("done");
}
error[E0004]: non-exhaustive patterns: `Status::Lost` not covered
--> src/main.rs:8:11
|
8 | match s {
| ^ pattern `Status::Lost` not covered
|
note: `Status` defined here
--> src/main.rs:1:6
|
1 | enum Status {
| ^^^^^^
...
4 | Lost,
| ---- not covered
= note: the matched value is of type `Status`
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 ~ Status::Late => println!("delayed"),
11 ~ Status::Lost => todo!(),
|
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0004`.
That is §6.2 Matches Are Exhaustive. The practical result is one of Rust's great comforts: add a variant to an enum, and the compiler walks you to every match that needs updating.
A warning comes with it. §6.2 Catch-All Patterns and the _ Placeholder shows _, which works like default:. It also switches this off for anything it catches, so a new variant slides in silently. Prefer naming every variant unless you truly mean "and anything else, forever".
null becomes a value you have to open
Strict TypeScript already does well here: string | undefined makes you check. Option is the same idea, built from an ordinary enum, as §6.1 The Option Enum explains, where the Book quotes its inventor on what null cost:
I call it my billion-dollar mistake.
function firstWord(text: string): string | undefined {
return text.split(" ")[0];
}
const word = firstWord("hello world");
console.log(word.toUpperCase());
fn first_word(text: &str) -> Option<&str> {
text.split(' ').next()
}
fn main() {
let word = first_word("hello world");
println!("{}", word.to_uppercase());
}
So far, it is a tie. Rust's advantage is that there is no way out. TypeScript's !, as and any all let undefined through, and they are the hatches anyscan exists to find. Rust's nearest things are .unwrap() and .expect(), and those check at run time rather than hiding the problem.
Here are the forms you will use most. They replace ?. and ??, and they are worth learning as a set:
| TypeScript | Rust | When |
|---|---|---|
a ?? b | a.unwrap_or(b) | use a default |
a?.length | a.map(str::len) | transform if present |
if (a !== undefined) | if let Some(a) = a | do something if present |
if (a === undefined) return | let Some(a) = a else { return }; | leave early, and carry on with the value |
The last two rows are from §6.3. The last row is worth noticing: it is the guard clause you already write, and afterwards a is known to be present for the rest of the function.
What you give up
Here is the honest part. TypeScript's unions are free to invent. string | number needs no declaration, and any function can return one on the spot. In Rust, every union has to be a named enum, declared somewhere. You also cannot take a subset of one: there is no equivalent of Exclude<Shape, Circle>, and a variant is not a type of its own. If a function takes only rectangles, you pass a Rect struct, and the enum wraps it.
That is more typing, and for quick work it can feel heavy. What you get back is that every union in your program has a name and a home, and that its variants can hold anything, with no tag convention to agree on.
Modelling states with strings and flags. status: String, or is_loaded: bool beside data: Option<T>, are TypeScript habits that make illegal combinations possible. A Rust enum makes them impossible to write: each variant holds exactly the data that state has. Whenever you write a bool or a string that selects behaviour, ask if it should be a variant.
_ => by reflex. It feels tidy, and it gives up the check that makes enums worth having.
Choose each type to see its size. The surprise is Option: on some types it costs nothing at all. In TypeScript you could not have asked this question.
Each size is printed by a real program that runs when this course is built. Rust does not guarantee most of these layouts, and they could change between compiler versions. The exception is that Option of a Box or a reference is guaranteed to be the size of a pointer.
Replace strings with an enum of what anyscan looks for. match turns each variant into its text, and because it is exhaustive, adding a fifth hatch later will make the compiler walk you here.
use std::{env, fs};
#[derive(Debug, Clone, Copy)]
enum Hatch {
Any,
As,
NonNull,
TsIgnore,
}
impl Hatch {
const ALL: [Hatch; 4] = [Hatch::Any, Hatch::As, Hatch::NonNull, Hatch::TsIgnore];
fn needle(self) -> &'static str {
match self {
Hatch::Any => ": any",
Hatch::As => " as ",
Hatch::NonNull => "!.",
Hatch::TsIgnore => "@ts-ignore",
}
}
}
#[derive(Debug)]
struct Finding {
line: usize,
column: usize,
hatch: Hatch,
}
impl Finding {
fn locate(source: &str, offset: usize, hatch: Hatch) -> Finding {
let before = &source[..offset];
let this_line = before.rsplit('\n').next().unwrap_or(before);
Finding {
line: before.matches('\n').count() + 1,
column: this_line.chars().count() + 1,
hatch,
}
}
}
fn main() {
let path = env::args().nth(1).expect("usage: anyscan <file>");
let source = fs::read_to_string(&path).expect("could not read the file");
for hatch in Hatch::ALL {
for (offset, _) in source.match_indices(hatch.needle()) {
let finding = Finding::locate(&source, offset, hatch);
println!(
"{path}:{}:{} {:?}",
finding.line, finding.column, finding.hatch
);
}
}
}
&'static str means a string literal, and Lesson 13 explains that 'static. Hatch derives Copy because it has no data needing cleanup, so self here copies it and takes nothing away.
$ cd anyscan
$ cargo run -- sample.ts
Compiling anyscan v0.1.0 (/path/to/anyscan)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.21s
Running `target/debug/anyscan sample.ts`
sample.ts:3:11 Any
sample.ts:4:12 Any
sample.ts:2:31 As
sample.ts:5:4 TsIgnore
Checkpoint: Any, As and TsIgnore appear by name, grouped by kind, and NonNull does not appear at all. The needles are crude, and !. cannot match ports![0] in sample.ts. That is what the lexer in Lesson 13 is for.
You add a Refunded variant to an enum that is matched in twelve places. Eleven of those list every variant, and one ends with _ =>. What does the compiler do?
getUser(id) returns User | undefined in TypeScript, and you handle the missing case by returning early. What is the closest Rust, if get_user returns Option<User>?
Modules are declared, not discovered
In TypeScript, a file is a module and an import is a path to it. In Rust, a file means nothing until something declares it, and everything is private until you say otherwise.
You'll be able to: Lay out a crate's module tree across files, and decide what each path can reach.
The goal is the same as in TypeScript: code grouped into named units, exposing only what others need. What differs is where the structure comes from. In TypeScript the module graph emerges from the files and their imports. In Rust the module tree is something you write down, starting from one root file, and use has less to do with loading code than you would guess.
A file that nothing declares does not exist
This surprises nearly everyone from TypeScript. Create a file full of nonsense, and watch what the compiler thinks of it:
$ cargo new --vcs none tree
Creating binary (application) `tree` package
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
$ cd tree
this is not rust at all, and the compiler will not care
$ cargo build
Compiling tree v0.1.0 (/path/to/tree)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.21s
It builds. Nothing refers to oops.rs, so rustc never opened it. The compiler starts at src/main.rs and follows mod declarations from there, and a single line changes the answer:
mod oops;
fn main() {
println!("hello");
}
$ cargo build
Compiling tree v0.1.0 (/path/to/tree)
error: expected one of `!` or `::`, found `is`
--> src/oops.rs:1:6
|
1 | this is not rust at all, and the compiler will not care
| ^^ expected one of `!` or `::`
error: could not compile `tree` (bin "tree") due to 1 previous error
So mod oops; is what brings a file into your program, and it is the opposite of what you are used to. In TypeScript, the file is the module and an import makes a dependency on it. In Rust, the mod line creates the module and use only shortens a path. Any file can be imported in TypeScript, and in Rust there is one declaration of each module, in its parent.
| What you want | TypeScript | Rust |
|---|---|---|
| Make this code part of the program | automatic, once imported | mod name; in the parent, once |
| Refer to something | import { x } from "./a/b" | crate::a::b::x, a path that always works |
| Shorten that | the import itself | use crate::a::b::x; |
| Make something available | export | pub |
| Re-export | export * from or a barrel file | pub use |
Private until proven public
TypeScript's rule is that a file's contents are private unless exported. Rust's looks the same and goes further, because pub has to be true at every step of the path. The §7.2 Modules Cheat Sheet is the reference to keep open:
mod kitchen {
mod stove {
pub fn light() {
println!("lit");
}
}
}
fn main() {
kitchen::stove::light();
}
Privacy runs in one direction as well. Code can see everything in its ancestors, and a parent cannot see into its child's private items. §7.3 Exposing Paths with the pub Keyword walks through a similar example. If you test in a child module, as Lesson 15 will, this is why tests can reach private functions without any special access.
Two more rules from §7.3 Making Structs and Enums Public are worth remembering. A pub struct keeps its fields private unless each one says pub, and a pub enum makes all of its variants public.
Where the files go
§7.5 gives the layout. mod hatch; in main.rs looks for src/hatch.rs, and a mod rules; inside that looks in src/hatch/rules.rs. Nothing else about the file's location matters, and files do not import each other by relative path. Within the crate you use crate::hatch::Hatch, or super:: to go up a level.
Idiom matters here, and §7.4 Creating Idiomatic use Paths describes it: bring a function's parent module into scope and call hosting::add_to_waitlist(), so a reader can see it is not local. For structs and enums, bring in the item itself, as in use std::collections::HashMap;.
One file per thing, with a barrel in front. In TypeScript, you may keep each class in its own file, plus an index.ts to tidy the exports. Rust files tend to be larger, and it is normal to keep a type, its implementation and its tests in one file. pub use does what a barrel does, and you should use it to shape a library's public API rather than as routine. This is the course's own reading of common practice, and projects vary.
Marking everything pub. Without export, your code would not compile in TypeScript, so exporting is a reflex. In Rust, code in the same module and in child modules already has access. Start private, and add pub when the compiler asks for it.
Every call below runs inside the same small tree. Predict whether it compiles, and watch which step of the path decides it.
Each program is the same tree with one call added, compiled when this course is built. The page also carries a small model of the privacy rule, checked against all of those real results as it loads.
Split anyscan into modules. Each file below replaces or creates one in src/. Note what pub each thing needs, and try removing one to see the error.
#[derive(Debug, Clone, Copy)]
pub enum Hatch {
Any,
As,
NonNull,
TsIgnore,
}
impl Hatch {
pub const ALL: [Hatch; 4] = [Hatch::Any, Hatch::As, Hatch::NonNull, Hatch::TsIgnore];
pub fn needle(self) -> &'static str {
match self {
Hatch::Any => ": any",
Hatch::As => " as ",
Hatch::NonNull => "!.",
Hatch::TsIgnore => "@ts-ignore",
}
}
}
use crate::hatch::Hatch;
#[derive(Debug)]
pub struct Finding {
pub line: usize,
pub column: usize,
pub hatch: Hatch,
}
impl Finding {
pub fn locate(source: &str, offset: usize, hatch: Hatch) -> Finding {
let before = &source[..offset];
let this_line = before.rsplit('\n').next().unwrap_or(before);
Finding {
line: before.matches('\n').count() + 1,
column: this_line.chars().count() + 1,
hatch,
}
}
}
mod finding;
mod hatch;
use finding::Finding;
use hatch::Hatch;
use std::{env, fs};
fn main() {
let path = env::args().nth(1).expect("usage: anyscan <file>");
let source = fs::read_to_string(&path).expect("could not read the file");
for hatch in Hatch::ALL {
for (offset, _) in source.match_indices(hatch.needle()) {
let found = Finding::locate(&source, offset, hatch);
println!("{path}:{}:{} {:?}", found.line, found.column, found.hatch);
}
}
}
These blocks are marked as fragments because they only compile together, so the next commands are the check:
$ cd ../anyscan
$ cargo run -- sample.ts
Compiling anyscan v0.1.0 (/path/to/anyscan)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.21s
Running `target/debug/anyscan sample.ts`
sample.ts:3:11 Any
sample.ts:4:12 Any
sample.ts:2:31 As
sample.ts:5:4 TsIgnore
$ cargo clippy
Checking anyscan v0.1.0 (/path/to/anyscan)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.21s
$ cargo fmt --check
Checkpoint: the output has not changed, and the code now has a shape. main.rs declares the modules. finding.rs reaches Hatch by its path from the crate root, not by a relative file path.
You add src/report.rs containing a syntax error, and cargo build still succeeds. Why?
From main, the call kitchen::stove::light() fails with E0603, and light is already pub. stove is declared as mod stove inside mod kitchen. What is the smallest fix?
The Record that should have been a struct
Array, Map and object cover nearly everything in TypeScript. Rust's collections look familiar, with one surprise about order and a real explanation for the borrow rule that puzzled you.
You'll be able to: Choose among Vec, HashMap and a struct, and use get, indexing and the entry API deliberately.
Vec is your array, and HashMap is your Map, and the mapping is close enough that you can start using them today. Three things differ, and all come from ownership or from what the type system can already express: reading can fail in two different ways, insertion takes ownership, and a HashMap has no order at all.
Two ways to read, on purpose
In TypeScript, reading a missing element gives you undefined. Rust makes you choose what you mean. §8.1 Reading Elements of Vectors sets out both:
fn main() {
let mut list = vec![10, 20];
list.push(30);
println!("{:?}", list.get(5));
println!("{}", list[1]);
}
list[5] would panic, as you saw in Lab 1. Use it when an out-of-range index means your program has a bug. get returns an Option, for when a missing element is an ordinary situation.
Why push cannot happen during a borrow
Lesson 4 showed you that changing a list while looping over it does not compile. Here is the real reason, and §8.1 gives it directly: a Vec keeps its elements next to each other in memory, and if the buffer is full, push allocates a bigger one and moves everything.
fn main() {
let mut list = vec![1, 2, 3];
let first = &list[0];
list.push(4);
println!("{first}");
}
Why should adding to the end care about a reference to the start? Because after a reallocation, first would point into freed memory. A TypeScript array can do this safely, because the collector keeps everything alive and list[0] is looked up again. In a language with no collector and direct references, this would be a memory-corruption bug. The lab below shows you exactly when it happens.
Maps: ownership, the entry API and no order
Counting is the classic job, and it shows the differences at once:
const counts = new Map<string, number>();
for (const word of ["any", "as", "any"]) {
counts.set(word, (counts.get(word) ?? 0) + 1);
}
console.log(counts.get("any"));
use std::collections::HashMap;
fn main() {
let mut counts = HashMap::new();
for word in ["any", "as", "any"] {
*counts.entry(word).or_insert(0) += 1;
}
println!("{:?}", counts.get("any"));
}
entry looks up the slot once and hands you a mutable reference to it, as §8.3 Updating a Value Based on the Old Value shows. The * writes through that reference. Reach for this rather than get followed by insert.
Inserting moves your value in, unlike a TypeScript Map, which stores a reference and leaves your variable alone:
use std::collections::HashMap;
fn main() {
let mut scores = HashMap::new();
let name = String::from("Ada");
scores.insert(name, 10);
println!("{name}");
}
The biggest surprise is iteration. A JavaScript Map keeps insertion order, and plain objects mostly do too, so you may have come to rely on it. A HashMap has no order, and §8.3 Accessing Values in a Hash Map says it prints in an arbitrary order. It is not just unspecified, either. Running the same program twice can give two different orders. When this lesson was written, 20 runs of a program printing a five-key map produced 19 different orders. If you need sorted keys, use BTreeMap. Otherwise, collect and sort, as you will do in this lesson's build step.
Strings, one last time
Two things from §8.2 Concatenating with + or format! that will trip you up. + takes ownership of its left side, so it reads badly. Use format! instead, which works like a template literal and takes nothing:
const a = "tic";
const b = "tac";
const line = `${a}-${b}`;
console.log(line, a, b);
fn main() {
let a = String::from("tic");
let b = String::from("tac");
let line = format!("{a}-{b}");
println!("{line} {a} {b}");
}
Using a map when you know the keys. Record<string, string> is a common shape in TypeScript, because objects are cheap and the type system can describe them. The Rust translation, HashMap<String, String>, loses everything: a missing key becomes a run-time question, and a typo in a key compiles. If the keys are known when you write the code, it is a struct. Use a map when the keys are data.
use std::collections::HashMap;
fn main() {
let mut config = HashMap::new();
config.insert("host", "localhost");
config.insert("port", "8080");
println!("{}", config["prot"]);
}
struct Config {
host: String,
port: u16,
}
fn main() {
let config = Config {
host: String::from("localhost"),
port: 8080,
};
println!("{} {}", config.host, config.prot);
}
Push items into a real vector, and watch the capacity. Use "Next reallocation" to jump between the moments when the whole buffer moves.
These capacities came from a real program, printed after every push. Rust documents that a Vec grows and does not promise this exact sequence, so treat the numbers as an observation about this compiler and this element size.
Replace the running total you added in Lesson 4 with a real tally, using the entry API. A HashMap has no order, and a tool's output should be repeatable, so the report sorts before printing. For that, Hatch has to be hashable and sortable, and deriving makes it so.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Hatch {
Any,
As,
NonNull,
TsIgnore,
}
impl Hatch {
pub const ALL: [Hatch; 4] = [Hatch::Any, Hatch::As, Hatch::NonNull, Hatch::TsIgnore];
pub fn needle(self) -> &'static str {
match self {
Hatch::Any => ": any",
Hatch::As => " as ",
Hatch::NonNull => "!.",
Hatch::TsIgnore => "@ts-ignore",
}
}
}
mod finding;
mod hatch;
use finding::Finding;
use hatch::Hatch;
use std::collections::HashMap;
use std::{env, fs};
fn main() {
let path = env::args().nth(1).expect("usage: anyscan <file>");
let source = fs::read_to_string(&path).expect("could not read the file");
let mut findings = Vec::new();
for hatch in Hatch::ALL {
for (offset, _) in source.match_indices(hatch.needle()) {
findings.push(Finding::locate(&source, offset, hatch));
}
}
findings.sort_by_key(|found| (found.line, found.column));
let mut tally = HashMap::new();
for found in &findings {
println!("{path}:{}:{} {:?}", found.line, found.column, found.hatch);
*tally.entry(found.hatch).or_insert(0) += 1;
}
let mut totals: Vec<_> = tally.into_iter().collect();
totals.sort();
println!("{totals:?}");
}
|found| (found.line, found.column) is a closure, which Lesson 14 covers. Vec<_> asks the compiler to work out the element type.
$ cd anyscan
$ cargo run -- sample.ts
Compiling anyscan v0.1.0 (/path/to/anyscan)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.21s
Running `target/debug/anyscan sample.ts`
sample.ts:2:31 As
sample.ts:3:11 Any
sample.ts:4:12 Any
sample.ts:5:4 TsIgnore
[(Any, 2), (As, 1), (TsIgnore, 1)]
$ cargo clippy
Checking anyscan v0.1.0 (/path/to/anyscan)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.21s
Checkpoint: findings now appear in file order, followed by the totals. Run it a few times: the output never changes, because of the sort. Remove totals.sort() and run it a few more times to see the HashMap shuffle.
Why does let first = &list[0]; list.push(4); println!("{first}"); fail to compile, when adding to the end should not affect the start?
You need to store settings with known names such as host and port, and you are about to write HashMap<String, String>. What should an experienced Rust programmer do?
Errors are values
In TypeScript, a function's type says nothing about how it can fail. In Rust, failure is in the signature, and the path it takes is written in the code.
You'll be able to: Propagate errors with ? and From, and decide between Result and panic!.
The Book's chapter introduction is direct about what you are leaving behind:
Rust doesn’t have exceptions.
If you have used a Result type in TypeScript, or returned { ok, value } objects to avoid throw, you already know why this is good, and you know how awkward it is without language support. Rust's Result is the same idea, with the support. Your intuition about what should be an error carries over. Your intuition about how it gets to the caller does not.
What a signature tells you
TypeScript has no way to say what a function throws. catch (e) gets an unknown for a reason:
function readPort(text: string): number {
const port = Number(text);
if (Number.isNaN(port)) throw new Error(`not a number: ${text}`);
return port;
}
function loadConfig(text: string): number {
return readPort(text) + 1;
}
try {
console.log(loadConfig("abc"));
} catch (e) {
console.log("failed:", (e as Error).message);
}
use std::num::ParseIntError;
fn read_port(text: &str) -> Result<u16, ParseIntError> {
text.parse()
}
fn load_config(text: &str) -> Result<u16, ParseIntError> {
let port = read_port(text)?;
Ok(port + 1)
}
fn main() {
match load_config("abc") {
Ok(port) => println!("{port}"),
Err(e) => println!("failed: {e}"),
}
}
failed: not a number: abc
failed: invalid digit found in string
Look at loadConfig. Nothing in it, or in its type, says that it can fail, and it does. A reader has to know what every call beneath it might throw. In the Rust, load_config's signature says it, and its body shows the exact line: the ?.
? is the whole feature
§9.2 The ? Operator Shortcut explains it. If the value is Ok, ? unwraps it. If it is Err, ? returns from the function right there, converting the error on the way. That makes it close to an await: one small mark that says something can happen here, and it makes error paths visible without making them long.
? also converts. If a function returns your error type, ? will call From to convert the underlying error into it. That lets one function use several fallible operations:
use std::num::ParseIntError;
enum LoadError {
Missing,
BadPort(ParseIntError),
}
impl From<ParseIntError> for LoadError {
fn from(error: ParseIntError) -> LoadError {
LoadError::BadPort(error)
}
}
fn load(text: Option<&str>) -> Result<u16, LoadError> {
let text = text.ok_or(LoadError::Missing)?;
let port: u16 = text.parse()?;
Ok(port)
}
fn main() {
for input in [Some("8080"), None, Some("abc")] {
match load(input) {
Ok(port) => println!("port {port}"),
Err(LoadError::Missing) => println!("no port given"),
Err(LoadError::BadPort(error)) => println!("bad port: {error}"),
}
}
}
port 8080
no port given
bad port: invalid digit found in string
Look at the match in main. Your caller can handle every failure by name, and exhaustively, which you cannot do with a caught unknown. The variants are the documentation of how this function fails. impl From is a trait implementation, which you will meet properly in Lesson 12.
In real projects, two crates take away most of the boilerplate. thiserror derives this kind of error type for libraries, and anyhow gives applications a catch-all with context. The Book does not cover them, and this is the course's own reading of common practice. Writing your error type by hand once, as you will below, is the best way to understand what they generate.
A panic is not a throw
The new instinct to build is that these are two different mechanisms, not two spellings of one. A Result is for failures your caller could reasonably handle: a missing file, or bad input. A panic is for bugs, where the program's assumptions no longer hold. §9.3 Guidelines for Error Handling sets out the distinction, and it is the opposite of a common JavaScript habit, where throw covers both.
So you do not catch panics as routine error handling. If you find yourself wanting to, the function should have returned a Result. And .unwrap() deserves suspicion for the same reason as as in TypeScript: it asserts something the compiler could not check. .expect("why this cannot fail") is the better version, because it records your reasoning, and §9.2 Shortcuts for Panic on Error says most Rustaceans prefer it.
Catching far away. A single try around a large block is a common TypeScript shape, and it loses track of what failed. In Rust, handle an error where you can do something useful about it, and pass it up with ? everywhere else. main can return a Result as well.
.unwrap() while you figure things out. It is fine in examples and tests, as §9.3 Examples, Prototype Code, and Tests says. In production code, each one is a crash you have decided to allow. Search your code for them now and then.
Choose an input and follow the failure through both programs. Then try the last two inputs, where the languages disagree about whether there is a failure at all. Each one is a real TypeScript gotcha.
TypeScript: where the failure goes
Rust: where the failure goes
Both programs were run with each input, and the outputs here are what they printed. The route through the functions is drawn from reading the code above.
Now the expect calls you have been carrying since Lesson 1 can go. run returns a Result, every fallible step uses ?, and main decides what to tell the user. That covers both failures, a missing argument and a file that cannot be read, and neither one is a bug in anyscan.
use std::io;
#[derive(Debug)]
pub enum ScanError {
Usage,
Read(io::Error),
}
impl From<io::Error> for ScanError {
fn from(error: io::Error) -> ScanError {
ScanError::Read(error)
}
}
mod error;
mod finding;
mod hatch;
use error::ScanError;
use finding::Finding;
use hatch::Hatch;
use std::collections::HashMap;
use std::process::ExitCode;
use std::{env, fs};
fn run() -> Result<(), ScanError> {
let path = env::args().nth(1).ok_or(ScanError::Usage)?;
let source = fs::read_to_string(&path)?;
let mut findings = Vec::new();
for hatch in Hatch::ALL {
for (offset, _) in source.match_indices(hatch.needle()) {
findings.push(Finding::locate(&source, offset, hatch));
}
}
findings.sort_by_key(|found| (found.line, found.column));
let mut tally = HashMap::new();
for found in &findings {
println!("{path}:{}:{} {:?}", found.line, found.column, found.hatch);
*tally.entry(found.hatch).or_insert(0) += 1;
}
let mut totals: Vec<_> = tally.into_iter().collect();
totals.sort();
println!("{totals:?}");
Ok(())
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(ScanError::Usage) => {
eprintln!("usage: anyscan <file>");
ExitCode::FAILURE
}
Err(ScanError::Read(error)) => {
eprintln!("could not read the file: {error}");
ExitCode::FAILURE
}
}
}
$ cd anyscan
$ cargo run --quiet -- sample.ts
sample.ts:2:31 As
sample.ts:3:11 Any
sample.ts:4:12 Any
sample.ts:5:4 TsIgnore
[(Any, 2), (As, 1), (TsIgnore, 1)]
$ cargo run --quiet
usage: anyscan <file>
$ cargo run --quiet -- missing.ts
could not read the file: No such file or directory (os error 2)
$ cargo clippy --quiet
Checkpoint: a good run works as before. Both failures now print a real message and exit with an error status, with no panic. ExitCode is how main reports that. The match in main also has to name every variant, so a new kind of failure cannot go unreported. Lesson 12 moves those messages to where they belong, once you have met traits.
What does the ? do in let port = read_port(text)?;?
A library function parses a config file that users edit by hand. Separately, it looks up an entry in a table that it built itself and that must contain the entry unless the library has a bug. How should each handle failure?
Generics that survive compilation
The angle brackets look the same and do something else. TypeScript's generics are gone by run time. Rust's generate the program.
You'll be able to: Explain erasure versus monomorphisation, write a bounded generic function, and say what Rust's generics cannot do that TypeScript's can.
You will read generic Rust fluently right away. Vec<T>, Option<T> and fn first<T>(list: &[T]) mean what they look like, inference works, and you already think in type parameters. Two things are different underneath. A Rust generic can do nothing with a T unless a bound says it can, and each use of it generates real code.
A T can do nothing until you say so
Here is the Book's largest, and its TypeScript twin. They do not get the same reception:
function largest<T>(list: T[]): T {
let best = list[0];
for (const item of list) {
if (item > best) best = item;
}
return best;
}
console.log(largest([1, 5, 2]));
console.log(largest([{ n: 1 }, { n: 2 }]));
fn largest<T>(list: &[T]) -> &T {
let mut best = &list[0];
for item in list {
if item > best {
best = item;
}
}
best
}
fn main() {
println!("{}", largest(&[1, 5, 2]));
}
5
{ n: 1 }
TypeScript lets you compare two values of type T, whatever T turns out to be. That is fine for numbers. For objects, > converts both sides to strings, so the answer is meaningless, and the program picks the first item without a word.
Rust refuses until T promises that it can be compared. That promise is a trait bound, and you can see the difference in the fix. TypeScript would ask what shape T has, and Rust asks what T has promised:
function largest<T extends number | string>(list: T[]): T {
let best = list[0];
for (const item of list) {
if (item > best) best = item;
}
return best;
}
console.log(largest([1, 5, 2]));
fn largest<T: PartialOrd>(list: &[T]) -> &T {
let mut best = &list[0];
for item in list {
if item > best {
best = item;
}
}
best
}
fn main() {
println!("{}", largest(&[1, 5, 2]));
println!("{}", largest(&["b", "a"]));
}
What happens to the types
§10.1 Performance of Code Using Generics explains what Rust does here, and you can see the difference on disk. TypeScript's output has one function, with no trace of T:
function largest<T extends number | string>(list: T[]): T {
let best = list[0];
for (const item of list) {
if (item > best) best = item;
}
return best;
}
console.log(largest([1, 5, 2]));
console.log(largest(["b", "a"]));
$ tsc --strict mono.ts
$ grep -c "function largest" mono.js
1
Now the same program in Rust, called with three types, one of them twice:
fn largest<T: PartialOrd>(list: &[T]) -> &T {
let mut best = &list[0];
for item in list {
if item > best {
best = item;
}
}
best
}
fn main() {
println!("{}", largest(&[1, 5, 2]));
println!("{}", largest(&[7, 3]));
println!("{}", largest(&[1.5, 0.5]));
println!("{}", largest(&['a', 'z']));
}
$ rustc mono.rs
$ nm mono | grep -c largest
3
One function in the JavaScript, and three in the binary: one each for i32, f64 and char. The Book calls this monomorphisation, and it is why it can say this:
we pay no runtime cost for using generics
Each copy is compiled knowing its exact type, so a comparison on i32 becomes a plain integer comparison. The cost moves elsewhere: more code to compile, and a larger binary. You pay at build time, not at run time. That is a real tradeoff, and it is one reason a large Rust build can be slow.
What Rust's generics cannot do
If you enjoy TypeScript's type-level programming, this is where you will feel the difference most, so let us be straight about it. TypeScript's type system is, in effect, a small programming language. You can compute one type from another:
type User = { id: number; name: string };
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
const user: Getters<User> = { getId: () => 1, getName: () => "Ada" };
console.log(user.getName());
Rust has no mapped types, no conditional types, no keyof, and no template literal types. Structs cannot be derived from each other in the type system at all. When you want that kind of generated code in Rust, you write a macro, and Lesson 20 shows how. Macros are powerful, and they are a different tool from the one you are used to.
There is a real loss here, particularly Pick, Omit and Partial over one definition. In return, Rust's generics are simpler to read, their errors tend to be shorter, and they generate fast code. Whether that is a good trade depends on what you are building, and reasonable people differ.
Some useful differences
Two other things are worth knowing from the section. The first is the turbofish. In TypeScript you can write f<number>() at a call site, and in Rust the same thing is written ::<>, because a bare < would be ambiguous in an expression:
fn main() {
let port = "8080".parse::<u16>();
let parts = "a b c".split(' ').collect::<Vec<_>>();
println!("{port:?} {parts:?}");
}
The second is that an impl block can be specific. §10.1 In Method Definitions shows impl Point<f32>, a method that exists only for one instantiation. A TypeScript class cannot easily say that a method only exists when T is a certain type.
Reaching for type-level cleverness. A TypeScript programmer's first instinct when something repeats may be a clever generic. In Rust, start with concrete types. Add a type parameter when a second real use appears, and then a bound for each thing you need, and the compiler will tell you which. If you are fighting for a Pick, define the smaller struct, or pass just the fields.
Reading T as erased. Every T is a real type with a known size at compile time. That is why you cannot have a bare Vec<dyn Something>, and why Lesson 12's dyn needs a pointer.
Choose which types to call largest with, and compare what each compiler has to produce. Compare the count on the right with what nm printed above.
The JavaScript tsc emits
The functions rustc generates
This lab applies one rule: one generated function per concrete type. The nm count in the lesson's session is what shows that the rule holds in a real binary, for three types. Optimisation can change what appears in a release build, for instance by inlining.
The sort from Lesson 9 does not depend on what is being counted, so make that explicit. Its bound says what it needs, which is that keys can be ordered.
use std::collections::HashMap;
/// Totals from a tally, sorted by key, because a `HashMap` has no order.
pub fn sorted_totals<K: Ord>(tally: HashMap<K, usize>) -> Vec<(K, usize)> {
let mut totals: Vec<_> = tally.into_iter().collect();
totals.sort();
totals
}
mod error;
mod finding;
mod hatch;
mod report;
use error::ScanError;
use finding::Finding;
use hatch::Hatch;
use std::collections::HashMap;
use std::process::ExitCode;
use std::{env, fs};
fn run() -> Result<(), ScanError> {
let path = env::args().nth(1).ok_or(ScanError::Usage)?;
let source = fs::read_to_string(&path)?;
let mut findings = Vec::new();
for hatch in Hatch::ALL {
for (offset, _) in source.match_indices(hatch.needle()) {
findings.push(Finding::locate(&source, offset, hatch));
}
}
findings.sort_by_key(|found| (found.line, found.column));
let mut tally = HashMap::new();
for found in &findings {
println!("{path}:{}:{} {:?}", found.line, found.column, found.hatch);
*tally.entry(found.hatch).or_insert(0) += 1;
}
println!("{:?}", report::sorted_totals(tally));
Ok(())
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(ScanError::Usage) => {
eprintln!("usage: anyscan <file>");
ExitCode::FAILURE
}
Err(ScanError::Read(error)) => {
eprintln!("could not read the file: {error}");
ExitCode::FAILURE
}
}
}
$ cd anyscan
$ cargo run --quiet -- sample.ts
sample.ts:2:31 As
sample.ts:3:11 Any
sample.ts:4:12 Any
sample.ts:5:4 TsIgnore
[(Any, 2), (As, 1), (TsIgnore, 1)]
$ cargo clippy --quiet
Checkpoint: the output is the same. sorted_totals also works on any other key type you can order. Try removing : Ord to see which line asks for it.
A generic Rust function is called with i32, f64 and i32 again. What does the compiler generate?
In TypeScript you use Partial<Config> for an update function, so you can set any subset of fields. What is the closest idiomatic Rust?
Traits are not interfaces
A trait looks like an interface, and you will use it like one for about a day. Then you find that a type can gain one after it is written, that nothing fits by accident, and that dispatch is a choice.
You'll be able to: Implement traits explicitly, apply the orphan rule, and choose between impl Trait and dyn Trait.
The Book itself says that traits are similar to what other languages call interfaces, and that is the right place to start. Everything you know about programming against a contract rather than a concrete type carries over. Three things are new: you must opt in by name, you can opt in from outside the type, and you choose how each use is dispatched.
Nothing fits by accident
A TypeScript class satisfies an interface by having the right shape, with or without implements. A Rust type satisfies a trait only because someone wrote impl Trait for Type:
interface Speak {
speak(): string;
}
class Duck {
speak() {
return "quack";
}
}
function talk(animal: Speak) {
return animal.speak();
}
console.log(talk(new Duck()));
trait Speak {
fn speak(&self) -> String;
}
struct Duck;
impl Duck {
fn speak(&self) -> String {
String::from("quack")
}
}
fn talk(animal: &impl Speak) -> String {
animal.speak()
}
fn main() {
println!("{}", talk(&Duck));
}
You lose some flexibility. A test double in TypeScript can be any object with the right methods, and in Rust it has to implement the trait. What you gain is that a trait can mean something beyond its signatures. Copy has no methods at all, and it says something important about a type. Two methods with the same name in unrelated traits never get confused. And an interface can no longer be satisfied by coincidence.
Behaviour from outside the type
You have probably wanted to add a method to something you did not write. In TypeScript that means augmenting a global and patching a prototype. Here it is ordinary:
trait Shout {
fn shout(&self) -> String;
}
impl Shout for str {
fn shout(&self) -> String {
self.to_uppercase()
}
}
fn main() {
println!("{}", "hi".shout());
}
Nothing changes for code that has not imported Shout, so this cannot leak the way a prototype patch can. There is one limit, in §10.2 Implementing a Trait on a Type: you need to own the trait or the type. That is the orphan rule, and without it two crates could give the same type conflicting implementations. To get around it, wrap the foreign type in a one-field struct of your own, as Lesson 6 showed.
You choose how dispatch works
In TypeScript every call on an interface is decided at run time. In Rust there are two options, and the difference is one word:
| You write | Decided | Cost | Use it for |
|---|---|---|---|
fn f(x: &impl Speak) | at compile time, one copy per type | none at run time, and larger code | the default |
fn f(x: &dyn Speak) | at run time, through a table of functions | one pointer lookup per call | mixed types in one collection |
&impl Speak is shorthand for the generic form you saw in Lesson 11, as §10.2 Trait Bound Syntax explains. dyn is what your TypeScript has been doing all along, and it matters because of what Lesson 11 said: every T needs a known size. A Vec of different types needs a pointer to each, hence Vec<Box<dyn Speak>>. The Book covers that fully in §18.2, which you will read in Lesson 19.
The traits you have already met
Much of what looked like magic so far was a trait all along:
| What you wrote | The trait behind it | Where it matters |
|---|---|---|
println!("{x}") | Display | you implement this one by hand |
println!("{x:?}") | Debug | you derive it |
let b = a; not moving | Copy | derived, and only for types needing no cleanup |
a > b | PartialOrd | the bound in Lesson 11 |
? converting an error | From | Lesson 10 |
for x in list | IntoIterator | Lesson 14 |
Appendix C lists what #[derive] can generate.
Writing a trait for everything, up front. In TypeScript, an interface costs nothing, and many codebases declare one per class out of habit. In Rust, a trait with one implementation is usually just noise, and generics or concrete types will do. Write the concrete type first, and add a trait when you have a second real implementation or a real need to abstract. That is the course's own reading, and you will see it argued about.
dyn by default. It is what you are used to, and it is rarely necessary. Use impl Trait until you need to store mixed types together.
Eight small programs, shown in both languages where TypeScript has an equivalent. Predict whether rustc accepts each one, and notice which ones go each way.
Every program here is compiled and run in both languages when this course is built. Where only Rust is shown, TypeScript has no meaningful equivalent.
Traits fix two things here. Display gives anyscan proper messages, and it moves them out of main, as Lesson 10 promised. Rule lets a hatch have its own way of being found, which is the fix for ports![0]: a plain text search cannot express "a ! followed by . or [", and a small type can. Now that there are two kinds of rule, a trait has earned its place.
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Hatch {
Any,
As,
NonNull,
TsIgnore,
}
impl fmt::Display for Hatch {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let name = match self {
Hatch::Any => "any",
Hatch::As => "as",
Hatch::NonNull => "non-null !",
Hatch::TsIgnore => "@ts-ignore",
};
write!(f, "{name}")
}
}
use crate::hatch::Hatch;
pub trait Rule {
fn hatch(&self) -> Hatch;
fn find(&self, source: &str) -> Vec<usize>;
}
/// A rule that only has to find some text.
pub struct Needle {
pub hatch: Hatch,
pub text: &'static str,
}
impl Rule for Needle {
fn hatch(&self) -> Hatch {
self.hatch
}
fn find(&self, source: &str) -> Vec<usize> {
source.match_indices(self.text).map(|(at, _)| at).collect()
}
}
/// `!` is only a non-null assertion when something is read through it.
pub struct NonNull;
impl Rule for NonNull {
fn hatch(&self) -> Hatch {
Hatch::NonNull
}
fn find(&self, source: &str) -> Vec<usize> {
source
.match_indices('!')
.map(|(at, _)| at)
.filter(|at| matches!(source[at + 1..].chars().next(), Some('.' | '[')))
.collect()
}
}
use std::{fmt, io};
#[derive(Debug)]
pub enum ScanError {
Usage,
Read(io::Error),
}
impl fmt::Display for ScanError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ScanError::Usage => write!(f, "usage: anyscan <file>"),
ScanError::Read(error) => write!(f, "could not read the file: {error}"),
}
}
}
impl std::error::Error for ScanError {}
impl From<io::Error> for ScanError {
fn from(error: io::Error) -> ScanError {
ScanError::Read(error)
}
}
mod error;
mod finding;
mod hatch;
mod report;
mod rules;
use error::ScanError;
use finding::Finding;
use hatch::Hatch;
use rules::{Needle, NonNull, Rule};
use std::collections::HashMap;
use std::process::ExitCode;
use std::{env, fs};
fn run() -> Result<(), ScanError> {
let path = env::args().nth(1).ok_or(ScanError::Usage)?;
let source = fs::read_to_string(&path)?;
let rules: Vec<Box<dyn Rule>> = vec![
Box::new(Needle {
hatch: Hatch::Any,
text: ": any",
}),
Box::new(Needle {
hatch: Hatch::As,
text: " as ",
}),
Box::new(NonNull),
Box::new(Needle {
hatch: Hatch::TsIgnore,
text: "@ts-ignore",
}),
];
let mut findings = Vec::new();
for rule in &rules {
for offset in rule.find(&source) {
findings.push(Finding::locate(&source, offset, rule.hatch()));
}
}
findings.sort_by_key(|found| (found.line, found.column));
let mut tally = HashMap::new();
for found in &findings {
println!("{path}:{}:{} {}", found.line, found.column, found.hatch);
*tally.entry(found.hatch).or_insert(0) += 1;
}
for (hatch, total) in report::sorted_totals(tally) {
println!("{total} {hatch}");
}
Ok(())
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("anyscan: {error}");
ExitCode::FAILURE
}
}
}
Vec<Box<dyn Rule>> is the mixed collection from the table above. .map and .filter are next-but-one lesson's subject.
$ cd anyscan
$ cargo run --quiet -- sample.ts
sample.ts:2:31 as
sample.ts:3:11 any
sample.ts:4:12 any
sample.ts:5:4 @ts-ignore
sample.ts:6:26 non-null !
2 any
1 as
1 non-null !
1 @ts-ignore
$ cargo run --quiet -- missing.ts
anyscan: could not read the file: No such file or directory (os error 2)
$ cargo clippy --quiet
Checkpoint: ports![0] is now found, so all four hatches appear, with readable names. main shrank, because errors can now describe themselves. Note that the sample's any in a string is still not reported, only because that string happens not to contain : any. The next lesson fixes that properly.
What does the orphan rule stop you doing?
You have fn describe(item: &impl Speak), and you now want a list holding a Duck and a Dog to loop over. What should you change?
Lifetimes: the question a GC answers for you
You have never had to ask how long a reference stays valid, because a garbage collector keeps anything reachable alive. A lifetime is that question, written down where the compiler can check it.
You'll be able to: Read and write lifetime annotations, apply the elision rules, and decide when a struct should own its data rather than borrow it.
There is nothing here to transfer, and that is fine. In TypeScript, if you can reach an object, it is alive. In Rust, a reference must not outlive what it points at, and when that depends on a function's inputs, the signature has to say how. You will write far fewer of these than you fear, and when you do, you are almost always describing something you already knew.
What an annotation does
The Book's longest returns one of two references. In TypeScript this is unremarkable, because whichever one comes back will stay alive. In Rust, the caller needs to know how long the result is good for, and that depends on which input it came from:
function longest(a: string, b: string): string {
return a.length > b.length ? a : b;
}
console.log(longest("hello", "hi"));
fn longest(a: &str, b: &str) -> &str {
if a.len() > b.len() { a } else { b }
}
fn main() {
println!("{}", longest("hello", "hi"));
}
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() > b.len() { a } else { b }
}
fn main() {
println!("{}", longest("hello", "hi"));
}
That 'a looks like an instruction, and it is not one. The most important sentence in the section is this:
Lifetime annotations don’t change how long any of the references live.
An annotation is a description. It says that the result is tied to these inputs, and the compiler then checks every call against that description. It is a type-level fact, like a generic parameter, and it is gone by run time. You can think of 'a as a generic parameter whose value is a region of code rather than a type.
Why the compiler usually works it out
You have written many functions that take and return references in this course, with no 'a. §10.3 Lifetime Elision has three rules for this, and in practice you need to remember two:
- If there is exactly one input reference, the output borrows from it.
- If there is a
&self, the output borrows fromself.
You write annotations when neither applies, or when the rule gets it wrong. That second case is the one worth understanding, and you are about to see it.
The annotation that makes anyscan possible
A struct that holds a reference has to name a lifetime, as §10.3 In Struct Definitions explains, because the struct cannot outlive what it points at. Here is a tiny lexer. Look at what elision does to the method's return type:
struct Lexer<'src> {
source: &'src str,
}
impl<'src> Lexer<'src> {
fn next_word(&mut self) -> &str {
let (word, rest) = self.source.split_once(' ').unwrap_or((self.source, ""));
self.source = rest;
word
}
}
fn main() {
let mut lexer = Lexer { source: "a b" };
let first = lexer.next_word();
let second = lexer.next_word();
println!("{first} {second}");
}
struct Lexer<'src> {
source: &'src str,
}
impl<'src> Lexer<'src> {
fn next_word(&mut self) -> &'src str {
let (word, rest) = self.source.split_once(' ').unwrap_or((self.source, ""));
self.source = rest;
word
}
}
fn main() {
let mut lexer = Lexer { source: "a b" };
let first = lexer.next_word();
let second = lexer.next_word();
println!("{first} {second}");
}
The only difference is 'src in the return type. With elision, each word is assumed to borrow from &mut self, so holding one keeps the lexer locked. But that assumption is wrong: the word points into the original text, and the lexer is just a cursor over it. Naming 'src says exactly that, and two words can be held at once.
That is what lifetimes are like in practice. The compiler asked a real question about the data, and the answer is one you could have given in words. Tokens that point into the source mean that a lexer copies nothing, and the compiler guarantees that no token can outlive the text it points at.
When to own instead
A lifetime parameter spreads. Once a struct has one, everything holding that struct needs one too. So the question to ask before adding one is whether you need a borrow at all:
Own (String, Vec<T>) when | Borrow (&str, &[T]) when |
|---|---|
| the struct is long-lived or moves around | it is a short-lived view, used and thrown away |
| it comes from different sources | it all points into one thing that clearly outlives it |
| you are not sure | you have measured, or copying is obviously wasteful |
Default to owned data in structs while you are learning. A few extra allocations will not matter, and in most programs they never will.
Adding 'a or 'static until the error stops. This is the Rust version of as any. The compiler may even suggest 'static, but it means the data lives for the entire program. A local value does not, so that fix only moves the error:
Read a lifetime error as a question about your data: what does this point at, and could that be gone by the time it is used? If the answer is that it really could be gone, no annotation will help. Restructure, or own the data.
Eight signatures and programs. Predict which ones compile as written, and for the ones that do not, what they are missing. The last two are the traps.
Each program is compiled when this course is built. The explanations are this course's, and they apply the elision rules from §10.3.
This is what the course has been building towards. Replace the text searching with a real lexer, so anyscan can tell code from comments and strings. Every Token is a view into the source, so scanning copies no text at all, and next_token names 'src for exactly the reason you just saw.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenKind {
Word,
Punct,
Text,
Comment,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Token<'src> {
pub kind: TokenKind,
pub text: &'src str,
pub offset: usize,
}
pub struct Lexer<'src> {
source: &'src str,
position: usize,
}
impl<'src> Lexer<'src> {
pub fn new(source: &'src str) -> Lexer<'src> {
Lexer {
source,
position: 0,
}
}
fn take(&mut self, kind: TokenKind, length: usize) -> Token<'src> {
let offset = self.position;
self.position += length;
Token {
kind,
text: &self.source[offset..self.position],
offset,
}
}
pub fn next_token(&mut self) -> Option<Token<'src>> {
let rest = &self.source[self.position..];
let code = rest.trim_start();
self.position += rest.len() - code.len();
let first = code.chars().next()?;
let token = if code.starts_with("//") {
let length = code.find('\n').unwrap_or(code.len());
self.take(TokenKind::Comment, length)
} else if code.starts_with("/*") {
let length = code.find("*/").map_or(code.len(), |at| at + 2);
self.take(TokenKind::Comment, length)
} else if matches!(first, '"' | '\'' | '`') {
self.take(TokenKind::Text, quoted_length(code, first))
} else if is_word_char(first) {
let length = code.find(|c| !is_word_char(c)).unwrap_or(code.len());
self.take(TokenKind::Word, length)
} else {
self.take(TokenKind::Punct, first.len_utf8())
};
Some(token)
}
}
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c == '_' || c == '$'
}
/// The length of a quoted string, including its quotes and any escapes.
fn quoted_length(text: &str, quote: char) -> usize {
let mut escaped = false;
for (at, c) in text.char_indices().skip(1) {
if escaped {
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == quote {
return at + c.len_utf8();
}
}
text.len()
}
The rules get simpler now that they look at tokens, each one with the tokens either side of it. The Rule trait keeps its purpose, and its input improves.
use crate::hatch::Hatch;
use crate::lexer::{Token, TokenKind};
pub trait Rule {
fn hatch(&self) -> Hatch;
fn matches(&self, before: Option<&Token>, token: &Token, after: Option<&Token>) -> bool;
}
pub struct AnyType;
impl Rule for AnyType {
fn hatch(&self) -> Hatch {
Hatch::Any
}
fn matches(&self, before: Option<&Token>, token: &Token, _after: Option<&Token>) -> bool {
token.kind == TokenKind::Word
&& token.text == "any"
&& before.is_some_and(|b| matches!(b.text, ":" | "<" | "|" | "as"))
}
}
pub struct AsCast;
impl Rule for AsCast {
fn hatch(&self) -> Hatch {
Hatch::As
}
fn matches(&self, _before: Option<&Token>, token: &Token, after: Option<&Token>) -> bool {
token.kind == TokenKind::Word
&& token.text == "as"
&& after.is_some_and(|a| a.text != "const")
}
}
pub struct NonNull;
impl Rule for NonNull {
fn hatch(&self) -> Hatch {
Hatch::NonNull
}
fn matches(&self, before: Option<&Token>, token: &Token, after: Option<&Token>) -> bool {
token.text == "!"
&& before.is_some_and(|b| b.kind == TokenKind::Word || matches!(b.text, ")" | "]"))
&& after.is_some_and(|a| matches!(a.text, "." | "[" | ")" | ";" | ","))
}
}
pub struct TsIgnore;
impl Rule for TsIgnore {
fn hatch(&self) -> Hatch {
Hatch::TsIgnore
}
fn matches(&self, _before: Option<&Token>, token: &Token, _after: Option<&Token>) -> bool {
token.kind == TokenKind::Comment && token.text.contains("@ts-ignore")
}
}
Finding can now hold the text it found, as a view rather than a copy, so it gains a lifetime of its own.
use crate::hatch::Hatch;
#[derive(Debug)]
pub struct Finding<'src> {
pub line: usize,
pub column: usize,
pub hatch: Hatch,
pub text: &'src str,
}
impl<'src> Finding<'src> {
pub fn locate(source: &str, offset: usize, hatch: Hatch, text: &'src str) -> Finding<'src> {
let before = &source[..offset];
let this_line = before.rsplit('\n').next().unwrap_or(before);
Finding {
line: before.matches('\n').count() + 1,
column: this_line.chars().count() + 1,
hatch,
text,
}
}
}
mod error;
mod finding;
mod hatch;
mod lexer;
mod report;
mod rules;
use error::ScanError;
use finding::Finding;
use lexer::Lexer;
use rules::{AnyType, AsCast, NonNull, Rule, TsIgnore};
use std::collections::HashMap;
use std::process::ExitCode;
use std::{env, fs};
fn run() -> Result<(), ScanError> {
let path = env::args().nth(1).ok_or(ScanError::Usage)?;
let source = fs::read_to_string(&path)?;
let rules: Vec<Box<dyn Rule>> = vec![
Box::new(AnyType),
Box::new(AsCast),
Box::new(NonNull),
Box::new(TsIgnore),
];
let mut tokens = Vec::new();
let mut lexer = Lexer::new(&source);
while let Some(token) = lexer.next_token() {
tokens.push(token);
}
let mut findings = Vec::new();
for (at, token) in tokens.iter().enumerate() {
let before = at.checked_sub(1).and_then(|i| tokens.get(i));
let after = tokens.get(at + 1);
for rule in &rules {
if rule.matches(before, token, after) {
findings.push(Finding::locate(
&source,
token.offset,
rule.hatch(),
token.text,
));
}
}
}
let mut tally = HashMap::new();
for found in &findings {
println!(
"{path}:{}:{} {} ({})",
found.line, found.column, found.hatch, found.text
);
*tally.entry(found.hatch).or_insert(0) += 1;
}
for (hatch, total) in report::sorted_totals(tally) {
println!("{total} {hatch}");
}
Ok(())
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("anyscan: {error}");
ExitCode::FAILURE
}
}
}
$ cd anyscan
$ cargo run --quiet -- sample.ts
sample.ts:2:32 as (as)
sample.ts:3:13 any (any)
sample.ts:4:14 any (any)
sample.ts:5:1 @ts-ignore (// @ts-ignore)
sample.ts:6:26 non-null ! (!)
2 any
1 as
1 non-null !
1 @ts-ignore
$ cargo clippy --quiet
$ cargo fmt --check
Checkpoint: every hatch is found, in file order, and the any inside the string on the last line is correctly ignored, because it is part of a Text token. Add x as const; to the sample and see it ignored too. This is a lexer and not a parser, so it still makes mistakes, and as inside import { a as b } will be reported. Lesson 21 measures how often that happens on real code.
What does adding 'a to a function signature change about your program at run time?
A method fn next_token(&mut self) -> &str on Lexer<'src> compiles, but calling it twice while keeping the first result gives E0499. The token points into the original source text. What is the right fix?
Closures and lazy iterators
These will look like the array methods you use every day, and they run on an entirely different model. Nothing happens until something asks, and it stops when nothing does.
You'll be able to: Choose among Fn, FnMut and FnOnce, and write iterator chains in place of index loops, predicting when each one does its work.
The Book calls these functional language features, and you already write this way. map, filter, and arrow functions that capture variables will all feel at home. This is one of the places your TypeScript helps most. Two things are new: a closure now has to say how it captures, and an iterator chain does no work at all until you ask for a result.
Nothing happens until you ask
Compare a chain you have written many times with what it looks like in Rust:
let calls = 0;
const doubled = [1, 2, 3].map((n) => {
calls += 1;
return n * 2;
});
console.log(calls, doubled.length);
fn main() {
let mut calls = 0;
let doubled = [1, 2, 3].iter().map(|n| {
calls += 1;
n * 2
});
drop(doubled);
println!("{calls}");
}
3 3
0
Zero calls. The Rust map built a description of work and did none of it. The compiler warns you about this if you forget, and the Book explains why:
Iterator adapters are lazy
Work happens when something consumes the chain, such as collect, sum or a for loop. The practical result is that a chain runs one item at a time, all the way through, and it can stop early. Here is a pipeline over 1,000 numbers, in Rust, counting its own calls:
use std::cell::Cell;
fn main() {
let maps = Cell::new(0);
let filters = Cell::new(0);
let found: Vec<i32> = (1..=1000)
.map(|n| {
maps.set(maps.get() + 1);
n * 2
})
.filter(|n| {
filters.set(filters.get() + 1);
n % 3 == 0
})
.take(3)
.collect();
println!("{found:?} {} {}", maps.get(), filters.get());
}
[6, 12, 18] 9 9
The same chain written as array methods makes 2,000 calls, because each method finishes before the next one starts. This one found its three results after making 18. Cell is here only so that both closures can update a counter, and Lesson 16 explains it.
iter, into_iter and iter_mut
TypeScript has one way to walk an array, and Rust has three. Ownership decides which one you want:
| You write | You get | Afterwards the list is |
|---|---|---|
list.iter() or for x in &list | &T | still yours |
list.iter_mut() or for x in &mut list | &mut T, so you can change items | still yours, changed |
list.into_iter() or for x in list | T, by value | gone |
That table explains a confusing error. A for x in list moves the list, so using it afterwards is E0382, which is not what a TypeScript programmer expects from a loop.
Closures say how they capture
An arrow function captures whatever it mentions, and the garbage collector keeps it alive. A Rust closure has to work out what it does with each thing it captures, and that decides which of three traits it implements, as §13.1 Moving Captured Values Out of Closures explains:
| Trait | The closure | How often it can be called |
|---|---|---|
Fn | only reads what it captures | any number of times |
FnMut | changes what it captures | any number of times, one at a time |
FnOnce | moves something out of what it captures | once |
You rarely write these unless you are accepting a closure as a parameter, and the compiler infers which one applies. The word you will write yourself is move, which makes a closure take ownership of what it captures. You need it when the closure outlives the current scope, as Lesson 17 will show with threads. Returning a closure that has borrowed a local fails for the same reason any reference to a local fails, and that is a mistake a TypeScript programmer makes without thinking:
function counter() {
let count = 0;
return () => {
count += 1;
return count;
};
}
const next = counter();
next();
console.log(next());
fn counter() -> impl FnMut() -> i32 {
let mut count = 0;
|| {
count += 1;
count
}
}
fn main() {
let mut next = counter();
next();
println!("{}", next());
}
Adding move before the || fixes it, and the closure then owns count:
fn counter() -> impl FnMut() -> i32 {
let mut count = 0;
move || {
count += 1;
count
}
}
fn main() {
let mut next = counter();
next();
println!("{}", next());
}
Is the chain slower than the loop?
§13.4 benchmarks this, and it uses the phrase zero-cost abstraction: a chain compiles down to about the same code as the loop you would write by hand. In TypeScript you may have avoided chains on hot paths for good reason, because each method allocates an array and calls a closure per item. In Rust you need not avoid them for that reason. Prefer whichever reads better, and it usually will be the chain.
Index loops. for i in 0..list.len() is Rust with an accent, and Clippy will tell you so, as you saw in Lesson 2. Say what you are doing to each item instead.
Collecting in the middle. In TypeScript each step returns an array, so it is natural to stop, name the array, and carry on. In Rust, .collect() allocates a Vec, so doing it mid-chain throws away laziness and costs memory. Keep the chain going, and collect once at the end.
fn total_length(words: &[&str]) -> usize {
let mut total = 0;
for i in 0..words.len() {
total += words[i].len();
}
total
}
fn main() {
println!("{}", total_length(&["any", "as"]));
}
fn total_length(words: &[&str]) -> usize {
words.iter().map(|word| word.len()).sum()
}
fn main() {
println!("{}", total_length(&["any", "as"]));
}
Both pipelines below really run in your browser, one as array methods and one with generators, which pull a value at a time as Rust's iterators do. Raise the list size and watch which count moves.
The eager version uses real array methods. The lazy version uses JavaScript generators, which pull one value at a time in the same order as Rust's iterators. The lesson's Rust program counts the same 18 calls for 1,000 items and 3 results.
next_token is one method away from being an Iterator, and then the whole standard library works on it. Change the method's name to next by implementing the trait. Replace the impl of next_token by making it the trait's method, then add this at the end of lexer.rs. Keep next_token as it is, because the trait can simply call it.
impl<'src> Iterator for Lexer<'src> {
type Item = Token<'src>;
fn next(&mut self) -> Option<Token<'src>> {
self.next_token()
}
}
Now the loop in main becomes a chain, and tokens becomes one collect. flat_map is flatMap, and filter_map is the two together in one step. run also moves its scanning into a function, ready for the next lesson.
mod error;
mod finding;
mod hatch;
mod lexer;
mod report;
mod rules;
use error::ScanError;
use finding::Finding;
use lexer::{Lexer, Token};
use rules::{AnyType, AsCast, NonNull, Rule, TsIgnore};
use std::collections::HashMap;
use std::process::ExitCode;
use std::{env, fs};
fn scan<'src>(source: &'src str, rules: &[Box<dyn Rule>]) -> Vec<Finding<'src>> {
let tokens: Vec<Token<'src>> = Lexer::new(source).collect();
tokens
.iter()
.enumerate()
.flat_map(|(at, token)| {
let before = at.checked_sub(1).and_then(|i| tokens.get(i));
let after = tokens.get(at + 1);
rules
.iter()
.filter(move |rule| rule.matches(before, token, after))
.map(|rule| Finding::locate(source, token.offset, rule.hatch(), token.text))
})
.collect()
}
fn run() -> Result<(), ScanError> {
let path = env::args().nth(1).ok_or(ScanError::Usage)?;
let source = fs::read_to_string(&path)?;
let rules: Vec<Box<dyn Rule>> = vec![
Box::new(AnyType),
Box::new(AsCast),
Box::new(NonNull),
Box::new(TsIgnore),
];
let mut tally = HashMap::new();
for found in scan(&source, &rules) {
println!(
"{path}:{}:{} {} ({})",
found.line, found.column, found.hatch, found.text
);
*tally.entry(found.hatch).or_insert(0) += 1;
}
for (hatch, total) in report::sorted_totals(tally) {
println!("{total} {hatch}");
}
Ok(())
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("anyscan: {error}");
ExitCode::FAILURE
}
}
}
$ cd anyscan
$ cargo run --quiet -- sample.ts
sample.ts:2:32 as (as)
sample.ts:3:13 any (any)
sample.ts:4:14 any (any)
sample.ts:5:1 @ts-ignore (// @ts-ignore)
sample.ts:6:26 non-null ! (!)
2 any
1 as
1 non-null !
1 @ts-ignore
$ cargo clippy --quiet
$ cargo fmt --check
Checkpoint: the same output, from less code. The signature of scan tells you something useful: the findings it returns borrow from source, and the rules are only needed during the call. This chain is about as dense as Rust chains should get, and it would be fair to prefer the nested for loops here. Readability decides, not fashion.
What does this print? let mut calls = 0; let doubled = [1, 2, 3].iter().map(|n| { calls += 1; n * 2 }); drop(doubled); println!("{calls}");
You have let names: Vec<String>, and you want its total length, and to go on using names afterwards. Which is right?
Tests, Clippy and Cargo are not your npm setup
In TypeScript you choose a test runner, a linter, a formatter and a bundler, then get them to agree. Here they all came with the compiler, and that changes what a project looks like.
You'll be able to: Write unit, integration and doc tests, make a project Clippy-clean, and read a Cargo.toml version requirement correctly.
You know why all of this matters, so this lesson is about where things went rather than why they exist. Most of it is good news. Unit tests live beside the code with nothing to install, documentation examples run as tests, and there is one formatter that everyone uses. One thing here is a real trap, and it is in Cargo.toml.
Tests live in the file
In TypeScript your tests are usually a separate file that imports what it tests, so it can see only what is exported. Rust's convention, from §11.3 Unit Tests, is a module at the bottom of the same file:
fn add_two(n: i32) -> i32 {
n + 2
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adds_two() {
assert_eq!(add_two(2), 4);
}
}
#[cfg(test)] means this module does not exist in your normal build, and use super::* works on private functions, for the reason Lesson 8 gave: a child module can see everything in its ancestors. So there is no pressure to export things only so they can be tested. There is no test framework to pick, either, and cargo test is all it takes.
| TypeScript | Rust | The difference |
|---|---|---|
__tests__/x.test.ts | #[cfg(test)] mod tests in the same file | sees private items |
tests/ with public imports | tests/ directory, as §11.3 Integration Tests describes | only sees your public API, and needs a library crate |
expect(a).toBe(b) | assert_eq!(a, b) | prints both values if it fails |
| examples in docs that drift out of date | doc tests | run by cargo test |
jest.mock() | usually a trait with a test implementation | no module patching |
Doc tests deserve a moment. A code example in a /// comment is compiled and run by cargo test, as §14.2 Documentation Comments as Tests explains. Your documentation examples cannot quietly go out of date, because then your tests fail.
Mocking is the bigger adjustment. With no monkey-patching, you can only substitute something that was designed to be substituted, which usually means a trait parameter. Many Rust programmers say this improves their designs, and it is also more work up front, so here again you are trading convenience for explicitness.
main shrinks, and the logic moves to a library
§12.3 Separating Concerns in Binary Projects sets out a convention you should adopt at once: src/main.rs parses arguments and reports errors, and everything else goes in src/lib.rs. A package can have both. The binary becomes a thin client of your library, which is what lets integration tests reach your code at all. You will do this to anyscan below.
Your tools, included
Clippy and rustfmt do not need configuring to be useful, and there is one formatting style, so a team has nothing to argue about. Everything you saw in Lesson 1's table is one command:
| Job | Command | Notes |
|---|---|---|
| Type check | cargo check | fastest feedback, no binary |
| Test | cargo test | unit, integration and doc tests |
| Lint | cargo clippy | -- -D warnings makes warnings fail CI |
| Format | cargo fmt | --check for CI |
| Add a dependency | cargo add name | writes the Cargo.toml line |
| Monorepo | [workspace] in Cargo.toml | like npm workspaces, with one shared lockfile |
The trap: what a version means
Here is the one place your npm instincts will quietly cost you. The same text means different things:
"rand": "0.10.1"
rand = "0.10.1"
§2 Increasing Functionality with a Crate said this back in Chapter 2: a bare version in Cargo is shorthand for ^0.10.1. In npm it pins exactly, and it is npm install that adds the ^ for you, so you may never have written a bare version on purpose. Treat Cargo.toml as saying "at least this", and Cargo.lock, as in npm, as what actually gets used. To pin in Cargo, write =0.10.1.
The other half of the trap is that the leading zero counts. For 0.x versions, the caret allows only patch updates, because the semantic versioning convention treats each 0.x as potentially breaking. Both tools agree on that. Widely used crates can stay on 0.x for a long time, including rand from Chapter 2, so this comes up often.
Reaching for a dependency first. The npm reflex is to install something for each small job. Crates tend to be larger and fewer, and each one adds compile time for everyone building your project, as well as its own dependencies. Look in the standard library first, and read what a crate pulls in before adding it. This is the course's own reading, and you will meet people with different views.
Skipping cargo clippy because the code compiles. You came from a world where a lint rule was often a matter of opinion. Clippy's defaults are closer to a code review from someone who knows the language, and they are the fastest way to lose your accent.
Choose a requirement and compare what each tool accepts. Start with 1.2.3, then try 0.2.3 and 1.2.
| Published version | npm accepts | Cargo accepts |
|---|
Both rules are small implementations in this page, and each is checked as the page loads: npm's against a table generated by npm's own semver package (7.7.3), and Cargo's against the ranges in Cargo's documentation. This covers plain releases only, and neither tool's handling of pre-release versions is shown.
Split anyscan into a library and a thin binary, as §12.3 recommends, and test it. Every module stays as it is.
//! Finds the places a TypeScript file escapes its type checker.
pub mod error;
pub mod finding;
pub mod hatch;
pub mod lexer;
pub mod report;
pub mod rules;
use finding::Finding;
use lexer::{Lexer, Token};
use rules::{AnyType, AsCast, NonNull, Rule, TsIgnore};
/// The rules `anyscan` applies by default.
pub fn default_rules() -> Vec<Box<dyn Rule>> {
vec![
Box::new(AnyType),
Box::new(AsCast),
Box::new(NonNull),
Box::new(TsIgnore),
]
}
/// Scan TypeScript source, and report where it escapes the type checker.
///
/// ```
/// use anyscan::{default_rules, scan};
///
/// let found = scan("const a: any = b!.c;", &default_rules());
/// assert_eq!(found.len(), 2);
/// assert_eq!(found[0].text, "any");
/// ```
pub fn scan<'src>(source: &'src str, rules: &[Box<dyn Rule>]) -> Vec<Finding<'src>> {
let tokens: Vec<Token<'src>> = Lexer::new(source).collect();
tokens
.iter()
.enumerate()
.flat_map(|(at, token)| {
let before = at.checked_sub(1).and_then(|i| tokens.get(i));
let after = tokens.get(at + 1);
rules
.iter()
.filter(move |rule| rule.matches(before, token, after))
.map(|rule| Finding::locate(source, token.offset, rule.hatch(), token.text))
})
.collect()
}
use anyscan::error::ScanError;
use anyscan::{default_rules, report, scan};
use std::collections::HashMap;
use std::process::ExitCode;
use std::{env, fs};
fn run() -> Result<(), ScanError> {
let path = env::args().nth(1).ok_or(ScanError::Usage)?;
let source = fs::read_to_string(&path)?;
let mut tally = HashMap::new();
for found in scan(&source, &default_rules()) {
println!(
"{path}:{}:{} {} ({})",
found.line, found.column, found.hatch, found.text
);
*tally.entry(found.hatch).or_insert(0) += 1;
}
for (hatch, total) in report::sorted_totals(tally) {
println!("{total} {hatch}");
}
Ok(())
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("anyscan: {error}");
ExitCode::FAILURE
}
}
}
Now some unit tests, at the bottom of the lexer. Each one pins down a mistake a text search would make.
#[cfg(test)]
mod tests {
use super::*;
fn kinds(source: &str) -> Vec<(TokenKind, &str)> {
Lexer::new(source).map(|t| (t.kind, t.text)).collect()
}
#[test]
fn a_word_in_a_string_is_not_a_word() {
assert_eq!(kinds("\"any\""), vec![(TokenKind::Text, "\"any\"")]);
}
#[test]
fn an_escaped_quote_does_not_end_a_string() {
assert_eq!(kinds(r#""a\"b" c"#).len(), 2);
}
#[test]
fn a_comment_runs_to_the_end_of_its_line() {
let tokens = kinds("// any\nas");
assert_eq!(tokens[0], (TokenKind::Comment, "// any"));
assert_eq!(tokens[1], (TokenKind::Word, "as"));
}
#[test]
fn offsets_are_bytes_not_characters() {
let tokens: Vec<Token> = Lexer::new("é any").collect();
assert_eq!(tokens[1].offset, 3);
}
}
And an integration test, which sees only the public API, in a new tests directory:
use anyscan::hatch::Hatch;
use anyscan::{default_rules, scan};
fn hatches(source: &str) -> Vec<Hatch> {
scan(source, &default_rules())
.iter()
.map(|found| found.hatch)
.collect()
}
#[test]
fn finds_every_kind_of_hatch() {
let source = "// @ts-ignore\nconst a: any = b as C;\nd!.e;";
assert_eq!(
hatches(source),
vec![Hatch::TsIgnore, Hatch::Any, Hatch::As, Hatch::NonNull]
);
}
#[test]
fn ignores_words_in_strings_and_safe_casts() {
assert!(hatches("const a = \"any as\"; const b = [1] as const;").is_empty());
}
#[test]
fn does_not_mistake_inequality_for_non_null() {
assert!(hatches("if (a !== b) { c(!d); }").is_empty());
}
$ cd anyscan
$ cargo test --quiet
running 4 tests
....
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.21s
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.21s
running 3 tests
...
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.21s
running 1 test
.
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.21s
all doctests ran in 0.21s; merged doctests compilation took 0.18s
$ cargo clippy --quiet --all-targets -- -D warnings
$ cargo fmt --check
$ cargo run --quiet -- sample.ts
sample.ts:2:32 as (as)
sample.ts:3:13 any (any)
sample.ts:4:14 any (any)
sample.ts:5:1 @ts-ignore (// @ts-ignore)
sample.ts:6:26 non-null ! (!)
2 any
1 as
1 non-null !
1 @ts-ignore
Checkpoint: every test passes, including the example in the doc comment, and Clippy passes with warnings treated as errors. That is the same check you would put in CI. The binary still does what it did, and src/main.rs is now only about command-line concerns.
You write a function with no pub in src/lexer.rs, and want to test it. What do you need to do?
You copy "serde": "1.0.200" from a package.json habit into Cargo.toml as serde = "1.0.200", intending to pin that exact version. What happens when 1.0.210 is published and you run cargo update?
When you really do want a graph
Every object in TypeScript is already a shared, mutable, reference-counted-or-better pointer. Rust makes you ask for each of those properties separately, and it turns out you rarely need all of them.
You'll be able to: Choose among Box, Rc, RefCell, Weak and indices into a Vec, and explain how a reference cycle leaks.
Here is a useful way to read this chapter. A TypeScript object reference quietly combines a heap allocation, shared ownership, shared mutation and cycle collection. Rust unbundles those into separate tools, and each one has a cost you can see. The chapter is not teaching exotic machinery. It is showing you what you have been using all along, one piece at a time.
What your object reference was
| What a TypeScript reference gives you | The Rust tool | What it costs |
|---|---|---|
| Data on the heap | Box<T> | one allocation |
| Several owners | Rc<T> | a count, updated on each clone |
| Changing it through a shared reference | RefCell<T> | a check on every access, and a possible panic |
| Sharing with another thread | Arc<T>, Mutex<T> | Lesson 17 |
| Cycles cleaned up for you | nothing: you use Weak<T> | you break cycles yourself |
So Rc<RefCell<T>> is the closest thing Rust has to a TypeScript object reference. Here it is doing what you are used to:
const a = [1];
const b = a;
b.push(2);
console.log(a);
use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let a = Rc::new(RefCell::new(vec![1]));
let b = Rc::clone(&a);
b.borrow_mut().push(2);
println!("{:?}", a.borrow());
}
It works, and now you can see all of it. A heap allocation, a count, a borrow check at run time. The rest of this lesson is about when you actually need that, and it is less often than you will first think.
Box: why recursion needs a pointer
In TypeScript a recursive type just works, because every object is already behind a reference. In Rust a struct holds its fields inline, so a type that contains itself would be infinitely large:
type List = { value: number; next?: List };
const list: List = { value: 1, next: { value: 2 } };
console.log(list.next?.value);
enum List {
Cons(i32, List),
Nil,
}
fn main() {
let _list = List::Nil;
}
§15.1 Enabling Recursive Types with Boxes gives the fix: Box<List> has a known size, the size of a pointer. §15.2 Using Deref Coercion in Functions and Methods also answers something from Lesson 4: &source worked where a &str was wanted because a &String converts automatically.
Rc and RefCell: rules that moved
Rc lets several owners share a value, and it gives them only read access, because Lesson 4's rule still applies. RefCell gets around that by moving the check from compile time to run time. With references, breaking the rule is a compile error. With RefCell, the Book is plain about what happens:
if you break these rules, your program will panic and exit.
So reach for RefCell knowing what you are giving up. Everything Lesson 4 made a compile error can now be a crash in production.
Cycles: the one thing your GC did that this does not
A TypeScript garbage collector finds unreachable cycles and frees them. A reference count cannot, because each value in a cycle keeps another alive. §15.6 shows this, and the lab lets you watch it. It is why memory safety in Rust does not mean no leaks: a leak is safe, in Rust's sense, because nothing gets misused.
Usually, use indices
Parent pointers are where this usually comes up. A tree where each node knows its parent is one line in TypeScript. You can write it the way the Book does, using Rc and Weak. Here is the other way, and a TypeScript programmer rarely thinks of it first: keep all the nodes in a Vec, and refer to them by position.
struct Node {
name: String,
parent: Option<usize>,
children: Vec<usize>,
}
struct Tree {
nodes: Vec<Node>,
}
impl Tree {
fn add(&mut self, name: &str, parent: Option<usize>) -> usize {
let id = self.nodes.len();
self.nodes.push(Node {
name: name.to_string(),
parent,
children: Vec::new(),
});
if let Some(parent) = parent {
self.nodes[parent].children.push(id);
}
id
}
fn path(&self, id: usize) -> String {
let node = &self.nodes[id];
match node.parent {
Some(parent) => format!("{}/{}", self.path(parent), node.name),
None => node.name.clone(),
}
}
}
fn main() {
let mut tree = Tree { nodes: Vec::new() };
let root = tree.add("src", None);
let lib = tree.add("lib", Some(root));
let file = tree.add("main.rs", Some(lib));
println!("{}", tree.path(file));
println!("{}", tree.nodes[root].children.len());
}
No Rc, no RefCell, no lifetimes, no cycles to worry about, and one owner of everything. The compiler is happy because an index is only a number. That is also the catch: an index can go stale if you remove a node, just as a stored position could in §4.3, and the compiler will not help you. For a structure that only grows, that does not matter. Crates such as slotmap deal with removal. Choosing this over Rc is the course's own reading of common Rust practice, not something the Book teaches.
Rc<RefCell<T>> as your default object reference. When the borrow checker pushes back, it is very tempting to wrap everything in this and carry on writing TypeScript. It works, it compiles, and you end up with the costs of a garbage-collected language, without the collector, plus run-time panics. If you see it spreading through a design, stop and ask who really owns this data.
It does have legitimate uses. Graphs with truly shared nodes, and observer patterns, for example. The Book teaches it for good reason, so the aim is to choose it deliberately, not to never use it.
Step through the output of four real programs. Watch the owner count in the first. Then compare the cycle with its Weak version, and look for the lines that are missing.
Each line is printed by a real program, which ran when this course was built. Nothing here is simulated, including the lines that never appear.
There is nothing to build, and that is the point of this step. Check what anyscan has been getting by without:
$ cd anyscan
$ grep -r "Rc<" src || echo "no Rc anywhere"
no Rc anywhere
$ grep -r "RefCell" src || echo "no RefCell anywhere"
no RefCell anywhere
Checkpoint: a lexer, rules, reports and tests, with no shared ownership anywhere. Data flows one way: the source is owned by run, tokens borrow it, and findings borrow it. That shape is common in Rust programs, and when you find one, a lot of problems simply never come up.
Two Rc values point at each other, and both variables go out of scope. What happens?
You are translating a TypeScript tree in which each node holds a parent and children. Nodes are added and never removed. Which is the simplest design that compiles and avoids run-time borrow panics?
Threads, without the event loop
You have spent twelve lessons on rules that may have felt fussy for a single thread. Here is the reason for them. The rule that stopped you changing a list while looping over it is the rule that stops a data race.
You'll be able to: Explain Send and Sync, and share work safely using scoped threads, channels and Arc with Mutex.
JavaScript gives you one thread and an event loop, so you have rarely had to think about two pieces of code touching memory at the same instant. Workers exist, but they mostly copy their messages. Rust threads share an address space, so this is a real change in the model. But do not assume that you have been safe all this time, because you have not.
You already have this bug
A single thread does not save you from losing an update. It just moves the gap to the nearest await:
export {};
let count = 0;
async function bump() {
const current = count;
await null;
count = current + 1;
}
await Promise.all([bump(), bump()]);
console.log(count);
1
Two increments, and the answer is 1. Each task read count, then yielded, and then wrote back a value based on what it read before. tsc has nothing to say about it, because this is correct TypeScript. Now look at the direct translation with threads:
use std::thread;
fn main() {
let mut count = 0;
thread::scope(|scope| {
scope.spawn(|| count += 1);
scope.spawn(|| count += 1);
});
println!("{count}");
}
That is E0499, two mutable borrows at once, the very rule from Lesson 4. You did not have to learn anything new to get a compile error for a data race. This is what the Book means by fearless concurrency, and it is the moment the ownership rules start paying you back.
What makes it work
Here is the same counter done properly, and each piece does one job from the table in Lesson 16:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let count = Arc::new(Mutex::new(0));
let mut workers = Vec::new();
for _ in 0..8 {
let count = Arc::clone(&count);
workers.push(thread::spawn(move || {
*count.lock().unwrap() += 1;
}));
}
for worker in workers {
worker.join().unwrap();
}
println!("{}", count.lock().unwrap());
}
use std::sync::mpsc;
use std::thread;
fn main() {
let (sender, receiver) = mpsc::channel();
for n in 0..8 {
let sender = sender.clone();
thread::spawn(move || {
sender.send(n).unwrap();
});
}
drop(sender);
let total: i32 = receiver.iter().sum();
println!("{total}");
}
Arc is Rc with an atomic count, safe to clone across threads. Mutex is what holds the data, and you cannot reach the data without locking it. Think about that for a moment. In most languages a lock is a convention that sits next to the data, and nothing stops you forgetting it. Here, the lock owns the data.
lock() returns a guard, and the guard unlocks when it goes out of scope, which is Lesson 3's predictable cleanup doing useful work. A channel feels like postMessage, with one improvement: sending moves the value, so using it afterwards is a compile error, as §16.2 Transferring Ownership Through Channels shows. That has the same effect as a Worker's transfer list, except that the compiler enforces it.
Send and Sync
How does the compiler know that Rc cannot cross a thread? Two traits with no methods, which §16.4 describes. Send means a value can move to another thread. Sync means a reference to it can be shared between threads. The compiler works these out for your types, field by field:
use std::rc::Rc;
use std::thread;
fn main() {
let shared = Rc::new(1);
thread::spawn(move || println!("{shared}"));
}
What this does not protect you from
The guarantee covers data races: two threads touching the same memory unsafely. It says nothing about logic. A deadlock is still possible, as §16.3 warns, and if you hold a lock only around the read and take it again for the write, you can reproduce the lost update above in safe Rust, with no compile error. "Fearless" is about one class of bug, and it is a big class.
Assuming one thread. Global mutable state, module-level caches and singletons are ordinary in TypeScript, because nothing can interrupt synchronous code. Rust will not compile a plain static mut access without unsafe, and it is right. When you want shared state, say who can touch it, and how.
Spawning a thread per item. A thread is much heavier than a promise, so a thread for each of 100,000 items is a problem, where 100,000 promises would be fine. In real projects, the rayon crate handles parallel iteration for you. This is the course's own suggestion, and the Book does not cover it.
Be the scheduler. Step each thread yourself and try to lose an update. Then turn on the lock and try again.
Thread A
Thread B
Counting every order is exact for this simple model, in which memory behaves as though one thing happens at a time. Real hardware can behave in stranger ways, and that is one reason a data race in unsafe code is undefined behaviour rather than merely a wrong answer. Safe Rust rejects the unprotected program outright, as you saw above.
Scan many files at once. thread::scope is not in the Book's Chapter 16, and it is in the standard library. It lets threads borrow from the surrounding function, because it guarantees that they finish before the scope ends. So no Arc is needed, and no move of the shared data.
First, the compiler objects, and it is right to. Box<dyn Rule> says nothing about whether a rule can be shared between threads:
use std::thread;
trait Rule {}
struct AnyType;
impl Rule for AnyType {}
fn main() {
let rules: Vec<Box<dyn Rule>> = vec![Box::new(AnyType)];
thread::scope(|scope| {
scope.spawn(|| println!("{}", rules.len()));
});
}
The fix is to say so in the type, as dyn Rule + Sync. Our rules hold no data, so the compiler can confirm it. Update lib.rs:
//! Finds the places a TypeScript file escapes its type checker.
pub mod error;
pub mod finding;
pub mod hatch;
pub mod lexer;
pub mod report;
pub mod rules;
use finding::Finding;
use lexer::{Lexer, Token};
use rules::{AnyType, AsCast, NonNull, Rule, TsIgnore};
/// Rules that can be shared between threads.
pub type Rules = Vec<Box<dyn Rule + Sync>>;
/// The rules `anyscan` applies by default.
pub fn default_rules() -> Rules {
vec![
Box::new(AnyType),
Box::new(AsCast),
Box::new(NonNull),
Box::new(TsIgnore),
]
}
/// Scan TypeScript source, and report where it escapes the type checker.
///
/// ```
/// use anyscan::{default_rules, scan};
///
/// let found = scan("const a: any = b!.c;", &default_rules());
/// assert_eq!(found.len(), 2);
/// assert_eq!(found[0].text, "any");
/// ```
pub fn scan<'src>(source: &'src str, rules: &[Box<dyn Rule + Sync>]) -> Vec<Finding<'src>> {
let tokens: Vec<Token<'src>> = Lexer::new(source).collect();
tokens
.iter()
.enumerate()
.flat_map(|(at, token)| {
let before = at.checked_sub(1).and_then(|i| tokens.get(i));
let after = tokens.get(at + 1);
rules
.iter()
.filter(move |rule| rule.matches(before, token, after))
.map(|rule| Finding::locate(source, token.offset, rule.hatch(), token.text))
})
.collect()
}
Now main takes any number of paths. Look at the two collect calls. The first one is essential: iterators are lazy, as Lesson 14 said, so without it each thread would start only when the previous one was joined, and everything would run one at a time.
use anyscan::error::ScanError;
use anyscan::hatch::Hatch;
use anyscan::{Rules, default_rules, report, scan};
use std::collections::HashMap;
use std::process::ExitCode;
use std::{env, fs, thread};
/// One file's report lines, and the hatches found in it.
fn scan_file(path: &str, rules: &Rules) -> Result<(Vec<String>, Vec<Hatch>), ScanError> {
let source = fs::read_to_string(path)?;
let found = scan(&source, rules);
let lines = found
.iter()
.map(|f| format!("{path}:{}:{} {} ({})", f.line, f.column, f.hatch, f.text))
.collect();
let hatches = found.iter().map(|f| f.hatch).collect();
Ok((lines, hatches))
}
fn run() -> Result<(), ScanError> {
let paths: Vec<String> = env::args().skip(1).collect();
if paths.is_empty() {
return Err(ScanError::Usage);
}
let rules = default_rules();
let results: Vec<_> = thread::scope(|scope| {
let workers: Vec<_> = paths
.iter()
.map(|path| scope.spawn(|| scan_file(path, &rules)))
.collect();
workers
.into_iter()
.map(|worker| worker.join().expect("a scan thread panicked"))
.collect()
});
let mut tally = HashMap::new();
for result in results {
let (lines, hatches) = result?;
for line in lines {
println!("{line}");
}
for hatch in hatches {
*tally.entry(hatch).or_insert(0) += 1;
}
}
for (hatch, total) in report::sorted_totals(tally) {
println!("{total} {hatch}");
}
Ok(())
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("anyscan: {error}");
ExitCode::FAILURE
}
}
}
const value = load() as unknown as Settings;
$ cd anyscan
$ cargo run --quiet -- sample.ts other.ts
sample.ts:2:32 as (as)
sample.ts:3:13 any (any)
sample.ts:4:14 any (any)
sample.ts:5:1 @ts-ignore (// @ts-ignore)
sample.ts:6:26 non-null ! (!)
other.ts:1:22 as (as)
other.ts:1:33 as (as)
2 any
3 as
1 non-null !
1 @ts-ignore
$ cargo test --quiet 2>&1 | grep "test result" | wc -l | tr -d " "
4
$ cargo clippy --quiet --all-targets -- -D warnings
Checkpoint: the results print in the order you gave the files, however the threads were scheduled, because each one is joined in order. The compiler checked that no thread can outlive the data it borrows. Two small files do not need threads, of course. Lesson 21 runs this on something bigger.
Why does the compiler reject thread::spawn(move || println!("{shared}")) when shared is an Rc<i32>?
You call let guard = data.lock().unwrap(); in one thread and hold onto guard while doing slow work. Other threads are waiting. What is the most direct fix?
Future is not Promise
The syntax is nearly identical, and you will read it easily. That is the danger, because the same shape of code runs in a different order.
You'll be able to: Predict when async work starts and stops, including storing a future, sequential awaits and join, and say when async is the wrong tool.
You are likely to be more at home with async and await than most of the Book's readers, and §17.1 even mentions JavaScript. What it means to wait for something, and why you would rather not block, carry straight over. Three things do not: a future does nothing until it is polled, there is no runtime unless you bring one, and dropping a future cancels it.
Calling it starts nothing
they don’t do anything until you ask them to
You have seen this idea before, with iterators in Lesson 14. A TypeScript async function runs up to its first await as soon as you call it, and the promise exists to tell you how it went. A Rust async fn runs nothing when called. It returns a value that describes the work:
export {};
async function load() {
console.log("started");
return "page";
}
const page = load();
console.log("called");
console.log(await page);
use std::future::Future;
use std::pin::pin;
use std::sync::Arc;
use std::task::{Context, Poll, Wake, Waker};
use std::thread::{self, Thread};
struct Unpark(Thread);
impl Wake for Unpark {
fn wake(self: Arc<Self>) {
self.0.unpark();
}
}
fn block_on<F: Future>(future: F) -> F::Output {
let mut future = pin!(future);
let waker = Waker::from(Arc::new(Unpark(thread::current())));
let mut context = Context::from_waker(&waker);
loop {
if let Poll::Ready(value) = future.as_mut().poll(&mut context) {
return value;
}
thread::park();
}
}
async fn load() -> &'static str {
println!("started");
"page"
}
fn main() {
block_on(async {
let page = load();
println!("called");
println!("{}", page.await);
});
}
started
called
page
called
started
page
Most of the Rust there is the second difference. Everything above async fn load is a tiny runtime, written out so that this program runs with nothing but the standard library, and you will never need to write one. The point is that Rust has none of its own:
async fn main() {
println!("hi");
}
JavaScript always has an event loop running. Rust's standard library defines what a future is and leaves running them to a crate. The Book uses its own trpl crate for this, which wraps Tokio, as §17.1 Executing an Async Function with a Runtime explains. Tokio is what most projects use, and in this course that is a claim from common practice, not one from the Book.
The trap: the same shape, a different order
This is the one to remember. You store two promises and then await them, and they run together. Write the same thing in Rust, and they do not:
let a = task("A");
let b = task("B");
a.await;
b.await;
Nothing started when you called task, so a.await runs A to the end before B has begun. No error, no warning, and your concurrent code has become sequential. To run them together you have to say so, with join, which the Book covers in §17.2:
join(task("A"), task("B")).await;
The lab below runs both of these for real, and you can compare them with the TypeScript.
Dropping cancels
In TypeScript a promise cannot be cancelled. Once it has started, it runs to the end, whether or not anyone is listening, and you may have reached for AbortController to work around that. A Rust future that is dropped simply stops, at whichever await it was waiting on, and its local values are cleaned up:
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll, Waker};
struct Guard;
impl Drop for Guard {
fn drop(&mut self) {
println!("cleaned up");
}
}
struct NeverReady;
impl Future for NeverReady {
type Output = ();
fn poll(self: Pin<&mut Self>, _context: &mut Context<'_>) -> Poll<()> {
Poll::Pending
}
}
async fn task() {
let _guard = Guard;
println!("starts");
NeverReady.await;
println!("ends");
}
fn main() {
let mut future = Box::pin(task());
let mut context = Context::from_waker(Waker::noop());
let _ = future.as_mut().poll(&mut context);
drop(future);
println!("dropped");
}
starts
cleaned up
dropped
No ends, and the cleanup still ran. This is a real feature, and it deserves a little care: code after an await might never run, so do not leave a half-finished update spanning one. The Book does not dwell on this, so this section is the course's own addition.
When not to use it
Async is the default for I/O in JavaScript, because nothing else is on offer. In Rust it is one option among several, and it adds complexity: Pin, a runtime, and a split between code that can await and code that cannot. §17.6 gives the rule of thumb. Threads suit work that is highly parallel, and async suits work that is highly concurrent, typically waiting on many things at once.
Making everything async by reflex. A command-line tool that reads a few files does not need a runtime, and neither does a library that does not do I/O. Plain synchronous Rust is simpler, and threads are there when you want parallelism.
Blocking inside async. thread::sleep, or a long computation, inside an async task stops every other task sharing that thread. A runtime's tasks take turns only at an await. You know this rule from the browser's main thread, and it applies here too. Use the runtime's async sleep, and move heavy work to a thread.
Fire and forget. A floating call runs in TypeScript, and in Rust it does nothing, and the compiler warns you. Spawn a task if you want background work.
Predict whether each program's tasks overlap, run in sequence, or never start. Compare the matching pairs, especially "Store both, then await both".
Every program ran when this course was built, and the order on screen is the order it printed. The Rust programs use the small runtime above, a hand-written join and a future that yields once, so they need only the standard library. Real runtimes schedule differently, but they cannot change what is started and when.
Nothing to build here either, and this time it is a design decision you can defend. anyscan reads files and does CPU work on them, and it has nothing to wait for concurrently, so §17.6's rule points to threads, which you already have. Adding async would mean taking on a runtime dependency and getting nothing for it.
$ cd anyscan
$ grep -r "async" src || echo "no async anywhere"
no async anywhere
$ cat Cargo.toml
[package]
name = "anyscan"
version = "0.1.0"
edition = "2024"
[dependencies]
Checkpoint: no dependencies in Cargo.toml, and no async in src. If you are building a web service, the answer will be different, and that is what Tokio is for.
const a = task(); const b = task(); await a; await b; runs both tasks concurrently in TypeScript. What does the same shape do in Rust?
You are writing a CLI that reads 200 local files and analyses each one, and it is CPU-heavy. You are about to add Tokio because the TypeScript version used async. What would you do instead?
Inheritance is gone; state can live in types
There are no base classes, and you will miss them less than you expect. What replaces them is one of the most useful ideas in Rust: making the wrong sequence of calls impossible to write.
You'll be able to: Replace inheritance with composition, choose among trait objects, enums and type-state, and use patterns beyond what destructuring can do.
TypeScript leans object-oriented when you want it to, and you may have extends in your fingers. The good news is that modern TypeScript has been moving away from deep hierarchies for years, and the unions and composition you already prefer are what Rust wants too. Chapter 18 has more to say to someone arriving from Java or C#, and you did some of that too, so it will be a useful comparison.
What you use extends for
The Book is direct about this:
There is no way to define a struct that inherits the parent struct’s fields and method implementations without using a macro.
§18.1 Inheritance as a Type System and as Code Sharing says inheritance is usually doing one of two jobs, and each one has a different replacement:
You reach for extends to… | In Rust, use… | Lesson |
|---|---|---|
| reuse an implementation | a trait's default method, or a helper function | 12 |
| reuse data | a field: a struct that holds the other struct | 6 |
| treat different types alike | a trait with impl or dyn | 12 |
| a closed set of kinds | an enum | 7 |
If you have written TypeScript's class B extends A mainly to share a few fields and a method, you will find a field and a function clearer. If you have used it to model a closed set of kinds, you were already writing an enum by another name.
Three ways to model state
§18.3 builds a blog post that moves from draft to review to published. It writes this first in a classic object-oriented style, using trait objects, and then asks whether that is the best Rust can do. Here are the three options, and what each one can catch:
| Design | Adding a new state | A call in the wrong state |
|---|---|---|
Box<dyn State>, as in the Book's first version | easy: add a type | runs, does nothing, and nothing warns you |
an enum field | every match must change, and the compiler finds them | runs, does nothing, and nothing warns you |
| a type for each state | add a type and its methods | does not compile |
The last row is the idea in the title, and TypeScript makes it awkward, because any object with the right shape would fit, and nothing can stop you using an old reference. Rust has both things it needs: nominal types, from Lesson 6, and moves, from Lesson 3. Here is a piece of the Book's version:
pub fn request_review(self) -> PendingReviewPost {
It takes self, so the draft is gone, and returns a different type, so only what a pending post can do is available next. People call this the type-state pattern. A method that does not exist in the current state is not a run-time error you have to test for: it cannot be written.
This has real costs, and the Book points out one of them: because each state is its own type, you cannot easily hold posts in different states in one list, and adding a transition means reshaping your types. It is best for workflows with a few fixed steps that really must go in order. Use an enum for most state.
Patterns are more than destructuring
You already destructure, and let (a, b) = pair; and let Point { x, y } = point; will feel like home. But a JavaScript pattern cannot fail: a missing field just gives you undefined. A Rust pattern is a real test, as §19.2 explains, so let will only accept one that always matches:
const [first] = [] as number[];
console.log(first);
fn main() {
let list: Vec<i32> = Vec::new();
let Some(first) = list.first();
println!("{first}");
}
That fix is let...else, from Lesson 7. And match goes much further than switch. It takes ranges, combinations and conditions, and it still checks that you covered everything. Compare what a TypeScript programmer has to write:
function describe(age: number, member: boolean): string {
if (age < 13) return "child";
if (age < 20 && member) return "teen member";
if (age < 20) return "teen";
return "adult";
}
console.log(describe(15, true));
fn describe(age: u32, member: bool) -> &'static str {
match (age, member) {
(0..=12, _) => "child",
(13..=19, true) => "teen member",
(13..=19, false) => "teen",
_ => "adult",
}
}
fn main() {
println!("{}", describe(15, true));
}
§19.3 Pattern Syntax has the rest: guards, |, nested destructuring and @ bindings. It is worth reading as a reference, and you will return to it.
Base classes for shared code. Before you reach for a way of simulating inheritance, ask which of the four jobs in the table you actually need done. There are ways to fake inheritance in Rust using Deref, and they are widely considered a mistake.
A status field for everything, without thinking. An enum is the right default, as this lesson's table shows. Just notice when the order of operations really matters: payment flows, connection setup, anything where a wrong call is expensive. Then a type per state may pay for itself.
One small workflow, written two ways. Watch which mistakes each one catches, and when.
Every program ran, or failed to compile, when this course was built. The first three use a status field in both languages, and the rest use a type for each state, which has no good TypeScript equivalent.
anyscan's report has two phases: while it is collecting, you can add to it, and once it is finished, you can print it. Make that a rule the compiler enforces. finish takes self, so nothing can be added afterwards, and only a finished report can be displayed. The second doc test is marked compile_fail, so cargo test checks that printing an unfinished report really does not compile. Rust has this gate built in.
use crate::hatch::Hatch;
use std::collections::HashMap;
use std::fmt;
/// Totals from a tally, sorted by key, because a `HashMap` has no order.
pub fn sorted_totals<K: Ord>(tally: HashMap<K, usize>) -> Vec<(K, usize)> {
let mut totals: Vec<_> = tally.into_iter().collect();
totals.sort();
totals
}
/// A report that is still collecting findings.
///
/// ```
/// use anyscan::hatch::Hatch;
/// use anyscan::report::Report;
///
/// let mut report = Report::default();
/// report.add(String::from("a.ts:1:1 any"), Hatch::Any);
/// assert_eq!(report.finish().to_string(), "a.ts:1:1 any\n1 any\n");
/// ```
///
/// Only a finished report can be displayed:
///
/// ```compile_fail
/// use anyscan::report::Report;
///
/// println!("{}", Report::default());
/// ```
#[derive(Default)]
pub struct Report {
lines: Vec<String>,
tally: HashMap<Hatch, usize>,
}
/// A report with nothing left to add.
pub struct FinishedReport {
lines: Vec<String>,
totals: Vec<(Hatch, usize)>,
}
impl Report {
pub fn add(&mut self, line: String, hatch: Hatch) {
self.lines.push(line);
*self.tally.entry(hatch).or_insert(0) += 1;
}
pub fn finish(self) -> FinishedReport {
FinishedReport {
lines: self.lines,
totals: sorted_totals(self.tally),
}
}
}
impl fmt::Display for FinishedReport {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
for line in &self.lines {
writeln!(f, "{line}")?;
}
for (hatch, total) in &self.totals {
writeln!(f, "{total} {hatch}")?;
}
Ok(())
}
}
use anyscan::error::ScanError;
use anyscan::hatch::Hatch;
use anyscan::report::Report;
use anyscan::{Rules, default_rules, scan};
use std::process::ExitCode;
use std::{env, fs, thread};
/// One file's report lines, and the hatches found in it.
fn scan_file(path: &str, rules: &Rules) -> Result<Vec<(String, Hatch)>, ScanError> {
let source = fs::read_to_string(path)?;
Ok(scan(&source, rules)
.iter()
.map(|f| {
let line = format!("{path}:{}:{} {} ({})", f.line, f.column, f.hatch, f.text);
(line, f.hatch)
})
.collect())
}
fn run() -> Result<(), ScanError> {
let paths: Vec<String> = env::args().skip(1).collect();
if paths.is_empty() {
return Err(ScanError::Usage);
}
let rules = default_rules();
let results: Vec<_> = thread::scope(|scope| {
let workers: Vec<_> = paths
.iter()
.map(|path| scope.spawn(|| scan_file(path, &rules)))
.collect();
workers
.into_iter()
.map(|worker| worker.join().expect("a scan thread panicked"))
.collect()
});
let mut report = Report::default();
for result in results {
for (line, hatch) in result? {
report.add(line, hatch);
}
}
print!("{}", report.finish());
Ok(())
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("anyscan: {error}");
ExitCode::FAILURE
}
}
}
$ cd anyscan
$ cargo run --quiet -- sample.ts
sample.ts:2:32 as (as)
sample.ts:3:13 any (any)
sample.ts:4:14 any (any)
sample.ts:5:1 @ts-ignore (// @ts-ignore)
sample.ts:6:26 non-null ! (!)
2 any
1 as
1 non-null !
1 @ts-ignore
$ cargo test --quiet 2>&1 | grep -E "running [0-9]+ test" | tr -s " "
running 4 tests
running 0 tests
running 3 tests
running 2 tests
running 1 test
$ cargo clippy --quiet --all-targets -- -D warnings
$ cargo fmt --check
Checkpoint: the same output, and main no longer knows how totals are sorted or formatted. The last two running lines are the doc tests, three between them, and one of those passes because the code inside it fails to compile.
What makes the type-state pattern work in Rust, when the same idea is awkward in TypeScript?
You have class Admin extends User in TypeScript. Admin reuses User's name and email fields, and adds permissions and one extra method. How would you model this in Rust?
unsafe is not any; macros are not decorators
Two features that look like ones you know. One is a much narrower door than the escape hatch you are thinking of, and the other runs at a different time altogether.
You'll be able to: State what unsafe does and does not switch off, read a macro_rules macro, and call Rust from TypeScript.
Some of this chapter will make you feel at home: a type alias means what it means in TypeScript, and the ! type is your never. The two names in this lesson's title are the ones to be careful with, because the instinct you bring to each would lead you wrong.
any turns checking off. unsafe does not.
When you write as any, the type checker stops looking at that value, and whatever you do with it goes unchecked from there on. You might assume unsafe is the same kind of thing. The Book is emphatic that it is not:
doesn’t turn off the borrow checker or disable any of Rust’s other safety checks
§20.1 Performing Unsafe Superpowers lists exactly five things it permits, such as dereferencing a raw pointer and calling an unsafe function. Everything else is still checked. Moved values, mismatched types and borrow conflicts are all errors as usual inside an unsafe block, and the lab shows each one.
TypeScript's any | Rust's unsafe | |
|---|---|---|
| What it does | stops checking a value | permits five specific operations |
| Types still checked? | no, for that value | yes |
| How far it spreads | wherever the value goes | only the block it marks |
| If you are wrong | a wrong value, or a run-time error | undefined behaviour, which can be much worse |
| How often you need it | whenever it is convenient | rarely, and mostly in libraries |
That last row is the real lesson. any is a shortcut you might use to save time. unsafe is for what the compiler cannot verify, such as calling C code or building a new data structure. In this course, anyscan has none, and you may write Rust for a long time before you need any. Treat a borrow error as a reason to rethink, not as a reason to reach for unsafe.
Macros run at compile time
TypeScript decorators run when your program runs: a function receives a class or method and can wrap it. A Rust macro runs during compilation, takes your code as input, and produces more code. The Book puts it this way:
Fundamentally, macros are a way of writing code that writes other code
You have been using them since Lesson 1. #[derive(Debug)] is a macro that writes an impl. println! and vec! are macros. That is the answer to Lesson 11's question about what replaces TypeScript's computed types: code generation at compile time. A macro_rules! macro matches on the shape of its input:
macro_rules! squares {
($($n:expr),*) => {
vec![$($n * $n),*]
};
}
fn main() {
println!("{:?}", squares![2, 3, 4]);
}
You will read many more macros than you write. §20.5 Declarative Macros for General Metaprogramming introduces the syntax. Procedural macros, which is what derive is, are a much larger subject that this course does not cover. Prefer a function when a function can do it. Macros cost readers and tools more.
The parts that really do match
For the rest of the chapter your TypeScript is a good guide, and these three all compile and run:
use std::ops::Add;
type Kilometers = i32;
fn fail(message: &str) -> ! {
panic!("{message}");
}
#[derive(Debug)]
struct Point {
x: i32,
y: i32,
}
impl Add for Point {
type Output = Point;
fn add(self, other: Point) -> Point {
Point {
x: self.x + other.x,
y: self.y + other.y,
}
}
}
fn main() {
let distance: Kilometers = 5;
let plain: i32 = 5;
println!("{}", distance + plain);
println!("{:?}", Point { x: 1, y: 2 } + Point { x: 3, y: 4 });
if distance < 0 {
fail("negative distance");
}
}
| In this chapter | Your TypeScript | The difference |
|---|---|---|
type Kilometers = i32 | type Kilometers = number | none: an alias, so it mixes freely. A one-field struct is what keeps them apart, as in Lesson 6 |
!, the never type | never | the same idea, and match arms use it the same way |
impl Add for Point | no equivalent | operators are traits in Rust |
type Item in a trait | a type parameter, mostly | one implementation chooses one type, as in Iterator |
trait A: B | interface A extends B | the same idea, called a supertrait |
Treating unsafe as your escape hatch. You have used as any as a pressure valve, and it was a reasonable choice in a language that lets you. There is no such valve in Rust, and that is deliberate. If you find yourself writing unsafe to satisfy the borrow checker, you almost certainly have a design problem or need a standard library type.
Reaching for a macro as you would a decorator. Logging, validation and dependency injection through decorators are common TypeScript patterns. In Rust, ordinary functions and traits usually do the job, and they are easier for everyone to read.
Six programs. Predict which ones unsafe lets through. Most people expect it to allow more than it does.
All six programs are compiled and, where possible, run when this course is built. The last one is sound only because of an order of operations that nothing checks, which is what it means to take responsibility with unsafe.
Two small additions. First, a macro that earns its place: every rule needs the same Box::new around it. This is about the limit of what a macro should be doing.
//! Finds the places a TypeScript file escapes its type checker.
pub mod error;
pub mod finding;
pub mod hatch;
pub mod lexer;
pub mod report;
pub mod rules;
use finding::Finding;
use lexer::{Lexer, Token};
use rules::{AnyType, AsCast, NonNull, Rule, TsIgnore};
/// Rules that can be shared between threads.
pub type Rules = Vec<Box<dyn Rule + Sync>>;
macro_rules! rules {
($($rule:expr),* $(,)?) => {
vec![$(Box::new($rule) as Box<dyn Rule + Sync>),*]
};
}
/// The rules `anyscan` applies by default.
pub fn default_rules() -> Rules {
rules![AnyType, AsCast, NonNull, TsIgnore]
}
/// Scan TypeScript source, and report where it escapes the type checker.
///
/// ```
/// use anyscan::{default_rules, scan};
///
/// let found = scan("const a: any = b!.c;", &default_rules());
/// assert_eq!(found.len(), 2);
/// assert_eq!(found[0].text, "any");
/// ```
pub fn scan<'src>(source: &'src str, rules: &[Box<dyn Rule + Sync>]) -> Vec<Finding<'src>> {
let tokens: Vec<Token<'src>> = Lexer::new(source).collect();
tokens
.iter()
.enumerate()
.flat_map(|(at, token)| {
let before = at.checked_sub(1).and_then(|i| tokens.get(i));
let after = tokens.get(at + 1);
rules
.iter()
.filter(move |rule| rule.matches(before, token, after))
.map(|rule| Finding::locate(source, token.offset, rule.hatch(), token.text))
})
.collect()
}
Second, use anyscan from TypeScript, which is where this course began. The simplest bridge has no dependencies at all, because a Rust binary is just a program you can run:
import { execFileSync } from "node:child_process";
const output = execFileSync("target/debug/anyscan", ["sample.ts"], { encoding: "utf8" });
const findings = output.split("\n").filter((line) => line.includes(".ts:"));
console.log(`anyscan found ${findings.length} hatches`);
$ cd anyscan
$ cargo build --quiet
$ node report.ts
anyscan found 5 hatches
$ cargo test --quiet 2>&1 | grep -c "test result: ok"
5
$ cargo clippy --quiet --all-targets -- -D warnings
$ cargo fmt --check
For a tighter connection, napi-rs compiles Rust into a native addon that Node can load like any module. It is one way the Rust-based tools in the TypeScript ecosystem reach JavaScript. This block is the exception in this course, because it needs crates from the network, and so the build does not check it. It was built by hand on 2026-11-06, with napi 3.16.1, napi-derive 3.7.0 and napi-build 2.6.0, and Node called the function and got the right answer:
use napi_derive::napi;
#[napi]
pub fn count_any(source: String) -> u32 {
source
.split_whitespace()
.filter(|word| *word == "any")
.count() as u32
}
That crate also needed crate-type = ["cdylib"], a build.rs calling napi_build::setup(), and its built library renamed to end in .node. The official napi command-line tool handles those steps for you. WebAssembly, through wasm-bindgen, is the other common bridge, and this course has not checked it.
Checkpoint: a TypeScript program reports how many hatches anyscan found. You now have a Rust library, a Rust binary and a TypeScript caller, and that is a realistic shape for bringing Rust into a TypeScript codebase: start with one slow job.
You write let b = a; inside an unsafe block, then use a again, where a is a String. What happens?
You want a list of validated fields, each with a rule attached, and you would use a decorator such as @IsEmail() in TypeScript. What should you try first in Rust?
Where this goes next
You have now been through the whole Book, and you have written a real tool. This last lesson measures that tool against real code, gives you a checklist for your own, and is honest about what is still ahead.
You'll be able to: Audit your own Rust for TypeScript habits, measure what your tool gets wrong on real code, and say what this course did not cover.
Remember where this started: a compiler that writes JavaScript even after it has told you the program is wrong. You still know TypeScript, and that is an asset. What should have changed is that you no longer read Rust as TypeScript with extra rules. If a borrow error now makes you ask who owns this, the accent is going.
What anyscan gets wrong
A tool is only worth what you can say about its mistakes. Here is the finished anyscan, built for release, on real TypeScript: the 52 declaration files in @types/node 26.6.6, which come to 51,667 lines. TS_SAMPLE is that directory, and you should point it at a project of your own.
$ cd anyscan
$ cargo build --quiet --release
$ ./target/release/anyscan $TS_SAMPLE/*.d.ts | tail -2
568 any
36 as
$ grep -hE '^\s*(import|export)\b.* as ' $TS_SAMPLE/*.d.ts | wc -l | tr -d " "
30
So it reports 36 as casts, and about 30 lines of those files are import or export statements that rename something with as. That means most of its as findings here are false positives. They are not casts at all. Lesson 13 warned about this, and now you have a number for it. This is an unusual sample too, as declaration files have far more import lines than casts, and on application code the balance will be different.
Here are its known mistakes, all in one file:
import { readFile as read } from "node:fs";
const pattern = /"/;
const code: any = 1;
const quoted = `${value as string}`;
type Row = { any: string };
$ ./target/release/anyscan tricky.ts
tricky.ts:1:19 as (as)
1 as
Read that against the file. The as on line 1 is a false positive. Line 2's regular expression has a quote in it, and a lexer cannot easily tell a regex from a division, so it reads a string starting there and swallows everything to the end of the file. That loses any on line 3 and the real cast on line 4. Line 5's any is a property name, and the tool was right to skip it.
Three different kinds of error, one small file. A real tool would use a real parser, such as oxc or swc, which are written in Rust and exist for exactly this. Fixing the import case yourself, with one more check in AsCast, is a good first exercise. So is adding a test first.
Were the threads worth it?
Lesson 17 promised a measurement. These were measured by hand on one 10-core machine, so the build does not check them, and they are the median of 30 runs, then 15:
| Input | Threaded | One at a time | Ratio |
|---|---|---|---|
| the 52 files | 12.3 ms | 21.8 ms | 1.8× |
| the same files listed 20 times, 1,040 threads | 126.6 ms | 288.8 ms | 2.3× |
A useful result, and a long way from 10×. This is a benchmark of a small program on small files, and it did not separate reading files from scanning them, so it does not tell you where the time went. It tells you only that adding threads helped less than the core count would suggest. Measure before you assume.
The checklist
Here is every habit this course has asked you to drop. Read your own Rust against it. Clippy will catch some of these for you, and it will not catch most of them, because they are about design.
| The TypeScript habit | What to do instead | Lesson |
|---|---|---|
| Working around compiler errors | Treat each one as the next step | 1 |
mut by default, trailing return | let, and a final expression | 2 |
as between numbers | from, or try_from | 2 |
| Using a value after passing it on | Decide who owns it | 3 |
| Holding a reference while changing the container | Finish reading first | 4 |
s[i], and treating length as characters | chars(), and treat len() as bytes | 5 |
&String parameters | &str | 5 |
| A class with accessors for everything | Public fields, unless there is a rule to protect | 6 |
| Status strings and boolean flags | An enum | 7 |
_ => by reflex | Name every variant | 7 |
| Barrel files, and exporting everything | Private by default | 8 |
| A map with known keys | A struct | 9 |
A distant catch | ?, and handle it where you can | 10 |
.unwrap() while you work it out | ?, or expect with a reason | 10 |
| Type-level cleverness | Concrete types, then simple bounds | 11 |
| An interface per class | A trait, when there is a second use | 12 |
Adding 'a until the error stops | Ask what it points at, and consider owning | 13 |
| Index loops, and collecting mid-chain | Iterator chains, collecting once | 14 |
| A dependency for every small job | Check the standard library first | 15 |
Rc<RefCell<T>> by default | One owner, or indices | 16 |
| Global mutable state | Say who can touch it | 17 |
Assuming a call starts the work, and async by reflex | join, and threads for CPU work | 18 |
| Base classes | Fields, traits and enums | 19 |
unsafe as an escape hatch | Rethink the design | 20 |
What this course did not cover
This is the list that matters, because you might otherwise assume you have seen everything:
- The rest of the ecosystem.
serde,tokio,axum,clap,rayon,thiserrorandanyhoware in most real Rust projects, and this course mentioned only a few of them in passing. Pininternals, and async in depth. Lesson 18 stayed at the level of what you need to avoid mistakes. Writing your own futures in production is a separate skill.- Writing procedural macros. You can read
macro_rules!, and that is all. unsafein depth. Lesson 20 showed you where the door is, and nothing about how to go through it soundly.no_stdand embedded work. Nothing here runs without an operating system.- Performance. There was one crude benchmark, and no profiling.
- WebAssembly. It is named in Lesson 20 and has not been checked.
- Large-codebase practice. Workspaces, compile times and API evolution all look different at scale.
What this course cannot promise
The compiler checked nearly all of the code in this course, and Clippy checked most of it. The exceptions are among the blocks marked fragment, which are not compiled on their own. Neither tool can tell you whether the advice is good. Every "habit to drop", and each place that says this is the course's own reading, is judgement. No long-time Rust programmer wrote it, and none has reviewed it yet. So hold the advice more loosely than the code. If an experienced Rust programmer tells you something here is off, believe them.
The lab figures are real and reproducible where they say so, and the sizes, capacities and error messages are all specific to the versions pinned here. Rust moves on, and details may have changed by the time you read this.
Where to go next
- Finish the Book. Type out Chapter 21's web server, then look through the Appendix. Appendix D covers tools, and Appendix E explains editions.
- Read real Rust.
ripgrepis a good place to start. Compare how it handles whatanyscandoes. - Port something small and slow. Pick one job from a TypeScript project. Design around its data and ownership, and use the TypeScript only as a description of behaviour. Line-by-line ports are where the accent comes back.
- Official references. Rust by Example, the Rust Reference, the Clippy book, the Rust API Guidelines and, when you are ready for
unsafe, the Rustonomicon. Rustlings has small exercises. This course links to these and has not been checked against them.
Reading Rust as a stricter TypeScript. It was a useful way in, and this whole course has relied on it. But the comparison was scaffolding, and the idea was always to take it down. When you stop translating and start thinking about who owns what, you will not need this page any more, and that was the point.
One function, six steps. It behaves the same from start to finish. Watch where Clippy runs out of things to say, and notice that two more steps come after that.
Each step is a real program, compiled and linted when this course is built. The objections shown are what Clippy said. The explanations and the last two steps are this course's own judgement.
Your turn, and this one has no answer printed here. Fix the false positive on line 1 of tricky.ts, and start with a failing test:
#[test]
fn a_rename_in_an_import_is_not_a_cast() {
assert!(hatches("import { a as b } from \"c\";").is_empty());
}
The difficulty is that AsCast sees only the tokens either side of it, and those look the same in import { a as b } as in x as y. You will need to change what a rule gets to see, and that means thinking about ownership and lifetimes, not just about the logic.
Checkpoint: when your test passes, run anyscan on a TypeScript project of your own. Count how many anys you have been living with. Then decide which ones you would have been forced to think about in Rust.
anyscan reports 36 as casts in @types/node, and about 30 lines there rename something with as in an import or export. What should you conclude?
You are about to rewrite a TypeScript service's hot path in Rust. Which approach does this course suggest?