Claude Code Plugins

Community-maintained marketplace

Feedback

convert-typescript-rust

@aRustyDev/ai
0
0

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 Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name convert-typescript-rust
description 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.

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

TypeScript Rust Notes
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

TypeScript Rust Notes
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

TypeScript Rust Notes
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

TypeScript Rust Notes
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 Composition Rust 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

TypeScript Rust Notes
<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

TypeScript Rust Strategy
any Avoid Use 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.

TypeScript Rust Use 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 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:

  • 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.

TypeScript Rust (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 impl Rare
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.

Concept TypeScript Rust
Memory allocation Automatic (GC) Explicit (ownership)
Sharing references Freely shared Borrowed (&) or shared (Arc)
Mutation Mutable by default Immutable by default (mut)
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:

  • 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

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 typescript compiler API, ts-morph syn, proc-macro2
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 JSON.parse() serde_json Serialization/deserialization
HTTP Client fetch, axios reqwest Async HTTP client
HTTP Server Express, Fastify axum, actix-web Web frameworks
Async Runtime Built-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
Regex Built-in regex Regular expressions
Environment dotenv dotenvy .env file loading
Testing jest rstest, proptest Enhanced testing
Mocking jest.mock() mockall Mock objects

Development Workflow

Stage Command
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