Ch.3 — Syntax Basics
2-1. Variables and Types
Section titled “2-1. Variables and Types”Basic Declarations
Section titled “Basic Declarations”In TypeScript, you declare variables with let and const. In Rust, both use let, but variables are immutable by default.
// TypeScriptlet count = 0; // type inference: numberconst name = "Alice"; // cannot be reassignedlet age: number = 30; // explicit type
count = 1; // OK// name = "Bob"; // error: const cannot be reassigned// Rustlet count = 0; // type inference: i32, and immutable by default!let name = "Alice"; // immutablelet age: u32 = 30; // explicit type
// count = 1; // compile error: cannot assign twice to immutable variable
let mut count = 0; // must add mut to allow mutationcount = 1; // OKKey difference: TypeScript’s
letmeans “can be reassigned.” Rust’sletmeans “cannot be reassigned” by default. In Rust, you needlet mutif you want to change a value.
Variable and Type Comparison
Section titled “Variable and Type Comparison”| Concept | TypeScript | Rust |
|---|---|---|
| Immutable variable | const x = 1 | let x = 1 |
| Mutable variable | let x = 1 | let mut x = 1 |
| Explicit type | let x: number = 1 | let x: i32 = 1 |
| Type inference | Yes (number, string, etc.) | Yes (i32, &str, etc.) |
| Global constant | const MAX = 100 | const MAX: u32 = 100 |
| Declaration before use | Optional | Required |
Numeric Types
Section titled “Numeric Types”TypeScript has a single number type. Rust has separate types based on size and signedness.
// TypeScript: one number type for everythinglet a: number = 42;let b: number = 3.14;let c: number = -10;// Rust: integers and floats are distinct, and sizes are explicitlet a: i32 = 42; // 32-bit signed integerlet b: f64 = 3.14; // 64-bit floating pointlet c: i64 = -10; // 64-bit signed integerlet d: u32 = 100; // 32-bit unsigned integer (non-negative)let e: usize = 10; // platform-sized integer (used for array indices)Shadowing: A Rust-Specific Feature
Section titled “Shadowing: A Rust-Specific Feature”In Rust, you can declare a new variable with the same name (shadowing). TypeScript doesn’t allow this within the same scope.
// TypeScript — cannot re-declare in the same scopelet value = "42";// let value = parseInt(value); // error: duplicate declarationlet numValue = parseInt(value); // have to use a different name// Rust — Shadowing: same name, even a different type is finelet value = "42";let value = value.parse::<i32>().unwrap(); // type changes from &str to i32// the previous value is gone, and the new value (i32) takes its placeprintln!("{}", value); // 42Shadowing is useful when “transforming a value that represents the same concept.” Unlike mut, it allows the type to change, and after the transformation you can no longer access the original value.
2-2. Functions
Section titled “2-2. Functions”Basic Function Syntax
Section titled “Basic Function Syntax”// TypeScriptfunction add(a: number, b: number): number { return a + b;}
// arrow functionconst multiply = (a: number, b: number): number => a * b;
// default parameterfunction greet(name: string, greeting: string = "Hello"): string { return `${greeting}, ${name}!`;}
// optional parameterfunction log(message: string, level?: string): void { console.log(`[${level ?? "INFO"}] ${message}`);}// Rustfn add(a: i32, b: i32) -> i32 { a + b // return keyword can be omitted (last expression is the return value)}
// closure (equivalent to arrow functions)let multiply = |a: i32, b: i32| -> i32 { a * b };let multiply_short = |a: i32, b: i32| a * b; // single expression, braces optional
// no default parameters → use a separate function or Optionfn greet(name: &str, greeting: &str) -> String { format!("{}, {}!", greeting, name)}
fn greet_default(name: &str) -> String { greet(name, "Hello")}
// optional parameter → use Option<T>fn log(message: &str, level: Option<&str>) { println!("[{}] {}", level.unwrap_or("INFO"), message);}
// when callinglog("Server started", Some("DEBUG"));log("Server started", None);Return Types: return vs. Last Expression
Section titled “Return Types: return vs. Last Expression”In Rust, return is only used for early returns. The last expression in a function is automatically returned.
fn classify(n: i32) -> &'static str { if n < 0 { return "negative"; // early return } if n == 0 { "zero" // no return — this is the last expression } else { "positive" }}Watch out for semicolons (
;): in Rust, a line ending with a semicolon is a “statement,” and one without is an “expression.” The last line must not have a semicolon if it’s meant to be the return value.
Closure Comparison
Section titled “Closure Comparison”// TypeScript closuresconst numbers = [1, 2, 3, 4, 5];const doubled = numbers.map(n => n * 2);const evens = numbers.filter(n => n % 2 === 0);const sum = numbers.reduce((acc, n) => acc + n, 0);// Rust closureslet numbers = vec![1, 2, 3, 4, 5];let doubled: Vec<i32> = numbers.iter().map(|n| n * 2).collect();let evens: Vec<&i32> = numbers.iter().filter(|n| *n % 2 == 0).collect();let sum: i32 = numbers.iter().sum();// orlet sum: i32 = numbers.iter().fold(0, |acc, n| acc + n);2-3. interface → struct / trait
Section titled “2-3. interface → struct / trait”TypeScript interface vs. Rust struct
Section titled “TypeScript interface vs. Rust struct”TypeScript’s interface defines the shape of an object. In Rust, data structures use struct, and behavior is separated into impl and trait.
// TypeScriptinterface User { id: number; name: string; email: string;}
interface Greetable { greet(): string;}
class User implements Greetable { constructor( public id: number, public name: string, public email: string, ) {}
greet(): string { return `Hi, I'm ${this.name}`; }}
const user = new User(1, "Alice", "alice@example.com");console.log(user.greet()); // "Hi, I'm Alice"// Rust: data (struct) and behavior (impl, trait) are separatedstruct User { id: u32, name: String, email: String,}
// method implementation (similar to class methods)impl User { // constructor pattern (new is a conventional name, not a keyword) fn new(id: u32, name: String, email: String) -> User { User { id, name, email } }}
// trait = TypeScript's interface (defines behavior)trait Greetable { fn greet(&self) -> String;}
// User implements Greetableimpl Greetable for User { fn greet(&self) -> String { format!("Hi, I'm {}", self.name) }}
let user = User::new(1, "Alice".to_string(), "alice@example.com".to_string());println!("{}", user.greet()); // "Hi, I'm Alice"Key Difference: Separating Data from Behavior
Section titled “Key Difference: Separating Data from Behavior”| Concept | TypeScript | Rust |
|---|---|---|
| Data structure | interface / class | struct |
| Define behavior | interface (method signatures) | trait |
| Implement behavior | class implements Interface | impl Trait for Struct |
| Constructor | constructor | fn new() (convention) |
this | this | self / &self / &mut self |
| Inheritance | extends | None (use composition instead) |
Implementing Multiple Traits
Section titled “Implementing Multiple Traits”// TypeScriptinterface Printable { print(): void;}interface Serializable { serialize(): string;}
class User implements Printable, Serializable { print(): void { console.log(this.name); } serialize(): string { return JSON.stringify(this); }}// Rusttrait Printable { fn print(&self);}trait Serializable { fn serialize(&self) -> String;}
impl Printable for User { fn print(&self) { println!("{}", self.name); }}impl Serializable for User { fn serialize(&self) -> String { format!(r#"{{"id":{},"name":"{}"}}"#, self.id, self.name) }}2-4. null/undefined → Option<T>
Section titled “2-4. null/undefined → Option<T>”TypeScript’s null/undefined
Section titled “TypeScript’s null/undefined”// TypeScriptfunction findUser(id: number): User | null { const user = users.find(u => u.id === id); return user ?? null;}
const user = findUser(1);
// optional chainingconst city = user?.profile?.address?.city;
// nullish coalescingconst displayName = user?.name ?? "Anonymous";
// use after null checkif (user !== null && user !== undefined) { console.log(user.name); // user is guaranteed to be User here}Rust’s Option<T>
Section titled “Rust’s Option<T>”Rust has no null. Instead, situations where a value may or may not be present are expressed with Option<T>.
// Rustfn find_user(id: u32) -> Option<User> { users.iter().find(|u| u.id == id).cloned()}
let user = find_user(1);
// branch with matchmatch user { Some(u) => println!("{}", u.name), None => println!("User not found"),}
// if let — only runs if a value is presentif let Some(u) = find_user(1) { println!("{}", u.name);}
// unwrap_or — provide a default (like nullish coalescing ??)let name = find_user(1) .map(|u| u.name.clone()) .unwrap_or_else(|| "Anonymous".to_string());
// ? operator — chains Options (similar to optional chaining)fn get_city(user_id: u32) -> Option<String> { let user = find_user(user_id)?; // returns None immediately if None let profile = user.profile?; let address = profile.address?; Some(address.city.clone())}Mapping the Concepts
Section titled “Mapping the Concepts”| TypeScript | Rust | Meaning |
|---|---|---|
T | null | Option<T> | value may be absent |
null / undefined | None | no value |
| value present | Some(value) | value present |
?. (optional chaining) | ? operator | return None early if absent |
?? "default" | .unwrap_or("default") | fallback if absent |
?? fn() | .unwrap_or_else(|| fn()) | call function if absent |
| use after null check | if let Some(x) = ... | pattern matching |
! (non-null assertion) | .unwrap() | force value present (risk of panic) |
Why is this better? In TypeScript,
string | nullcan accidentally be passed where astringis expected, and the compiler doesn’t force you to handle all paths. Rust’sOption<T>won’t compile unless you handle both theSomeandNonecases.
2-5. try/catch → Result<T, E>
Section titled “2-5. try/catch → Result<T, E>”TypeScript’s Exception Handling
Section titled “TypeScript’s Exception Handling”// TypeScriptfunction readFile(path: string): string { try { return fs.readFileSync(path, "utf-8"); } catch (e) { // e is of type unknown... we don't know what it is throw new Error(`Failed to read file: ${e}`); }}
async function processData(path: string): Promise<Data> { try { const raw = readFile(path); const json = JSON.parse(raw); // parsing can fail return validate(json); // validation can fail } catch (e) { // hard to tell where the failure occurred console.error(e); throw e; }}The problems:
eincatchis of typeunknown— you don’t know what you got- The function signature carries no information that “this function can throw”
- The compiler doesn’t enforce that errors must be handled
Rust’s Result<T, E>
Section titled “Rust’s Result<T, E>”Result<T, E> represents either success (Ok(T)) or failure (Err(E)) as a type. The function signature makes the possibility of error explicit, and the compiler forces you to handle it.
use std::fs;use std::io;use serde_json;
// error type is explicit in the function signaturefn read_file(path: &str) -> Result<String, io::Error> { fs::read_to_string(path) // returns Result<String, io::Error>}
fn parse_json(content: &str) -> Result<serde_json::Value, serde_json::Error> { serde_json::from_str(content)}
// ? operator: returns Err immediately on failure, unwraps the value on successfn process_data(path: &str) -> Result<Data, Box<dyn std::error::Error>> { let raw = read_file(path)?; // returns io::Error on failure let json = parse_json(&raw)?; // returns serde_json::Error on failure let data = validate(json)?; // returns ValidationError on failure Ok(data)}
// caller must handle the resultmatch process_data("config.json") { Ok(data) => println!("Loaded: {:?}", data), Err(e) => eprintln!("Error: {}", e),}
// or shorterlet data = process_data("config.json").expect("Failed to load config");The ? Operator: A Cleaner Alternative to Chained try/catch
Section titled “The ? Operator: A Cleaner Alternative to Chained try/catch”// TypeScript — nested try/catchasync function loadConfig(): Promise<Config> { let raw: string; try { raw = await fs.readFile("config.json", "utf-8"); } catch (e) { throw new Error(`Read failed: ${e}`); }
let parsed: unknown; try { parsed = JSON.parse(raw); } catch (e) { throw new Error(`Parse failed: ${e}`); }
return validateConfig(parsed);}// Rust — clean with the ? operatorasync fn load_config() -> Result<Config, AppError> { let raw = fs::read_to_string("config.json")?; // returns Err immediately on failure let parsed: serde_json::Value = serde_json::from_str(&raw)?; let config = validate_config(parsed)?; Ok(config)}Mapping the Concepts
Section titled “Mapping the Concepts”| TypeScript | Rust | Meaning |
|---|---|---|
try { ... } | Result<T, E> | operation that can fail |
throw new Error(...) | Err(MyError::...) | return an error |
| return success value | Ok(value) | wrap a success value |
catch (e) | match res { Err(e) => ... } | handle an error |
finally | Drop trait (automatic cleanup) | cleanup code |
| propagate error | manual throw | ? operator |
| error type | none (discovered at runtime) | explicit at compile time |
2-6. async/await
Section titled “2-6. async/await”TypeScript’s Async
Section titled “TypeScript’s Async”// TypeScript + Node.jsasync function fetchUser(id: number): Promise<User> { const response = await fetch(`/api/users/${id}`); if (!response.ok) { throw new Error(`HTTP error: ${response.status}`); } return response.json() as Promise<User>;}
async function main() { const user = await fetchUser(1); console.log(user.name);}
// Promise.all — parallel executionconst [user, posts] = await Promise.all([ fetchUser(1), fetchPosts(1),]);Rust’s Async (Tokio runtime)
Section titled “Rust’s Async (Tokio runtime)”// Rust + Tokio + reqwestuse tokio;use reqwest;
async fn fetch_user(id: u32) -> Result<User, reqwest::Error> { let url = format!("http://api/users/{}", id); let user = reqwest::get(&url) .await? // .await executes the Future .json::<User>() .await?; Ok(user)}
#[tokio::main] // macro that makes main asyncasync fn main() { let user = fetch_user(1).await.expect("Failed to fetch user"); println!("{}", user.name);}
// parallel execution — tokio::join!let (user, posts) = tokio::join!( fetch_user(1), fetch_posts(1),);Promise vs. Future
Section titled “Promise vs. Future”| Concept | TypeScript | Rust |
|---|---|---|
| Async type | Promise<T> | Future<Output = T> |
| Async function | async function | async fn |
| Awaiting | await | .await |
| Eager execution | Yes (starts on creation) | No (only runs when polled) |
| Runtime | Node.js (built-in) | Tokio, async-std, etc. (your choice) |
| Parallel execution | Promise.all() | tokio::join!() |
| Error handling | try/catch | ? + Result |
An important difference: JavaScript’s Promise starts executing as soon as it’s created. Rust’s Future does nothing until it’s polled with .await (lazy evaluation).
// In Rust, forgetting .await means nothing happenslet future = fetch_user(1); // Future is created, not yet running// if future is unused, the compiler will warn youlet user = fetch_user(1).await?; // this is what actually runs it2-7. Generics
Section titled “2-7. Generics”TypeScript Generics
Section titled “TypeScript Generics”// TypeScriptfunction identity<T>(value: T): T { return value;}
// constraintsfunction getLength<T extends { length: number }>(value: T): number { return value.length;}
// generic interfaceinterface Repository<T, ID> { findById(id: ID): Promise<T | null>; save(entity: T): Promise<T>; delete(id: ID): Promise<void>;}
// multiple constraintsfunction merge<T extends object, U extends object>(a: T, b: U): T & U { return { ...a, ...b };}Rust Generics
Section titled “Rust Generics”// Rustfn identity<T>(value: T) -> T { value}
// constraints: trait boundsfn get_length<T: HasLength>(value: T) -> usize { value.len()}
// or where clause (more readable)fn process<T, E>(value: T) -> Result<String, E>where T: Display + Clone, // T must implement Display and Clone E: std::error::Error,{ Ok(format!("{}", value))}
// generic structstruct Repository<T> { items: Vec<T>,}
impl<T: Clone> Repository<T> { fn new() -> Self { Repository { items: Vec::new() } }
fn save(&mut self, item: T) { self.items.push(item); }
fn find_by_index(&self, index: usize) -> Option<T> { self.items.get(index).cloned() }}Constraint Comparison
Section titled “Constraint Comparison”| TypeScript | Rust | Meaning |
|---|---|---|
<T extends Type> | <T: Trait> | T must implement Trait |
<T extends A & B> | <T: A + B> | T must implement both A and B |
keyof T | Not available (different approach) | Extract key types |
ReturnType<F> | Not available (type inference) | Extract return type |
Partial<T> | Manual Option<T> fields | Optional fields |
Practical Example: Generic Cache
Section titled “Practical Example: Generic Cache”// TypeScriptclass Cache<K, V> { private store = new Map<K, V>();
set(key: K, value: V): void { this.store.set(key, value); }
get(key: K): V | undefined { return this.store.get(key); }}
const cache = new Cache<string, User>();cache.set("user:1", user);// Rustuse std::collections::HashMap;
struct Cache<K, V> { store: HashMap<K, V>,}
impl<K: Eq + std::hash::Hash, V> Cache<K, V> { fn new() -> Self { Cache { store: HashMap::new() } }
fn set(&mut self, key: K, value: V) { self.store.insert(key, value); }
fn get(&self, key: &K) -> Option<&V> { self.store.get(key) }}
let mut cache: Cache<String, User> = Cache::new();cache.set("user:1".to_string(), user);To use a type as a
HashMapkey in Rust, it must implementEq + Hash. TypeScript’sMapaccepts any value as a key, but Rust enforces this constraint at compile time.
Summary
Section titled “Summary”- Rust variables are immutable by default; use
mutto allow mutation. - Functions return the last expression by default.
structandtraitseparate data from behavior.OptionandResultreplace null and exceptions.- async/await requires a runtime (such as Tokio).
Core Code
Section titled “Core Code”fn add(a: i32, b: i32) -> i32 { a + b}
fn main() { let sum = add(2, 3); println!("sum = {}", sum);}Common Mistakes
Section titled “Common Mistakes”- Forgetting that
letis immutable by default and trying to reassign. - Adding a semicolon to the last line of a function, causing the return value to disappear.
- Trying to use an
Optionvalue directly and hitting a compile error.
Exercises
Section titled “Exercises”- Convert a TypeScript
try/catchblock to useResult. - Create a simple
structwith animplblock and add a method to it.
Chapter Connections
Section titled “Chapter Connections”The previous chapter covered the differences in mental models. The next chapter dives into Ownership and Borrowing in earnest.