Understanding the Migration Landscape
Migrating from TypeScript or JavaScript to a compiled, statically-typed systems language like Go or Rust represents a fundamental shift in how you approach software design. This tutorial walks you through the complete journey—from deciding why to migrate, to writing your first production-ready service in either Go or Rust. Whether you're chasing performance, better type safety, or a more robust concurrency model, this guide provides concrete, side-by-side code translations and a structured migration path you can follow step by step.
Why Migrate from TypeScript/JavaScript?
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →TypeScript and JavaScript dominate the web ecosystem, but they carry inherent limitations that become painful at scale:
- Runtime performance: The V8 engine is fast, but garbage-collection pauses, dynamic dispatch, and single-threaded execution (without Worker threads) cap throughput for CPU-bound workloads.
- Startup latency: Node.js and Deno services can take hundreds of milliseconds to cold-start, a liability in serverless environments.
- Type system gaps: TypeScript's structural typing is powerful but can't enforce invariants like "this integer is never negative" at compile time, nor does it prevent null-pointer exceptions without strict null checks enabled everywhere.
- Concurrency complexity:
async/awaitsimplifies promise chains, but true parallelism requires explicit worker management, and debugging race conditions in a dynamic language remains difficult. - Resource footprint: A typical Node.js microservice consumes 50–150 MB of RAM at idle. Go and Rust binaries often use less than 10 MB.
Migrating isn't about abandoning TypeScript entirely—it's about selecting the right tool for CPU-bound, latency-sensitive, or resource-constrained workloads.
Choosing Between Go and Rust
Both languages solve the problems above, but their philosophies differ dramatically:
- Go prioritizes simplicity, fast compilation, and a batteries-included standard library. Its goroutine model makes concurrent I/O trivial. Choose Go when you want a drop-in replacement for Node.js services—especially I/O-heavy REST APIs, proxies, or CLI tools—with minimal learning curve.
- Rust prioritizes zero-cost abstractions, compile-time memory safety (no garbage collector, no data races), and expressive type-level programming. Choose Rust when you need C++-level performance, when you're building databases, network protocols, or WASM modules, or when you want the compiler to prove correctness rather than relying on runtime tests.
This guide covers both paths. Follow the Go path if your primary pain is concurrency and deployment overhead. Follow the Rust path if you need maximum control and performance.
Step-by-Step: TypeScript to Go
Step 1: Mental Model Shift — From Async Promises to Goroutines
In TypeScript, all I/O is non-blocking and managed via Promise chains or async/await. Go uses blocking calls inside lightweight goroutines. The runtime multiplexes thousands of goroutines onto a small thread pool. This means you write synchronous-looking code that behaves asynchronously:
// TypeScript — async/await
async function fetchUser(id: string): Promise {
const resp = await fetch(`https://api.example.com/users/${id}`);
const data = await resp.json();
return data as User;
}
async function main() {
const user = await fetchUser("abc");
console.log(user.name);
}
The Go equivalent uses blocking HTTP calls launched from a goroutine:
// Go — goroutine with blocking call
func fetchUser(id string) (*User, error) {
resp, err := http.Get("https://api.example.com/users/" + id)
if err != nil {
return nil, fmt.Errorf("fetch failed: %w", err)
}
defer resp.Body.Close()
var user User
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, fmt.Errorf("decode failed: %w", err)
}
return &user, nil
}
func main() {
// Launch a goroutine (or just call directly; main goroutine blocks here)
user, err := fetchUser("abc")
if err != nil {
log.Fatal(err)
}
fmt.Println(user.Name)
}
Key insight: you don't need await because the Go runtime handles scheduling. Every network call blocks the goroutine, not the entire process.
Step 2: Setting Up Your Go Environment
Install Go (≥1.21), initialize a module, and create a minimal HTTP server to replace Express/Fastify:
# Install Go and scaffold a project
go mod init myapp
mkdir -p cmd/server
touch cmd/server/main.go
// cmd/server/main.go — Minimal HTTP server
package main
import (
"encoding/json"
"log"
"net/http"
"time"
)
type User struct {
ID string `json:"id"`
Name string `json:"name"`
}
func userHandler(w http.ResponseWriter, r *http.Request) {
user := User{ID: "1", Name: "Alice"}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/user", userHandler)
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
log.Println("Listening on :8080")
log.Fatal(srv.ListenAndServe())
}
This replaces a typical Express app.get('/user', ...) route with zero external dependencies.
Step 3: Translating TypeScript Interfaces to Go Structs
TypeScript interfaces describe shapes; Go structs describe concrete data layouts. Both support composition:
// TypeScript — interface composition
interface Timestamped {
createdAt: Date;
updatedAt: Date;
}
interface User extends Timestamped {
id: string;
email: string;
}
// Go — struct embedding (composition)
type Timestamped struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type User struct {
Timestamped // embedded struct — fields promoted
ID string `json:"id"`
Email string `json:"email"`
}
Go's embedded structs promote fields to the outer struct, so user.CreatedAt works directly. For methods, embedding also promotes behavior—Go's version of "mixins."
Step 4: Error Handling Without Exceptions
TypeScript uses exceptions and try/catch. Go returns errors explicitly. This is the hardest adjustment for newcomers:
// TypeScript — throwing and catching
function parsePort(env: string): number {
const port = parseInt(env, 10);
if (isNaN(port)) throw new Error("Invalid port");
return port;
}
try {
const port = parsePort(process.env.PORT ?? "3000");
console.log(`Starting on ${port}`);
} catch (e) {
console.error("Failed:", e.message);
}
// Go — explicit error returns
func parsePort(env string) (int, error) {
port, err := strconv.Atoi(env)
if err != nil {
return 0, fmt.Errorf("invalid port: %w", err)
}
return port, nil
}
func main() {
port, err := parsePort(os.Getenv("PORT"))
if err != nil {
log.Fatalf("Failed: %v", err)
}
fmt.Printf("Starting on %d\n", port)
}
Best practice: never ignore errors. Use fmt.Errorf with %w to wrap errors, preserving the chain for later inspection with errors.Is and errors.As.
Step 5: Concurrency Patterns — Workers and Fan-Out
A common TypeScript pattern uses Promise.all to fetch multiple resources concurrently. Go offers channels and sync.WaitGroup:
// TypeScript — concurrent fetches with Promise.all
async function fetchAll(ids: string[]): Promise {
const promises = ids.map(id => fetchUser(id));
return await Promise.all(promises);
}
// Go — fan-out with goroutines and channels
func fetchAll(ids []string) ([]User, error) {
userCh := make(chan User, len(ids))
errCh := make(chan error, len(ids))
for _, id := range ids {
go func(id string) {
user, err := fetchUser(id)
if err != nil {
errCh <- err
return
}
userCh <- user
}(id)
}
var users []User
for i := 0; i < len(ids); i++ {
select {
case user := <-userCh:
users = append(users, user)
case err := <-errCh:
return nil, err
}
}
return users, nil
}
Channels are Go's built-in thread-safe queues. The select statement waits on multiple channels simultaneously. For production, prefer the golang.org/x/sync/errgroup package, which encapsulates this pattern with automatic context cancellation.
Step 6: Building a Complete Microservice
Let's build a complete REST API with middleware, structured logging, and graceful shutdown—replacing a typical Express + TypeScript setup:
// cmd/server/main.go — Production-ready Go HTTP service
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
)
type Server struct {
httpServer *http.Server
db *InMemoryDB // example data store
}
type InMemoryDB struct {
mu sync.RWMutex
users map[string]User
}
type User struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
}
func NewServer() *Server {
db := &InMemoryDB{
users: map[string]User{
"1": {ID: "1", Name: "Alice", Email: "alice@example.com", CreatedAt: time.Now()},
},
}
mux := http.NewServeMux()
// Route with method guard middleware
mux.HandleFunc("GET /users/{id}", db.getUserHandler)
mux.HandleFunc("POST /users", db.createUserHandler)
// Middleware chain
handler := loggingMiddleware(corsMiddleware(mux))
return &Server{
httpServer: &http.Server{
Addr: ":8080",
Handler: handler,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
},
db: db,
}
}
func (db *InMemoryDB) getUserHandler(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
db.mu.RLock()
user, ok := db.users[id]
db.mu.RUnlock()
if !ok {
http.Error(w, "user not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
func (db *InMemoryDB) createUserHandler(w http.ResponseWriter, r *http.Request) {
var user User
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
defer r.Body.Close()
user.CreatedAt = time.Now()
db.mu.Lock()
db.users[user.ID] = user
db.mu.Unlock()
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user)
}
// Middleware: structured logging
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s %v", r.Method, r.URL.Path, r.RemoteAddr, time.Since(start))
})
}
// Middleware: CORS
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
func main() {
srv := NewServer()
// Graceful shutdown
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
go func() {
log.Println("Server running on :8080")
if err := srv.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen error: %v", err)
}
}()
<-stop
log.Println("Shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.httpServer.Shutdown(ctx); err != nil {
log.Fatalf("shutdown error: %v", err)
}
log.Println("Server stopped")
}
This single file replaces Express, Helmet, Morgan, body-parser, and manual shutdown logic. The standard library covers routing (ServeMux with path parameters in Go 1.22+), middleware chains, timeouts, and graceful shutdown via OS signals.
Step-by-Step: TypeScript to Rust
Step 1: Mental Model Shift — Ownership and Borrowing
Rust's defining feature is the ownership system: every value has exactly one owner at a time, and references (&) borrow that value with compile-time-enforced lifetimes. There is no garbage collector and no null. Instead, you use Option<T> for optional values and Result<T, E> for fallible operations:
// TypeScript — nullable and throwable
function findUser(id: string): User | null {
const user = db.get(id);
return user ?? null;
}
function getUserName(id: string): string {
const user = findUser(id);
if (!user) throw new Error("Not found");
return user.name;
}
// Rust — Option and Result, no null, no exceptions
fn find_user(id: &str) -> Option {
db.get(id) // returns Option directly
}
fn get_user_name(id: &str) -> Result {
let user = find_user(id).ok_or_else(|| Error::NotFound)?;
Ok(user.name.clone())
}
The ? operator propagates errors upward, similar to throw but checked at compile time. Rust forces you to handle every error path explicitly.
Step 2: Setting Up Rust and Your First Crate
Install Rust via rustup, then scaffold a new project:
# Install Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Create a new crate
cargo new myapp
cd myapp
# Add dependencies (edit Cargo.toml or use cargo add)
cargo add tokio --features full
cargo add axum
cargo add serde --features derive
cargo add serde_json
cargo add tracing
cargo add tracing-subscriber
Your Cargo.toml will look like:
[package]
name = "myapp"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["full"] }
axum = "0.7"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = "0.3"
Step 3: Translating TypeScript Types to Rust Structs and Enums
TypeScript's discriminated unions map beautifully to Rust enums:
// TypeScript — discriminated union
type ApiResponse =
| { kind: "ok"; data: T }
| { kind: "error"; message: string };
function handle(res: ApiResponse): string {
switch (res.kind) {
case "ok": return `Success: ${JSON.stringify(res.data)}`;
case "error": return `Error: ${res.message}`;
}
}
// Rust — enum with generics and exhaustive match
enum ApiResponse {
Ok { data: T },
Error { message: String },
}
fn handle(res: ApiResponse) -> String {
match res {
ApiResponse::Ok { data } => format!("Success: {:?}", data),
ApiResponse::Error { message } => format!("Error: {}", message),
}
}
Rust's match is exhaustive—the compiler errors if you forget a variant. This eliminates entire categories of bugs at compile time.
Step 4: Async Runtime — From Promise to Tokio
Rust has no built-in async runtime; you choose one (typically Tokio). Async functions return impl Future and are polled by the runtime, similar to JavaScript promises but with explicit spawning:
// TypeScript — async function returning Promise
async function fetchData(url: string): Promise> {
try {
const resp = await fetch(url);
const data = await resp.json();
return { kind: "ok", data };
} catch (e) {
return { kind: "error", message: String(e) };
}
}
// Rust — async with reqwest and Tokio
use reqwest::Client;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct User {
id: String,
name: String,
}
async fn fetch_data(url: &str) -> ApiResponse {
let client = Client::new();
match client.get(url).send().await {
Ok(resp) => match resp.json::().await {
Ok(user) => ApiResponse::Ok { data: user },
Err(e) => ApiResponse::Error { message: e.to_string() },
},
Err(e) => ApiResponse::Error { message: e.to_string() },
}
}
#[tokio::main]
async fn main() {
let result = fetch_data("https://api.example.com/users/1").await;
println!("{}", handle(result));
}
The #[tokio::main] macro sets up the async runtime. Under the hood, Tokio uses a work-stealing scheduler similar to Go's, but you must explicitly .await futures.
Step 5: Error Handling with Result and the ? Operator
Rust's error handling is type-driven. You define or derive error types and use ? for propagation:
// Define a custom error type
use std::fmt;
use std::error::Error;
#[derive(Debug)]
enum AppError {
NotFound(String),
Internal(String),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::NotFound(msg) => write!(f, "Not found: {}", msg),
AppError::Internal(msg) => write!(f, "Internal error: {}", msg),
}
}
}
impl Error for AppError {}
// Function using ? for propagation
fn get_user_by_id(id: &str) -> Result {
let user = db.get(id)
.ok_or_else(|| AppError::NotFound(id.to_string()))?;
if user.deleted {
return Err(AppError::Internal("User is deleted".into()));
}
Ok(user)
}
For production, use the thiserror crate to derive error types automatically, and anyhow for application-level error contexts.
Step 6: Building a Complete REST API with Axum
Axum is Rust's idiomatic web framework, built on Tokio and inspired by Express's middleware pattern:
use axum::{
extract::{Path, State},
http::StatusCode,
response::Json,
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
use tokio::net::TcpListener;
use tracing::{info, error};
use tracing_subscriber;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct User {
id: String,
name: String,
email: String,
}
type Db = Arc>>;
#[tokio::main]
async fn main() {
// Initialize structured logging
tracing_subscriber::fmt::init();
// In-memory "database" wrapped in Arc for shared mutable state
let db: Db = Arc::new(Mutex::new(vec![
User {
id: "1".to_string(),
name: "Alice".to_string(),
email: "alice@example.com".to_string(),
},
]));
// Build router with dependency injection via State
let app = Router::new()
.route("/users", get(list_users).post(create_user))
.route("/users/{id}", get(get_user))
.layer(tower_http::cors::CorsLayer::permissive())
.with_state(db);
let listener = TcpListener::bind("0.0.0.0:8080").await.unwrap();
info!("Listening on http://0.0.0.0:8080");
axum::serve(listener, app).await.unwrap();
}
async fn list_users(State(db): State) -> Json> {
let users = db.lock().unwrap().clone();
Json(users)
}
async fn get_user(
Path(id): Path,
State(db): State,
) -> Result, StatusCode> {
let users = db.lock().unwrap();
users
.iter()
.find(|u| u.id == id)
.cloned()
.map(Json)
.ok_or(StatusCode::NOT_FOUND)
}
#[derive(Deserialize)]
struct CreateUserPayload {
id: String,
name: String,
email: String,
}
async fn create_user(
State(db): State,
Json(payload): Json,
) -> (StatusCode, Json) {
let user = User {
id: payload.id,
name: payload.name,
email: payload.email,
};
let mut users = db.lock().unwrap();
users.push(user.clone());
(StatusCode::CREATED, Json(user))
}
Key observations:
- State sharing:
Arc<Mutex<Vec<User>>>provides thread-safe shared mutable state—no external database needed for prototyping. - Extractors:
State,Path, andJsonare Axum extractors that parse request data at compile time, replacing middleware-based body parsers. - Layers:
tower_http::cors::CorsLayeris middleware from the Tower ecosystem, composable like Express middleware. - No garbage collection: Memory is freed deterministically when values go out of scope.
Comparative Migration Patterns: Go vs Rust
Below is a side-by-side reference for common TypeScript patterns translated to both Go and Rust:
Pattern 1: JSON Serialization
// TypeScript
const user: User = { id: "1", name: "Alice" };
const json = JSON.stringify(user);
const parsed: User = JSON.parse(json);
// Go
user := User{ID: "1", Name: "Alice"}
jsonBytes, _ := json.Marshal(user)
var parsed User
json.Unmarshal(jsonBytes, &parsed)
// Rust (serde)
let user = User { id: "1".to_string(), name: "Alice".to_string() };
let json = serde_json::to_string(&user).unwrap();
let parsed: User = serde_json::from_str(&json).unwrap();
Pattern 2: Middleware / Request Interceptors
// TypeScript (Express)
app.use((req, res, next) => {
console.log(`${req.method} ${req.path}`);
next();
});
// Go (net/http middleware)
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("%s %s", r.Method, r.URL.Path)
next.ServeHTTP(w, r)
})
}
// Rust (Axum / Tower layer)
use tower::ServiceBuilder;
use tower_http::trace::TraceLayer;
let app = Router::new()
.layer(ServiceBuilder::new().layer(TraceLayer::new_for_http()));
Pattern 3: Graceful Shutdown
// TypeScript (Node.js)
process.on('SIGTERM', () => {
server.close(() => {
console.log('Shut down');
process.exit(0);
});
});
// Go
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGTERM)
<-stop
srv.Shutdown(context.Background())
// Rust (Tokio)
use tokio::signal;
tokio::select! {
_ = signal::ctrl_c() => {
println!("Shutting down...");
}
}
Best Practices for Migration
- Start with a single service, not a monolith rewrite. Extract a CPU-bound or latency-critical module (image processing, payment pipeline, WebSocket hub) and rewrite it in Go or Rust. Run both systems side-by-side behind a reverse proxy.
- Invest in CI-native compilation checks. Both Go and Rust compile in seconds. Add a CI step that fails on compilation errors—this catches type mismatches that would be runtime errors in TypeScript.
- Use feature flags or canary deployments. Deploy the new service to 1% of traffic, compare p99 latency and memory usage against the Node.js baseline, then ramp up.
- Don't fight the language idioms. Go code should use channels and explicit error returns, not try to emulate async/await with complex libraries. Rust code should embrace
ResultandOption, not wrap everything inpanic!-based error handling. - Leverage generated clients. For Go, use
oapi-codegenfrom OpenAPI specs. For Rust, useprostfor Protobuf oropenapicrates. These eliminate manual type translation between services. - Monitor memory and goroutine/task leakage. Go's
pprofand Rust'stokio-consoleprovide deep introspection into runtime behavior—use them from day one in production. - Keep TypeScript for what it does best: rapid UI prototyping, glue code, and developer tooling. The ideal stack often combines a TypeScript frontend with Go or Rust backend services communicating via gRPC or REST.
Conclusion
Migrating from TypeScript to Go or Rust is not a rewrite exercise—it's a strategic shift toward compile-time guarantees and predictable runtime performance. Go offers a gentle learning curve and near-instant compilation, making it the natural successor for I/O-bound Node.js services. Rust demands a steeper upfront investment in understanding ownership and lifetimes, but repays that effort with zero-cost abstractions and memory safety without a garbage collector. Both ecosystems provide mature HTTP frameworks, async runtimes, and observability tooling that rival or exceed the Node.js ecosystem. By starting small, translating patterns idiomatically, and measuring impact with real traffic, you can migrate incrementally and end up with a polyglot system where each component runs on the runtime best suited to its workload.