Agents convert-typescript-rust
Convert TypeScript code to idiomatic Rust. Use when migrating TypeScript projects to Rust, translating TypeScript patterns to idiomatic Rust, or refactoring TypeScript codebases. Extends meta-convert-dev with TypeScript-to-Rust specific patterns.
git clone https://github.com/aRustyDev/agents
T=$(mktemp -d) && git clone --depth=1 https://github.com/aRustyDev/agents "$T" && mkdir -p ~/.claude/skills && cp -r "$T/content/skills/convert-typescript-rust" ~/.claude/skills/arustydev-agents-convert-typescript-rust && rm -rf "$T"
content/skills/convert-typescript-rust/SKILL.mdConvert TypeScript to Rust
Convert TypeScript code to idiomatic Rust. This skill extends
meta-convert-dev with TypeScript-to-Rust specific type mappings, idiom translations, and tooling.
This Skill Extends
- Foundational conversion patterns (APTV workflow, testing strategies)meta-convert-dev
For general concepts like the Analyze → Plan → Transform → Validate workflow, testing strategies, and common pitfalls, see the meta-skill first.
This Skill Adds
- Type mappings: TypeScript types → Rust types
- Idiom translations: TypeScript patterns → idiomatic Rust
- Error handling: TypeScript exceptions → Rust Result types
- Async patterns: TypeScript Promise/async → Rust Future with tokio
- Memory/Ownership: JavaScript GC → Rust ownership and borrowing
This Skill Does NOT Cover
- General conversion methodology - see
meta-convert-dev - TypeScript language fundamentals - see
lang-typescript-dev - Rust language fundamentals - see
lang-rust-dev - Reverse conversion (Rust → TypeScript) - see
convert-rust-typescript
Quick Reference
| TypeScript | Rust | Notes |
|---|---|---|
| / | Owned vs borrowed |
| / | Specify precision |
| | Direct mapping |
| | Growable array |
| | Borrowed slice |
| | Nullable types |
| | Tagged union |
| | Async with tokio |
| / | Depends on usage |
| + | No inheritance |
| | Hash-based map |
| | Unique values |
| - | Avoid; use generics |
| | Unit type |
When Converting Code
- Analyze source thoroughly before writing target
- Map types first - create type equivalence table
- Preserve semantics over syntax similarity
- Adopt Rust idioms - don't write "TypeScript code in Rust syntax"
- Handle edge cases - null/undefined, error paths, resource cleanup
- Test equivalence - same inputs → same outputs
Type System Mapping
Primitive Types
| TypeScript | Rust | Notes |
|---|---|---|
| | Owned, heap-allocated UTF-8 |
| | Borrowed string slice (prefer for parameters) |
| | Default signed 32-bit integer |
| | Large integers |
| / | Unsigned integers |
| / | Floating point (f64 default) |
| / | 128-bit integers |
| | Arbitrary precision |
| | Direct mapping |
| - | Use |
| - | Use |
| - | No direct equivalent |
| - | Avoid; use generics or enums |
| - | Use generics with trait bounds |
| | Never type (unstable feature) |
| | Unit type |
Collection Types
| TypeScript | Rust | Notes |
|---|---|---|
| | Owned, growable array |
| | Borrowed slice (immutable view) |
| | Tuple (fixed size) |
| | Tuple with 3+ elements |
| | Same as |
| | Same as |
| | Hash-based map |
| | Sorted map |
| | Hash-based set |
| | Sorted set |
| | Object as map |
| for each field | All fields optional |
Composite Types
| TypeScript | Rust | Notes |
|---|---|---|
| | Data-only types |
| | Behavior contracts |
| + | Implementation |
| Composition | Rust avoids inheritance |
| | Abstract contracts |
| | Tagged union (sum type) |
| | Intersection (limited) |
| | Type alias |
| | Similar but different |
| - | No conditional types |
Generic Type Mappings
| TypeScript | Rust | Notes |
|---|---|---|
| | Unconstrained generic |
| | Trait bound |
| | Multiple trait bounds |
| | Default type parameter |
| - | No direct equivalent |
| | Generic array |
| | Generic future |
| | Immutable borrow |
| - | Manual struct creation |
| - | Manual struct creation |
Special TypeScript Types → Rust
| TypeScript | Rust | Strategy |
|---|---|---|
| Avoid | Use for known variants, generics for flexibility |
| or generics | Bounded generics safer |
| | For dynamic objects |
| / / | Depends on usage |
| method or | Associated function |
in methods | / / | Explicit receiver |
Idiom Translation
Pattern 1: Null/Undefined Handling
TypeScript:
function findUser(id: string): User | null { const user = users.find(u => u.id === id); return user ?? null; } const name = user?.name ?? "Anonymous";
Rust:
fn find_user(id: &str) -> Option<&User> { users.iter().find(|u| u.id == id) } let name = user.as_ref() .map(|u| u.name.as_str()) .unwrap_or("Anonymous");
Why this translation:
- TypeScript's
andnull
both map to Rust'sundefinedOption<T> - Option combinators (
,map
) replace optional chainingunwrap_or - Rust's borrow checker requires explicit ownership decisions (
vs&User
)User
Pattern 2: Array Methods
TypeScript:
const result = items .filter(x => x.active) .map(x => x.value) .reduce((sum, val) => sum + val, 0);
Rust:
let result: i32 = items.iter() .filter(|x| x.active) .map(|x| x.value) .sum();
Why this translation:
- Iterator adaptors are zero-cost abstractions in Rust
creates a borrowed iterator (doesn't consume the collection).iter()
is specialized and more efficient than manual reduce.sum()- Type annotation may be needed for
to infer the result typesum()
Pattern 3: Object Destructuring
TypeScript:
const { name, age } = user; const { x, y, ...rest } = point;
Rust:
let User { name, age, .. } = user; // Rest pattern not directly supported // Instead, extract what you need: let Point { x, y, .. } = point;
Why this translation:
- Rust supports struct destructuring but not rest patterns
- Use
to ignore remaining fields.. - For true "rest" behavior, manually construct a new struct
Pattern 4: Default Parameters
TypeScript:
function greet(name: string = "World"): string { return `Hello, ${name}!`; }
Rust:
fn greet(name: Option<&str>) -> String { format!("Hello, {}!", name.unwrap_or("World")) } // Or with multiple functions: fn greet_default() -> String { greet_with_name("World") } fn greet_with_name(name: &str) -> String { format!("Hello, {}!", name) } // Or using Default trait for complex types: fn process(config: Option<Config>) -> Result<()> { let config = config.unwrap_or_default(); // ... }
Why this translation:
- Rust doesn't have default parameters
- Use
to make parameters optionalOption<T> - Use
trait for complex types with sensible defaultsDefault - Consider multiple function variants for different arities
Pattern 5: Template Literals
TypeScript:
const message = `User ${user.name} has ${user.points} points`;
Rust:
let message = format!("User {} has {} points", user.name, user.points);
Why this translation:
macro provides type-safe string formattingformat!- Display trait must be implemented for custom types
- More verbose but catches type errors at compile time
Pattern 6: Object Literals
TypeScript:
const point = { x: 10, y: 20 }; const user = { name, age, greet() { return `Hello, ${this.name}`; } };
Rust:
let point = Point { x: 10, y: 20 }; // Field init shorthand let user = User { name, age }; // Methods defined in impl block struct User { name: String, age: u32, } impl User { fn greet(&self) -> String { format!("Hello, {}", self.name) } }
Why this translation:
- Rust separates data (struct) from behavior (impl)
- Methods require explicit
parameterself - No implicit
bindingthis
Pattern 7: Class Inheritance → Composition
TypeScript:
class Animal { constructor(public name: string) {} speak(): string { return "Some sound"; } } class Dog extends Animal { speak(): string { return "Woof!"; } }
Rust:
trait Animal { fn speak(&self) -> &str; } struct Dog { name: String, } impl Animal for Dog { fn speak(&self) -> &str { "Woof!" } } // For shared data, use composition: struct AnimalData { name: String, } struct Dog { data: AnimalData, } impl Dog { fn name(&self) -> &str { &self.data.name } }
Why this translation:
- Rust uses composition over inheritance
- Traits define shared behavior
- Structs can contain other structs for data sharing
- Delegation requires explicit methods
Pattern 8: Method Chaining with Builder Pattern
TypeScript:
const request = new RequestBuilder() .url("https://api.example.com") .method("POST") .header("Content-Type", "application/json") .build();
Rust:
let request = RequestBuilder::new() .url("https://api.example.com") .method("POST") .header("Content-Type", "application/json") .build(); // Builder implementation: impl RequestBuilder { fn new() -> Self { RequestBuilder::default() } fn url(mut self, url: &str) -> Self { self.url = url.to_string(); self } fn method(mut self, method: &str) -> Self { self.method = method.to_string(); self } fn build(self) -> Request { Request { /* ... */ } } }
Why this translation:
- Builder pattern is idiomatic in both languages
- Rust builders consume
and returnself
for chainingSelf - Final
consumes the builderbuild()
Pattern 9: Async Iteration
TypeScript:
async function* fetchPages(): AsyncIterable<Page> { let page = 1; while (true) { const data = await fetch(`/api/pages/${page}`); if (!data.ok) break; yield await data.json(); page++; } } for await (const page of fetchPages()) { process(page); }
Rust:
use futures::stream::{Stream, StreamExt}; use futures::stream; fn fetch_pages() -> impl Stream<Item = Page> { stream::unfold(1, |page| async move { let url = format!("/api/pages/{}", page); match reqwest::get(&url).await { Ok(resp) if resp.status().is_success() => { let data: Page = resp.json().await.ok()?; Some((data, page + 1)) } _ => None, } }) } // Consuming the stream: let mut pages = fetch_pages(); while let Some(page) = pages.next().await { process(page); }
Why this translation:
- Rust uses Stream from futures crate for async iteration
creates stateful streamsstream::unfold
pattern for consuming streamswhile let Some(...)
Pattern 10: Namespace/Module Organization
TypeScript:
// math.ts export namespace Math { export function add(a: number, b: number): number { return a + b; } export const PI = 3.14159; } // usage import { Math } from './math'; Math.add(1, 2);
Rust:
// math.rs pub mod math { pub fn add(a: i32, b: i32) -> i32 { a + b } pub const PI: f64 = 3.14159; } // usage use crate::math; math::add(1, 2); // Or with glob import: use crate::math::*; add(1, 2);
Why this translation:
- Rust modules are file-based or inline
keyword controls visibilitypub
statements import items into scopeuse
Error Handling
TypeScript Exception Model → Rust Result Type
TypeScript uses exceptions for error handling, while Rust uses the
Result<T, E> type for recoverable errors and panics for unrecoverable errors.
| TypeScript | Rust | Use Case |
|---|---|---|
| | Recoverable errors |
| | Error handling |
| | Error propagation |
| Uncaught exception | | Unrecoverable errors |
| Drop trait | Resource cleanup |
Basic Error Translation
TypeScript:
function divide(a: number, b: number): number { if (b === 0) { throw new Error("Division by zero"); } return a / b; } try { const result = divide(10, 0); console.log(result); } catch (e) { console.error("Error:", e.message); }
Rust:
fn divide(a: f64, b: f64) -> Result<f64, String> { if b == 0.0 { return Err("Division by zero".to_string()); } Ok(a / b) } match divide(10.0, 0.0) { Ok(result) => println!("{}", result), Err(e) => eprintln!("Error: {}", e), } // Or with error propagation: fn calculate() -> Result<f64, String> { let result = divide(10.0, 2.0)?; // ? propagates errors Ok(result * 2.0) }
Custom Error Types
TypeScript:
class AppError extends Error { constructor(message: string, public code: string) { super(message); this.name = "AppError"; } } class NotFoundError extends AppError { constructor(resource: string) { super(`${resource} not found`, "NOT_FOUND"); } } class ValidationError extends AppError { constructor(message: string) { super(message, "VALIDATION_ERROR"); } } function getUser(id: string): User { if (!id) { throw new ValidationError("User ID is required"); } const user = users.find(u => u.id === id); if (!user) { throw new NotFoundError("User"); } return user; }
Rust:
use thiserror::Error; #[derive(Debug, Error)] enum AppError { #[error("{resource} not found")] NotFound { resource: String }, #[error("validation failed: {message}")] Validation { message: String }, #[error(transparent)] Io(#[from] std::io::Error), #[error(transparent)] Parse(#[from] serde_json::Error), } fn get_user(id: &str) -> Result<User, AppError> { if id.is_empty() { return Err(AppError::Validation { message: "User ID is required".to_string(), }); } users.iter() .find(|u| u.id == id) .cloned() .ok_or_else(|| AppError::NotFound { resource: "User".to_string(), }) }
Why this translation:
crate provides ergonomic error typesthiserror- Enum variants replace class hierarchy
enables automatic conversion from other error types#[from]
operator works seamlessly with custom error types?
Error Context and Wrapping
TypeScript:
async function loadConfig(path: string): Promise<Config> { try { const content = await fs.readFile(path, 'utf-8'); return JSON.parse(content); } catch (e) { throw new Error(`Failed to load config from ${path}: ${e.message}`); } }
Rust:
use anyhow::{Context, Result}; async fn load_config(path: &Path) -> Result<Config> { let content = tokio::fs::read_to_string(path).await .context(format!("Failed to read config from {}", path.display()))?; let config = serde_json::from_str(&content) .context("Failed to parse config JSON")?; Ok(config) } // Alternative with custom error type: async fn load_config_custom(path: &Path) -> Result<Config, ConfigError> { let content = tokio::fs::read_to_string(path).await .map_err(|e| ConfigError::ReadFailed { path: path.to_owned(), source: e, })?; serde_json::from_str(&content) .map_err(|e| ConfigError::ParseFailed { path: path.to_owned(), source: e, }) }
Why this translation:
providesanyhow
for adding error context.context()
transforms error typesmap_err- Error chains preserve the original error
Async Patterns
Promise → Future with tokio
TypeScript uses Promises and
async/await for asynchronous operations. Rust uses Futures with async runtimes like tokio.
| TypeScript | Rust (tokio) | Notes |
|---|---|---|
| | Lazy evaluation |
| | Async function syntax |
| | Await syntax |
| | Concurrent execution |
| | First to complete |
| | Immediate future |
| | Immediate error |
| Manual Future impl | Rare |
| | Delayed execution |
Basic Async Translation
TypeScript:
async function fetchUser(id: string): Promise<User> { const response = await fetch(`/users/${id}`); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } return response.json(); } async function main() { try { const user = await fetchUser("123"); console.log(user); } catch (e) { console.error("Failed:", e); } }
Rust:
use reqwest; async fn fetch_user(id: &str) -> Result<User, reqwest::Error> { let response = reqwest::get(format!("/users/{}", id)).await?; response.error_for_status()?.json::<User>().await } #[tokio::main] async fn main() { match fetch_user("123").await { Ok(user) => println!("{:?}", user), Err(e) => eprintln!("Failed: {}", e), } }
Why this translation:
macro sets up async runtime#[tokio::main]
is a postfix operator in Rust.await
propagates errors in async context?- reqwest provides ergonomic HTTP client
Parallel Execution
TypeScript:
async function fetchAll(): Promise<[User[], Order[]]> { const [users, orders] = await Promise.all([ fetchUsers(), fetchOrders(), ]); return [users, orders]; } // With individual error handling: async function fetchAllSafe() { const results = await Promise.allSettled([ fetchUsers(), fetchOrders(), ]); results.forEach((result, i) => { if (result.status === 'rejected') { console.error(`Task ${i} failed:`, result.reason); } }); }
Rust:
async fn fetch_all() -> Result<(Vec<User>, Vec<Order>), Error> { let (users, orders) = tokio::try_join!( fetch_users(), fetch_orders(), )?; Ok((users, orders)) } // Non-failing version (both must succeed): async fn fetch_all_join() -> (Result<Vec<User>>, Result<Vec<Order>>) { tokio::join!( fetch_users(), fetch_orders(), ) } // With individual error handling: async fn fetch_all_safe() { let (users_result, orders_result) = tokio::join!( fetch_users(), fetch_orders(), ); match users_result { Ok(users) => println!("Got {} users", users.len()), Err(e) => eprintln!("Users failed: {}", e), } match orders_result { Ok(orders) => println!("Got {} orders", orders.len()), Err(e) => eprintln!("Orders failed: {}", e), } }
Why this translation:
fails fast if any future failstokio::try_join!
waits for all futures, returning individual Resultstokio::join!- More explicit about error handling strategy
Timeout and Cancellation
TypeScript:
async function fetchWithTimeout( url: string, timeoutMs: number ): Promise<Response> { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { signal: controller.signal }); return response; } finally { clearTimeout(timeout); } }
Rust:
use tokio::time::{timeout, Duration}; async fn fetch_with_timeout( url: &str, timeout_ms: u64, ) -> Result<reqwest::Response, FetchError> { timeout( Duration::from_millis(timeout_ms), reqwest::get(url) ) .await .map_err(|_| FetchError::Timeout)? .map_err(FetchError::Request) } // With select for more control: use tokio::select; async fn fetch_with_cancel( url: &str, mut cancel: tokio::sync::oneshot::Receiver<()>, ) -> Result<reqwest::Response, FetchError> { select! { result = reqwest::get(url) => { result.map_err(FetchError::Request) } _ = &mut cancel => { Err(FetchError::Cancelled) } } }
Why this translation:
provides built-in timeouttokio::time::timeout
for custom cancellation logictokio::select!- Dropping a future cancels it (structured concurrency)
Sequential Async Operations
TypeScript:
async function processItems(items: Item[]): Promise<Result[]> { const results: Result[] = []; for (const item of items) { const result = await processItem(item); results.push(result); } return results; }
Rust:
async fn process_items(items: Vec<Item>) -> Result<Vec<ItemResult>> { let mut results = Vec::new(); for item in items { let result = process_item(item).await?; results.push(result); } Ok(results) } // Or using futures::stream for better control: use futures::stream::{self, StreamExt}; async fn process_items_stream(items: Vec<Item>) -> Result<Vec<ItemResult>> { stream::iter(items) .then(|item| process_item(item)) .collect::<Vec<_>>() .await }
Why this translation:
- Direct for loop works for sequential processing
provides combinators for complex flowsfutures::stream
operator works in async context?
Memory & Ownership
TypeScript GC → Rust Ownership
TypeScript relies on garbage collection, while Rust uses ownership and borrowing for memory safety.
| Concept | TypeScript | Rust |
|---|---|---|
| Memory allocation | Automatic (GC) | Explicit (ownership) |
| Sharing references | Freely shared | Borrowed (&) or shared (Arc) |
| Mutation | Mutable by default | Immutable by default () |
| Lifetime | GC determines | Compile-time lifetimes |
| Resource cleanup | Finalizers (unreliable) | RAII (Drop trait) |
| Circular references | Allowed (ref counting handles) | Prevented by borrow checker |
Ownership Decision Tree
When converting TypeScript to Rust: 1. Is this data shared across components? ├─ YES → Consider Arc<T> (thread-safe) or Rc<T> (single-thread) └─ NO → Single owner, use moves 2. Is this data mutated by multiple parts? ├─ YES → Arc<Mutex<T>> or Arc<RwLock<T>> └─ NO → Immutable borrows (&T) 3. Does this data outlive its creator? ├─ YES → Return owned value or use 'static └─ NO → Return borrowed reference (&T) 4. Is this a callback that captures environment? ├─ YES → Use closures with appropriate captures └─ NO → Function pointer
Pattern: Sharing Data
TypeScript:
class Cache { private data: Map<string, User> = new Map(); get(id: string): User | undefined { return this.data.get(id); } set(id: string, user: User): void { this.data.set(id, user); } } // Multiple references to the same cache const cache = new Cache(); const service1 = new UserService(cache); const service2 = new OrderService(cache);
Rust:
use std::collections::HashMap; use std::sync::{Arc, RwLock}; struct Cache { data: Arc<RwLock<HashMap<String, User>>>, } impl Cache { fn new() -> Self { Cache { data: Arc::new(RwLock::new(HashMap::new())), } } fn get(&self, id: &str) -> Option<User> { self.data.read().unwrap() .get(id) .cloned() } fn set(&self, id: String, user: User) { self.data.write().unwrap() .insert(id, user); } } impl Clone for Cache { fn clone(&self) -> Self { Cache { data: Arc::clone(&self.data), } } } // Sharing cache across services let cache = Cache::new(); let service1 = UserService::new(cache.clone()); let service2 = OrderService::new(cache.clone());
Why this translation:
enables shared ownership across threadsArc
allows multiple readers or single writerRwLock
on.clone()
increments reference count (cheap)Arc- Explicit locking makes synchronization visible
Pattern: Builder Pattern (Consuming self)
TypeScript:
class RequestBuilder { private url?: string; private method?: string; private headers: Record<string, string> = {}; setUrl(url: string): this { this.url = url; return this; } setMethod(method: string): this { this.method = method; return this; } build(): Request { if (!this.url) throw new Error("URL is required"); return new Request(this.url, this.method, this.headers); } }
Rust:
struct RequestBuilder { url: Option<String>, method: Option<String>, headers: HashMap<String, String>, } impl RequestBuilder { fn new() -> Self { RequestBuilder { url: None, method: None, headers: HashMap::new(), } } fn url(mut self, url: impl Into<String>) -> Self { self.url = Some(url.into()); self } fn method(mut self, method: impl Into<String>) -> Self { self.method = Some(method.into()); self } fn build(self) -> Result<Request, &'static str> { let url = self.url.ok_or("URL is required")?; Ok(Request { url, method: self.method.unwrap_or_else(|| "GET".to_string()), headers: self.headers, }) } } // Usage: let request = RequestBuilder::new() .url("https://api.example.com") .method("POST") .build()?;
Why this translation:
- Methods take
by value and returnself
for chainingSelf - Final
consumes the builderbuild() - Can't accidentally reuse builder after building
Pattern: RAII Resource Management
TypeScript:
class FileHandle { private fd: number; constructor(path: string) { this.fd = fs.openSync(path, 'r'); } read(): Buffer { return fs.readFileSync(this.fd); } close(): void { fs.closeSync(this.fd); } } // Manual cleanup required: const file = new FileHandle('data.txt'); try { const data = file.read(); process(data); } finally { file.close(); }
Rust:
use std::fs::File; use std::io::Read; struct FileHandle { file: File, } impl FileHandle { fn new(path: &str) -> std::io::Result<Self> { Ok(FileHandle { file: File::open(path)?, }) } fn read(&mut self) -> std::io::Result<Vec<u8>> { let mut buffer = Vec::new(); self.file.read_to_end(&mut buffer)?; Ok(buffer) } } // Drop trait automatically closes file impl Drop for FileHandle { fn drop(&mut self) { // File's Drop is called automatically println!("FileHandle dropped, file closed"); } } // No explicit close needed: { let mut file = FileHandle::new("data.txt")?; let data = file.read()?; process(data); } // file automatically closed here
Why this translation:
- Drop trait ensures cleanup happens automatically
- Scope-based resource management (RAII)
- No need for try/finally
- Resources cleaned up in reverse order of creation
Pattern: Avoiding Clones
TypeScript (doesn't apply):
function processUsers(users: User[]): void { // TypeScript freely shares references for (const user of users) { analyzeUser(user); validateUser(user); saveUser(user); } }
Rust (inefficient):
// ❌ Unnecessary cloning fn process_users_bad(users: Vec<User>) { for user in users.clone() { analyze_user(&user.clone()); validate_user(&user.clone()); save_user(&user.clone()); } }
Rust (efficient):
// ✓ Use references fn process_users(users: &[User]) { for user in users { analyze_user(user); validate_user(user); save_user(user); } } fn analyze_user(user: &User) { /* ... */ } fn validate_user(user: &User) { /* ... */ } fn save_user(user: &User) { /* ... */ }
Why this matters:
- Cloning in Rust is explicit and potentially expensive
- Prefer borrowing (
) when you don't need ownership&T - Use slices (
) instead of owned vectors when possible&[T]
Common Pitfalls
1. Over-cloning to Satisfy Borrow Checker
Problem: Coming from TypeScript, new Rust developers often clone excessively to avoid borrow checker errors.
Example:
// ❌ Bad: Cloning everything fn get_full_name_bad(user: &User) -> String { let first = user.first_name.clone(); let last = user.last_name.clone(); format!("{} {}", first, last) } // ✓ Good: Borrow instead fn get_full_name(user: &User) -> String { format!("{} {}", user.first_name, user.last_name) }
Solution: Learn to work with references. Clone only when you truly need owned data.
2. Fighting the Borrow Checker with Mutation
Problem: Trying to mutate data while holding immutable borrows.
Example:
// ❌ Won't compile fn process_bad(items: &mut Vec<Item>) { for item in items.iter() { // Immutable borrow items.push(item.clone()); // Error: mutable borrow while immutable borrow exists } } // ✓ Good: Collect then extend fn process_good(items: &mut Vec<Item>) { let to_add: Vec<Item> = items.iter() .map(|item| item.clone()) .collect(); items.extend(to_add); }
Solution: Separate reading and writing phases, or use indices instead of iterators.
3. Misunderstanding String vs &str
Problem: Using
String everywhere when &str would be more appropriate.
Example:
// ❌ Bad: Forces caller to own strings fn greet_bad(name: String) -> String { format!("Hello, {}!", name) } // ✓ Good: Accepts borrowed strings fn greet(name: &str) -> String { format!("Hello, {}!", name) } // Usage: let name = "Alice"; greet(name); // Works with &str greet(&String::from("Bob")); // Also works with String
Solution: Use
&str for parameters unless you need to own the string.
4. Ignoring Error Types
Problem: Using
String for all errors instead of proper error types.
Example:
// ❌ Bad: String errors fn parse_config_bad(path: &str) -> Result<Config, String> { let content = std::fs::read_to_string(path) .map_err(|e| e.to_string())?; serde_json::from_str(&content) .map_err(|e| e.to_string()) } // ✓ Good: Proper error types use thiserror::Error; #[derive(Debug, Error)] enum ConfigError { #[error("failed to read config file")] Io(#[from] std::io::Error), #[error("failed to parse config")] Parse(#[from] serde_json::Error), } fn parse_config(path: &str) -> Result<Config, ConfigError> { let content = std::fs::read_to_string(path)?; let config = serde_json::from_str(&content)?; Ok(config) }
Solution: Use
thiserror or anyhow for proper error handling.
5. Not Leveraging Pattern Matching
Problem: Using if/else chains instead of pattern matching.
Example:
// ❌ Bad: Nested if/else fn handle_response_bad(response: Response) -> String { if response.status == 200 { response.body } else if response.status == 404 { "Not found".to_string() } else if response.status >= 500 { "Server error".to_string() } else { "Unknown error".to_string() } } // ✓ Good: Pattern matching fn handle_response(response: Response) -> String { match response.status { 200 => response.body, 404 => "Not found".to_string(), 500..=599 => "Server error".to_string(), _ => "Unknown error".to_string(), } }
Solution: Use
match for clarity and exhaustiveness checking.
6. Not Using Iterator Combinators
Problem: Using manual loops when iterator methods would be clearer.
Example:
// ❌ Bad: Manual loop fn sum_active_values_bad(items: &[Item]) -> i32 { let mut sum = 0; for item in items { if item.active { sum += item.value; } } sum } // ✓ Good: Iterator combinators fn sum_active_values(items: &[Item]) -> i32 { items.iter() .filter(|item| item.active) .map(|item| item.value) .sum() }
Solution: Learn iterator methods for cleaner, more functional code.
7. Forgetting to Handle Option/Result
Problem: Using
unwrap() in production code.
Example:
// ❌ Bad: Will panic if None fn get_user_name_bad(users: &[User], id: &str) -> String { users.iter() .find(|u| u.id == id) .unwrap() // Panics if not found! .name .clone() } // ✓ Good: Proper error handling fn get_user_name(users: &[User], id: &str) -> Option<String> { users.iter() .find(|u| u.id == id) .map(|u| u.name.clone()) } // Or with Result: fn get_user_name_result(users: &[User], id: &str) -> Result<String, UserError> { users.iter() .find(|u| u.id == id) .map(|u| u.name.clone()) .ok_or(UserError::NotFound) }
Solution: Use
?, map, and_then, or match instead of unwrap.
8. Misunderstanding Async Runtimes
Problem: Forgetting to add
#[tokio::main] or trying to call async from sync.
Example:
// ❌ Bad: Can't await without async runtime fn main() { let result = fetch_data().await; // Error: can't await outside async } // ✓ Good: Use tokio::main #[tokio::main] async fn main() { let result = fetch_data().await; } // For sync code calling async: fn sync_wrapper() -> Result<Data> { tokio::runtime::Runtime::new() .unwrap() .block_on(async { fetch_data().await }) }
Solution: Use
#[tokio::main] for main, or create a runtime for sync/async boundaries.
Tooling
TypeScript → Rust Transpilers
| Tool | Status | Notes |
|---|---|---|
| ts2rs | Experimental | Limited scope, not production-ready |
| Manual conversion | Recommended | No mature automated tool exists |
Recommendation: Manual conversion following this skill guide.
Code Analysis Tools
| Category | TypeScript | Rust Equivalent |
|---|---|---|
| AST Parsing | compiler API, | , |
| Linting | ESLint | Clippy (built-in) |
| Formatting | Prettier | rustfmt (built-in) |
| Type Checking | tsc | rustc |
| Testing | Jest, Vitest | cargo test (built-in) |
| Coverage | c8, nyc | cargo-tarpaulin, cargo-llvm-cov |
| Benchmarking | Benchmark.js | Criterion |
Helpful Rust Crates for TypeScript Patterns
| Pattern | TypeScript | Rust Crate | Notes |
|---|---|---|---|
| JSON | | | Serialization/deserialization |
| HTTP Client | , | | Async HTTP client |
| HTTP Server | Express, Fastify | , | Web frameworks |
| Async Runtime | Built-in | , | Required for async |
| Error Handling | - | , | Ergonomic errors |
| CLI Parsing | , | | Command-line parser |
| Logging | , | , | Structured logging |
| Date/Time | , | | Date manipulation |
| UUID | | | UUID generation |
| Regex | Built-in | | Regular expressions |
| Environment | | | .env file loading |
| Testing | | , | Enhanced testing |
| Mocking | | | Mock objects |
Development Workflow
| Stage | Command |
|---|---|
| Check syntax | |
| Run tests | |
| Run with output | |
| Lint | |
| Format | |
| Benchmark | |
| Documentation | |
| Watch mode | |
Examples
Example 1: Simple - Basic CRUD Operations
Before (TypeScript):
interface User { id: string; name: string; email: string; } class UserRepository { private users: Map<string, User> = new Map(); create(user: User): void { this.users.set(user.id, user); } get(id: string): User | undefined { return this.users.get(id); } update(id: string, updates: Partial<User>): boolean { const user = this.users.get(id); if (!user) return false; this.users.set(id, { ...user, ...updates }); return true; } delete(id: string): boolean { return this.users.delete(id); } }
After (Rust):
use std::collections::HashMap; #[derive(Debug, Clone)] struct User { id: String, name: String, email: String, } struct UserRepository { users: HashMap<String, User>, } impl UserRepository { fn new() -> Self { UserRepository { users: HashMap::new(), } } fn create(&mut self, user: User) { self.users.insert(user.id.clone(), user); } fn get(&self, id: &str) -> Option<&User> { self.users.get(id) } fn update(&mut self, id: &str, name: Option<String>, email: Option<String>) -> bool { if let Some(user) = self.users.get_mut(id) { if let Some(n) = name { user.name = n; } if let Some(e) = email { user.email = e; } true } else { false } } fn delete(&mut self, id: &str) -> bool { self.users.remove(id).is_some() } }
Example 2: Medium - HTTP API Client with Error Handling
Before (TypeScript):
interface ApiResponse<T> { data?: T; error?: string; } class ApiError extends Error { constructor( message: string, public statusCode: number, public response?: any ) { super(message); } } class ApiClient { constructor(private baseUrl: string) {} async get<T>(path: string): Promise<T> { try { const response = await fetch(`${this.baseUrl}${path}`); if (!response.ok) { throw new ApiError( `HTTP ${response.status}`, response.status, await response.text() ); } const data: ApiResponse<T> = await response.json(); if (data.error) { throw new ApiError(data.error, response.status); } if (!data.data) { throw new ApiError("No data in response", response.status); } return data.data; } catch (error) { if (error instanceof ApiError) { throw error; } throw new ApiError(`Network error: ${error.message}`, 0); } } async post<T, U>(path: string, body: T): Promise<U> { const response = await fetch(`${this.baseUrl}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!response.ok) { throw new ApiError(`HTTP ${response.status}`, response.status); } const data: ApiResponse<U> = await response.json(); if (data.error || !data.data) { throw new ApiError(data.error || "Invalid response", response.status); } return data.data; } }
After (Rust):
use reqwest; use serde::{Deserialize, Serialize}; use thiserror::Error; #[derive(Debug, Deserialize)] struct ApiResponse<T> { data: Option<T>, error: Option<String>, } #[derive(Debug, Error)] enum ApiError { #[error("HTTP error {status}: {message}")] Http { status: u16, message: String, response: Option<String>, }, #[error("API error: {0}")] Api(String), #[error("Network error: {0}")] Network(#[from] reqwest::Error), #[error("No data in response")] NoData, } struct ApiClient { base_url: String, client: reqwest::Client, } impl ApiClient { fn new(base_url: impl Into<String>) -> Self { ApiClient { base_url: base_url.into(), client: reqwest::Client::new(), } } async fn get<T>(&self, path: &str) -> Result<T, ApiError> where T: for<'de> Deserialize<'de>, { let url = format!("{}{}", self.base_url, path); let response = self.client.get(&url).send().await?; let status = response.status(); if !status.is_success() { let text = response.text().await.ok(); return Err(ApiError::Http { status: status.as_u16(), message: format!("HTTP {}", status), response: text, }); } let api_response: ApiResponse<T> = response.json().await?; if let Some(error) = api_response.error { return Err(ApiError::Api(error)); } api_response.data.ok_or(ApiError::NoData) } async fn post<T, U>(&self, path: &str, body: &T) -> Result<U, ApiError> where T: Serialize, U: for<'de> Deserialize<'de>, { let url = format!("{}{}", self.base_url, path); let response = self.client .post(&url) .json(body) .send() .await?; let status = response.status(); if !status.is_success() { return Err(ApiError::Http { status: status.as_u16(), message: format!("HTTP {}", status), response: None, }); } let api_response: ApiResponse<U> = response.json().await?; if let Some(error) = api_response.error { return Err(ApiError::Api(error)); } api_response.data.ok_or(ApiError::NoData) } } // Usage: #[tokio::main] async fn main() -> Result<(), ApiError> { let client = ApiClient::new("https://api.example.com"); let user: User = client.get("/users/123").await?; println!("User: {:?}", user); let new_user = CreateUserRequest { name: "Alice".to_string(), email: "alice@example.com".to_string(), }; let created: User = client.post("/users", &new_user).await?; println!("Created: {:?}", created); Ok(()) }
Example 3: Complex - Event-Driven Architecture with Async Streams
Before (TypeScript):
interface Event { id: string; type: string; timestamp: Date; data: any; } type EventHandler<T = any> = (event: Event) => Promise<void>; class EventBus { private handlers: Map<string, Set<EventHandler>> = new Map(); private eventQueue: Event[] = []; private processing = false; subscribe(eventType: string, handler: EventHandler): () => void { if (!this.handlers.has(eventType)) { this.handlers.set(eventType, new Set()); } this.handlers.get(eventType)!.add(handler); // Return unsubscribe function return () => { const handlers = this.handlers.get(eventType); if (handlers) { handlers.delete(handler); } }; } async publish(event: Event): Promise<void> { this.eventQueue.push(event); if (!this.processing) { await this.processQueue(); } } private async processQueue(): Promise<void> { this.processing = true; while (this.eventQueue.length > 0) { const event = this.eventQueue.shift()!; const handlers = this.handlers.get(event.type); if (handlers) { // Process handlers in parallel await Promise.all( Array.from(handlers).map(handler => handler(event).catch(error => console.error(`Handler error for ${event.type}:`, error) ) ) ); } } this.processing = false; } async publishAndWait(event: Event): Promise<void> { const handlers = this.handlers.get(event.type); if (handlers) { await Promise.all( Array.from(handlers).map(handler => handler(event)) ); } } } // Usage example: class UserService { constructor(private eventBus: EventBus) {} async createUser(name: string, email: string): Promise<User> { const user = { id: generateId(), name, email, createdAt: new Date() }; await this.eventBus.publish({ id: generateId(), type: 'user.created', timestamp: new Date(), data: user, }); return user; } } class EmailService { constructor(eventBus: EventBus) { eventBus.subscribe('user.created', async (event) => { const user = event.data; await this.sendWelcomeEmail(user.email); }); } private async sendWelcomeEmail(email: string): Promise<void> { console.log(`Sending welcome email to ${email}`); // Send email... } } class AnalyticsService { constructor(eventBus: EventBus) { eventBus.subscribe('user.created', async (event) => { await this.trackUserCreation(event.data); }); } private async trackUserCreation(user: any): Promise<void> { console.log(`Tracking user creation: ${user.id}`); // Track analytics... } }
After (Rust):
use std::collections::HashMap; use std::sync::Arc; use tokio::sync::{mpsc, RwLock}; use serde::{Deserialize, Serialize}; use chrono::{DateTime, Utc}; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize)] struct Event { id: String, event_type: String, timestamp: DateTime<Utc>, data: serde_json::Value, } type EventHandler = Arc<dyn Fn(Event) -> BoxFuture<'static, ()> + Send + Sync>; type BoxFuture<'a, T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>; struct EventBus { handlers: Arc<RwLock<HashMap<String, Vec<EventHandler>>>>, tx: mpsc::UnboundedSender<Event>, } impl EventBus { fn new() -> Self { let (tx, mut rx) = mpsc::unbounded_channel::<Event>(); let handlers: Arc<RwLock<HashMap<String, Vec<EventHandler>>>> = Arc::new(RwLock::new(HashMap::new())); let handlers_clone = Arc::clone(&handlers); // Spawn background task to process events tokio::spawn(async move { while let Some(event) = rx.recv().await { let handlers_map = handlers_clone.read().await; if let Some(event_handlers) = handlers_map.get(&event.event_type) { // Process handlers in parallel let futures: Vec<_> = event_handlers .iter() .map(|handler| { let event = event.clone(); let handler = Arc::clone(handler); tokio::spawn(async move { handler(event).await; }) }) .collect(); // Wait for all handlers to complete for future in futures { if let Err(e) = future.await { eprintln!("Handler error: {:?}", e); } } } } }); EventBus { handlers, tx } } async fn subscribe<F, Fut>(&self, event_type: impl Into<String>, handler: F) where F: Fn(Event) -> Fut + Send + Sync + 'static, Fut: std::future::Future<Output = ()> + Send + 'static, { let event_type = event_type.into(); let handler: EventHandler = Arc::new(move |event| { Box::pin(handler(event)) }); let mut handlers = self.handlers.write().await; handlers .entry(event_type) .or_insert_with(Vec::new) .push(handler); } fn publish(&self, event: Event) -> Result<(), mpsc::error::SendError<Event>> { self.tx.send(event) } async fn publish_and_wait(&self, event: Event) { let handlers_map = self.handlers.read().await; if let Some(event_handlers) = handlers_map.get(&event.event_type) { let futures: Vec<_> = event_handlers .iter() .map(|handler| { let event = event.clone(); let handler = Arc::clone(handler); async move { handler(event).await } }) .collect(); futures::future::join_all(futures).await; } } } impl Clone for EventBus { fn clone(&self) -> Self { EventBus { handlers: Arc::clone(&self.handlers), tx: self.tx.clone(), } } } // Domain types #[derive(Debug, Clone, Serialize, Deserialize)] struct User { id: String, name: String, email: String, created_at: DateTime<Utc>, } struct UserService { event_bus: EventBus, } impl UserService { fn new(event_bus: EventBus) -> Self { UserService { event_bus } } async fn create_user(&self, name: String, email: String) -> User { let user = User { id: Uuid::new_v4().to_string(), name, email, created_at: Utc::now(), }; let event = Event { id: Uuid::new_v4().to_string(), event_type: "user.created".to_string(), timestamp: Utc::now(), data: serde_json::to_value(&user).unwrap(), }; self.event_bus.publish(event).ok(); user } } struct EmailService; impl EmailService { fn new(event_bus: EventBus) -> Self { let service = EmailService; tokio::spawn(async move { event_bus.subscribe("user.created", |event| async move { if let Ok(user) = serde_json::from_value::<User>(event.data) { EmailService::send_welcome_email(&user.email).await; } }).await; }); service } async fn send_welcome_email(email: &str) { println!("Sending welcome email to {}", email); // Send email... } } struct AnalyticsService; impl AnalyticsService { fn new(event_bus: EventBus) -> Self { let service = AnalyticsService; tokio::spawn(async move { event_bus.subscribe("user.created", |event| async move { if let Ok(user) = serde_json::from_value::<User>(event.data) { AnalyticsService::track_user_creation(&user).await; } }).await; }); service } async fn track_user_creation(user: &User) { println!("Tracking user creation: {}", user.id); // Track analytics... } } // Usage: #[tokio::main] async fn main() { let event_bus = EventBus::new(); let _email_service = EmailService::new(event_bus.clone()); let _analytics_service = AnalyticsService::new(event_bus.clone()); let user_service = UserService::new(event_bus.clone()); let user = user_service.create_user( "Alice".to_string(), "alice@example.com".to_string(), ).await; println!("Created user: {:?}", user); // Give handlers time to process tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; }
See Also
For more examples and patterns, see:
- Foundational patterns with cross-language examplesmeta-convert-dev
- TypeScript development patternslang-typescript-dev
- Rust development patternslang-rust-dev
- Similar GC → ownership conversion patternsconvert-python-rust
- Similar concurrency model translationsconvert-golang-rust