Ukpai Ugochi I am a female Nigerian with a Bachelor's degree in Marine Engineering and bootcamp certificates in software development. I'm a fullstack JavaScript developer on the MEVN stack. I love to share knowledge about my transition from marine engineering to software development in the form of writing, to encourage people who love software development and don't know where to begin. I also contribute to FOSS in my free time.

Switching from Rust to TypeScript (and vice versa)

9 min read 2646

rust to typescript

It’s very common for developers to switch from one programming language to another. For instance, you might convert a JavaScript application to TypeScript to take advantage of the latter’s code maintainability and reusability benefits.

Did you know that you can also do this with Rust? Rust and TypeScript each bring their own advantages and drawbacks, and learning to write apps in both Rust and TypeScript will help you become a better, more versatile developer.

In this tutorial, we’ll show you how to transition from writing apps in Rust to TypeScript and vice versa. We’ll explore how these languages are similar, how they differ, and common challenges associated with each. We’ll then discuss the benefits of adopting TypeScript in Rust as well as best practices for how to do so.

Here’s what we’ll cover:

Rust vs. TypeScript: Managing dependencies

Rust manages its dependencies in cargo.toml file. Once you create your Rust project, this file is created in the root of your project. To import a new dependency into your project, add it to your cargo.toml file.

// cargo.toml file
[package]
name = "[email protected]"
version = "0.0.1"
authors = ["Helloworld"]

[dependencies]
chrono = "0.4"
futures = "0.3"
atk = "^0"

After adding the dependency in your cargo.toml file, run your project.

The way TypeScript manages dependencies is quite similar to how Rust manages dependencies. All you need to do is create a TypeScript project, then a package.json file will be created in the root of your project folder.

// package.json file
{
  "name": "[email protected]",
  "description": "",
  "version": "1.0.0",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "repository": {
    "type": "git",
    "url": "https://github.com/me/[email protected]"
  },
  "keywords": [],
  "author": "Helloworld",
  "license": "ISC",
  "bugs": {
    "url": "https://github.com/me/[email protected]/issues"
  },
  "homepage": "https://github.com/me/[email protected]"
}

Next, install your dependencies. You can use a package manager such as npm. The dependency you installed will appear in your package.json file.

Unlike Rust, you don’t need to import dependencies into your project by editing your package.json file. When you install the dependency, it registers in package.json. If you add the dependency in package.json without installing it (like in Rust), you’ll be prompted to install it while you try to run your project.

We made a custom demo for .
No really. Click here to check it out.

Data types in Rust and TypeScript

Rust and TypeScript are both statically typed languages. This means the compiler tries to infer the data type during compilation.

Let’s look at the Rust example below:

fn main() {
    // NUMBERS
    let i:i32 = 1;
    println!("The value of i is: {}", i); // 1
    // This will throw an error at compile time because 1.1 is type f64
    let j:i32 = 1.1; 
    // error[E0308]: mismatched types, expected `i32`, found floating-point number
    println!("The value of j is: {}", j); // 1.1
}

While TypeScript is statically typed, it doesn’t actually enforce that you’re accessing the right data type at compile time.

  const i:Number = 1;
  console.log(`The value of i is: ${i}`); // 1
  const j:String = 1.1;
  console.log(`The value of j is: ${j}`); // 1.1

The program above throws an error at compile time: expected `i32`, found floating-point number. However, the same code is executed in runtime. This act can cause more problems and may result in an exception.

To avoid that, the program below uses a TypeScript library runtypes. It not only gives a warning at compile time, it enforces data type checking whenever you’re accessing a different type from what you assigned.

// install library
npm install --save runtypes

import { Boolean, Number, String, Literal, Tuple, Record, Union } from 'runtypes';

const Vector = Tuple(Number, Number, Number);

const AcademicStaff = Record({
  type: Literal('academicStaff'),
  location: Vector,
  population: Number,
});

const Student = Record({
  type: Literal('student'),
  location: Vector,
  population: Number,
  punctual: Boolean
});

const NonAcademicStaff = Record({
  type: Literal('nonAcademicStaff'),
  location: Vector,
  population: Number,
  name: String,
});

const UniversityObject = Union(AcademicStaff, Student, NonAcademicStaff);

// spaceObject: SpaceObject
const universityObject = UniversityObject.check(obj);//check will throw an exception if the object doesn't conform to the type specification

Code blocks and exceptions

Rust, as a statically typed programming language, allows exceptions to be caught in time. For data types, the compiler can infer the data type of the value you assigned.

This method helps to capture errors at compile time, before runtime. That way, you don’t use exceptions for flow control, which impacts the performance of your code. You can know confidently that you program won’t throw an error at runtime once it compiles successfully.

The way Rust handles errors ensures there are no exceptions. For errors that are recoverable, there’s a Result<T, E> type. When errors are not recoverable, a panic! macro stops the execution of the program at compile time.

// This error isn't recoverable
fn main() {
    let v = vec![1, 2, 3];
    v[99];
}

// This is a rust recoverable error
use std::fs::File;

fn main() {
    let f = File::open("hello.txt");
}

// A typical rust program that utilizes panic! to get results from a recoverable error (file not found)
use std::fs::File;

fn main() {
    let f = File::open("hello.txt");

    let f = match f {
        Ok(file) => file,
        Err(error) => panic!("Problem opening the file: {:?}", error),
    };
}

Here’s an example of how TypeScript handles exceptions with a try, catch statement.

// Typescript runs program, shuts at exceptions
try {
  try_statements
}
[catch [(exception_var)] { //what if you're not really catching the exception?
  catch_statements
}]
[finally {
  finally_statements
}]

The problem with the try, catch method is that it handles runtime errors exclusively. Therefore, the compiler can’t tell you when there’s an error in your try, catch block. Furthermore, you can’t use try, catch in asynchronous codes because it will finish executing before the asynchronous code finishes its execution. TypeScript’s type-safe error handling with type Type<T> handles exceptions more capably.

<// How to handle exceptions in Typescript codes better,
type Result<T> = T | Error;
export type Type<T> = Result<T>;

export function isError<T>(result: Result<T>): result is Error {
  return result instanceof Error;
}

export function isSuccess<T>(result: Result<T>): result is T {
  return !isError(result);
}
  function doIt(): Result.Type<Thing>{
    if(true) {
      return {name: "Todd"};
    } else {
      return new Error("boom")
    }
  }

Loops and conditional statements

Writing conditional statements in TypeScript can quickly become ambiguous. To solve this problem, most developers use the switch case statement in TypeScript. However, this also becomes verbose over time.

type Option<A> = { _tag: 'Some'; value: A } | { _tag: 'None' }

type Foo = {
  _tag: 'Foo'
}

type Bar = {
  _tag: 'Bar'
}

type FooBar = Foo | Bar

declare const foobar: FooBar

switch (foobar._tag) {
  case 'Some':
    // Double switch!
    switch (foobar.value._tag) {
      case 'Foo':
        break
      case 'Bar':
        break
    }
    break
  default:
    break
}

The example above shows how verbose TypeScript can become when you’re writing conditional statements in nested ADTs. That’s because each level of nesting requires a switch statement.

The same example can be rewritten in Rust, as shown below:

 pub enum FooBar {
  Foo,
  Bar
}

fn main() {
  let foobar: Option<FooBar> = Some (FooBar::Foo);
  match foobar {
      Some(FooBar::Foo) => {}
      Some(FooBar::Bar) => {}
      _ => {}
  }
}

Any in Typescript and Unsafe In Rust

TypeScript’s Any type and Rust’s unsafe share a lot of similarities. They are mostly used for unlocking additional features. For instance, Rust’s unsafe enables you to dereference a raw pointer, access an unsafe function, and so on.

fn main() {
    unsafe fn dangerous() {
        println!("I'm Unsafe") //prints I'm Unsafe
    }
    unsafe {
        dangerous();
    }
  }

With the any type in TypeScript, you can access properties without restrictions, even ones that don’t exist. For example, developers can access functions and TypeScript won’t check their type:

let Hello: any = 4;
// OK, ifItExists might exist at runtime, it dosen't exist now. 
Hello.ifItExists();
// OK, itExists exists (but the compiler doesn't check)
Hello.itExists();

From the Rust example, we just assessed an unsafe function. With this program, Rust doesn’t enforce memory safety and allocates on the heap. Variables from TypeScript’s any type are also stored in the heap since the variable type can change in the course of execution.

Mutability and immutability

A mutable object or variable can be changed during the course of execution even after assigning a value. In Rust, variables are immutable by default because the compiler wants to make sure you don’t assign values to a variable more than once.

You don’t have to keep track of where a value might change. When you run the program below, the compiler throws an error:

fn main() {
    let a = 5;
    println!("The value of a is: {}", a);
    a = 6;
    println!("The value of a is: {}", a);
}

Typescript is not designed for variable immutability. However, if you use the keyword const to assign values to variables, you can’t assign them to another value:

const a = 5;
console.log(a);
a = 6;
console.log(a);

The program above will throw an error at runtime when it gets to a = 6;. That’s because it realizes that a is a constant and another value shouldn’t be assigned to it.

There are times when mutability is beneficial or even necessary. You can mutate variables in Rust by adding mut in front of the variable. Let’s look at the first example we treated:

fn main() {
    let mut a = 5;
    println!("The value of a is: {}", a);
    a = 6;
    println!("The value of a is: {}", a);
}

The program above runs without error because we added mut to the variable. In TypeScript, you can mutate variables of type let and var, as shown in the example below:

//let
let a = 5;
console.log(a);
a = 6;
console.log(a);

//var
var a = 5;
console.log(a);
a = 6;
console.log(a);

Rust has the const keyword. However, you can only set the variable value at runtime alone, not at compile time.

fn another_function(x: i32) -> i32 {
    return x + 1;
}

fn main() {
    // RUN-TIME ASSIGNMENT, if you replace const with let, there's no compile error
    const z = another_function(5);
    println!("The value of z is: {}", z); // 6
}

Because let can be set at compile time and const can’t, the code throws an error at compile time. Although Rust variables are immutable by default, you can redefine or shadow variables of type let:

fn main() {
    let a = 5;
    let a = a + 1;
    let a = a * 2;
    println!("The value of a is: {}", a); // 12
}

In TypeScript, you can only shadow or redefine variables of type var by default:

    var a = 6;
    var a = a + 1;
    var a = a * 2;
    console.log(a);

Structs and objects

Objects are a very important part of TypeScript. You can think of an object as a person that has different characteristics, called properties. You want to be able to link and access those characteristics of the person as if they’re linked together:

var person = {
    name: 'Ugochi',
    country: 'Nigeria',
    age: 10
  }
console.log(person.name); //Ugochi
console.log(person.country); //Nigeria
console.log(person.age); //10

However, in Rust, you don’t create objects themselves. You create an instance with a struct type. Structures are called structs in Rust. Structs put related properties into one data type.

Let’s recreate the object sample from our TypeScript object with a struct:

fn main() {
    let person = Person {
        name: String::from("Ugochi"),
        country: String::from("Nigeria"),
        age: i32::from(10)
    };
    println!("{}", person.name); //Ugochi
    println!("{}", person.country); //Nigeria
    println!("{}", person.age); //10
}

struct Person {
    name: String,
    country: String,
    age: i32,
}

You can build objects from other objects in TypeScript, as shown in the example below:

const Manager = {
  name: "John",
  age: 27,
  job: "Software Engineer"
}
const intern = Manager;
console.log(intern.name); //John
console.log(intern.age); // 27
console.log(intern.job); // Software Engineer

Structs, too, can be built from other structs:

fn main() {
    let person = Person {
        name: String::from("Ugochi"),
        country: String::from("Nigeria"),
        age: i32::from(10)
    };
    println!("{}", person.name); //Ugochi
    println!("{}", person.country); //Nigeria
    println!("{}", person.age); //10
  let intern = person;
    println!("{}", intern.name); //Ugochi
    println!("{}", intern.country); //Nigeria
    println!("{}", intern.age); //10
}

struct Person {
    name: String,
    country: String,
    age: i32,
}

Ownership

One reason why developers love Rust is its memory management capabilities. In Rust, you don’t have to worry about how the garbage collector cleans up the memory that’s no longer required from the heap. All you need to do is request for memory from the memory allocator. Rust automatically returns this memory to the allocator when you’re done.

fn main() {
    let mut s = String::from("Welcome");

    s.push_str(", Ugochi!"); // push_str() appends a literal to a String

    println!("{}", s); // This will print `Welcome, Ugochi!`
  }// This scope is over and s is no longer valid.

In the example above, we requested memory from the memory allocator. Since we don’t know the size of the string (it can be changed in the program mut), it’s sent to the heap instead of the stack.

Immediately, the scope is over. Rust returns the memory s was occupying back to the heap. We don’t need to write programs to clean up or return memory back to the heap when we are done with them.

TypeScript uses a garbage collector, which manages memory from the heap. Just like in Rust, it does this automatically.

Let’s look at the example below from Mozilla:

var y = {
  a: {
    b: 2
  }
};
// two objects are created. One is referenced by being assigned to the 'y' variable
// The other is referenced by the other as one of its properties.
// None can be garbage collecte.

var y = x;      // The 'x' variable is the second thing that has a reference to one of the object.

y = 1;          // The object that was originally in 'y' has a unique reference from the 'x' variable.

var z = x.a;    // Reference to 'a' property of the object.
                //   The object now has 2 references: one as a property, the other as the 'z' variable.

x = 'mozilla';  // The object that was originally in 'y' has no zero references to it. It can be garbage-collected.
                //   However its 'a' property is still referenced by the 'z' variable, so it cannot be freed.

z = null;       // The 'a' property of the object originally in 'y' has no references to it. It can be garbage-collected.

The memory is not released back to the heap at the end of the scope because a property of the variable is still in use.

Portability

Portability and stability are two common reasons to switch from one programming language to another. What about a language that supports multiple operating systems and architecture?

Rust allows cross-compiling. It has binary builds of the compilers for Windows, MacOS, Linux etc. You can use binary builds of any OS to build for other architecture.

Although TypeScript doesn’t offer as much portability as Rust, it can be run on virtually any OS you can think of. You don’t need to install any runtime environment to execute TypeScript code.

Rust vs. TypeScript: Why you should switch

The most common reason for switching to Rust is its guaranteed memory safety. Developers who choose TypeScript usually value its intuitiveness and type safety in JavaScript.

Switching your application from one programming language to another can be stressful and complicated. You may wonder whether it’s better to just leave the application stack the way it is. No one wants to be a latecomer in their field; as the frontend and web development ecosystems evolve, it’s critical to find your place in it. Learning to write the same types of apps and perform the same functions in multiple programming languages can help you become a more well-rounded developer.

In this article, we demonstrated how to switch from TypeScript to Rust and vice versa. We also walked through some challenges you’re likely to encounter when making this change and explored the many similarities between TypeScript and Rust.

LogRocket: Full visibility into production Rust apps

Debugging Rust applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking performance of your Rust apps, automatically surfacing errors, and tracking slow network requests and load time, try LogRocket.

LogRocket is like a DVR for web apps, recording literally everything that happens on your Rust app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.

Modernize how you debug your Rust apps — .

Writing a lot of TypeScript? Watch the recording of our recent TypeScript meetup to learn about writing more readable code.

TypeScript brings type safety to JavaScript. There can be a tension between type safety and readable code. Watch the recording for a deep dive on some new features of TypeScript 4.4.

Ukpai Ugochi I am a female Nigerian with a Bachelor's degree in Marine Engineering and bootcamp certificates in software development. I'm a fullstack JavaScript developer on the MEVN stack. I love to share knowledge about my transition from marine engineering to software development in the form of writing, to encourage people who love software development and don't know where to begin. I also contribute to FOSS in my free time.

One Reply to “Switching from Rust to TypeScript (and vice versa)”

  1. “You don’t need to install any runtime environment to execute TypeScript code.” That’s not really true… you first need a TypeScript to Javascript, compiler, and then you need a Javacript interpreter runtime (embedded in a browser or in nodejs) to run your program. Granted, for many people they already have a runtime installed on their system, but for some applications this could be a deal-breaker.

Leave a Reply