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.

install
source · Clone the upstream repo
git clone https://github.com/aRustyDev/agents
Claude Code · Install into ~/.claude/skills/
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"
manifest: content/skills/convert-typescript-rust/SKILL.md
source content

Convert 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

  • meta-convert-dev
    - Foundational conversion patterns (APTV workflow, testing strategies)

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

TypeScriptRustNotes
string
String
/
&str
Owned vs borrowed
number
i32
/
f64
Specify precision
boolean
bool
Direct mapping
T[]
Vec<T>
Growable array
readonly T[]
&[T]
Borrowed slice
T | null
Option<T>
Nullable types
T | U
enum { A(T), B(U) }
Tagged union
Promise<T>
Future<Output=T>
Async with tokio
interface X
struct X
/
trait X
Depends on usage
class X
struct X
+
impl
No inheritance
Map<K, V>
HashMap<K, V>
Hash-based map
Set<T>
HashSet<T>
Unique values
any
-Avoid; use generics
void
()
Unit type

When Converting Code

  1. Analyze source thoroughly before writing target
  2. Map types first - create type equivalence table
  3. Preserve semantics over syntax similarity
  4. Adopt Rust idioms - don't write "TypeScript code in Rust syntax"
  5. Handle edge cases - null/undefined, error paths, resource cleanup
  6. Test equivalence - same inputs → same outputs

Type System Mapping

Primitive Types

TypeScriptRustNotes
string
String
Owned, heap-allocated UTF-8
string
&str
Borrowed string slice (prefer for parameters)
number
i32
Default signed 32-bit integer
number
i64
Large integers
number
u32
/
u64
Unsigned integers
number
f32
/
f64
Floating point (f64 default)
bigint
i128
/
u128
128-bit integers
bigint
num_bigint::BigInt
Arbitrary precision
boolean
bool
Direct mapping
null
-Use
Option<T>
undefined
-Use
Option<T>
symbol
-No direct equivalent
any
-Avoid; use generics or enums
unknown
-Use generics with trait bounds
never
!
Never type (unstable feature)
void
()
Unit type

Collection Types

TypeScriptRustNotes
T[]
Vec<T>
Owned, growable array
readonly T[]
&[T]
Borrowed slice (immutable view)
[T, U]
(T, U)
Tuple (fixed size)
[T, U, V]
(T, U, V)
Tuple with 3+ elements
Array<T>
Vec<T>
Same as
T[]
ReadonlyArray<T>
&[T]
Same as
readonly T[]
Map<K, V>
HashMap<K, V>
Hash-based map
Map<K, V>
BTreeMap<K, V>
Sorted map
Set<T>
HashSet<T>
Hash-based set
Set<T>
BTreeSet<T>
Sorted set
Record<K, V>
HashMap<K, V>
Object as map
Partial<T>
Option<T>
for each field
All fields optional

Composite Types

TypeScriptRustNotes
interface X { ... }
struct X { ... }
Data-only types
interface X { method(): T }
trait X { fn method(&self) -> T }
Behavior contracts
class X implements Y
struct X
+
impl Y for X
Implementation
class X extends Y
CompositionRust avoids inheritance
abstract class X
trait X
Abstract contracts
T | U
enum { A(T), B(U) }
Tagged union (sum type)
T & U
struct { ...T, ...U }
Intersection (limited)
type X = ...
type X = ...
Type alias
enum X { A, B }
enum X { A, B }
Similar but different
T extends U ? V : W
-No conditional types

Generic Type Mappings

TypeScriptRustNotes
<T>
<T>
Unconstrained generic
<T extends U>
<T: U>
Trait bound
<T extends A & B>
<T: A + B>
Multiple trait bounds
<T = Default>
<T = Default>
Default type parameter
<T extends keyof U>
-No direct equivalent
Array<T>
Vec<T>
Generic array
Promise<T>
Future<Output = T>
Generic future
Readonly<T>
&T
Immutable borrow
Pick<T, K>
-Manual struct creation
Omit<T, K>
-Manual struct creation

Special TypeScript Types → Rust

TypeScriptRustStrategy
any
AvoidUse
enum
for known variants, generics for flexibility
unknown
T: ?Sized
or generics
Bounded generics safer
object
HashMap<String, Value>
For dynamic objects
Function
Fn()
/
FnMut()
/
FnOnce()
Depends on usage
constructor
new()
method or
Default
Associated function
this
in methods
&self
/
&mut self
/
self
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
    null
    and
    undefined
    both map to Rust's
    Option<T>
  • Option combinators (
    map
    ,
    unwrap_or
    ) replace optional chaining
  • Rust's borrow checker requires explicit ownership decisions (
    &User
    vs
    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
  • .iter()
    creates a borrowed iterator (doesn't consume the collection)
  • .sum()
    is specialized and more efficient than manual reduce
  • Type annotation may be needed for
    sum()
    to infer the result type

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
    Option<T>
    to make parameters optional
  • Use
    Default
    trait for complex types with sensible defaults
  • 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:

  • format!
    macro provides type-safe string formatting
  • 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
    self
    parameter
  • No implicit
    this
    binding

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
    self
    and return
    Self
    for chaining
  • Final
    build()
    consumes the builder

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
  • stream::unfold
    creates stateful streams
  • while let Some(...)
    pattern for consuming streams

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
  • pub
    keyword controls visibility
  • use
    statements import items into scope

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.

TypeScriptRustUse Case
throw new Error()
return Err(...)
Recoverable errors
try/catch
match result { Ok(..) => .., Err(..) => .. }
Error handling
try/catch
result?
Error propagation
Uncaught exception
panic!()
Unrecoverable errors
finally
Drop traitResource 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:

  • thiserror
    crate provides ergonomic error types
  • Enum variants replace class hierarchy
  • #[from]
    enables automatic conversion from other error types
  • ?
    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:

  • anyhow
    provides
    .context()
    for adding error context
  • map_err
    transforms error types
  • 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.

TypeScriptRust (tokio)Notes
Promise<T>
Future<Output = T>
Lazy evaluation
async function
async fn
Async function syntax
await promise
promise.await
Await syntax
Promise.all([...])
tokio::join!(...)
Concurrent execution
Promise.race([...])
tokio::select!
First to complete
Promise.resolve(x)
async { x }
Immediate future
Promise.reject(e)
async { Err(e) }
Immediate error
new Promise((resolve, reject) => ...)
Manual Future implRare
setTimeout
tokio::time::sleep
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:

  • #[tokio::main]
    macro sets up async runtime
  • .await
    is a postfix operator in Rust
  • ?
    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:

  • tokio::try_join!
    fails fast if any future fails
  • tokio::join!
    waits for all futures, returning individual Results
  • 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:

  • tokio::time::timeout
    provides built-in timeout
  • tokio::select!
    for custom cancellation logic
  • 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
  • futures::stream
    provides combinators for complex flows
  • ?
    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.

ConceptTypeScriptRust
Memory allocationAutomatic (GC)Explicit (ownership)
Sharing referencesFreely sharedBorrowed (&) or shared (Arc)
MutationMutable by defaultImmutable by default (
mut
)
LifetimeGC determinesCompile-time lifetimes
Resource cleanupFinalizers (unreliable)RAII (Drop trait)
Circular referencesAllowed (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:

  • Arc
    enables shared ownership across threads
  • RwLock
    allows multiple readers or single writer
  • .clone()
    on
    Arc
    increments reference count (cheap)
  • 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
    self
    by value and return
    Self
    for chaining
  • Final
    build()
    consumes the builder
  • 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 (
    &T
    ) when you don't need ownership
  • Use slices (
    &[T]
    ) instead of owned vectors when possible

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

ToolStatusNotes
ts2rsExperimentalLimited scope, not production-ready
Manual conversionRecommendedNo mature automated tool exists

Recommendation: Manual conversion following this skill guide.

Code Analysis Tools

CategoryTypeScriptRust Equivalent
AST Parsing
typescript
compiler API,
ts-morph
syn
,
proc-macro2
LintingESLintClippy (built-in)
FormattingPrettierrustfmt (built-in)
Type Checkingtscrustc
TestingJest, Vitestcargo test (built-in)
Coveragec8, nyccargo-tarpaulin, cargo-llvm-cov
BenchmarkingBenchmark.jsCriterion

Helpful Rust Crates for TypeScript Patterns

PatternTypeScriptRust CrateNotes
JSON
JSON.parse()
serde_json
Serialization/deserialization
HTTP Client
fetch
,
axios
reqwest
Async HTTP client
HTTP ServerExpress, Fastify
axum
,
actix-web
Web frameworks
Async RuntimeBuilt-in
tokio
,
async-std
Required for async
Error Handling-
thiserror
,
anyhow
Ergonomic errors
CLI Parsing
commander
,
yargs
clap
Command-line parser
Logging
winston
,
pino
tracing
,
log
Structured logging
Date/Time
Date
,
date-fns
chrono
Date manipulation
UUID
uuid
uuid
UUID generation
RegexBuilt-in
regex
Regular expressions
Environment
dotenv
dotenvy
.env file loading
Testing
jest
rstest
,
proptest
Enhanced testing
Mocking
jest.mock()
mockall
Mock objects

Development Workflow

StageCommand
Check syntax
cargo check
Run tests
cargo test
Run with output
cargo run
Lint
cargo clippy
Format
cargo fmt
Benchmark
cargo bench
Documentation
cargo doc --open
Watch mode
cargo watch -x check -x test

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:

  • meta-convert-dev
    - Foundational patterns with cross-language examples
  • lang-typescript-dev
    - TypeScript development patterns
  • lang-rust-dev
    - Rust development patterns
  • convert-python-rust
    - Similar GC → ownership conversion patterns
  • convert-golang-rust
    - Similar concurrency model translations