Getting started with Rust as a web developer

Tobias Quante

Tobias Quante

Javascript was my first big step toward programming. And since it's still under active development, there's something new to learn every other day. It takes more than that to regularly step out of one's comfort zone, so I committed to learning a second language. Introducing Rust.

Table of contents

Please note that I'm still actively learning the language. I'll do my best to describe my current knowledge, but if you find anything to be incorrect, please contact me and I'll see to put it right.

Why Rust?

There were a few other candidates, such as Go and C. I settled with Rust because it's

  • a relatively young system language with many 'future-proof' use cases (such as WASM and Cryptocurrencies)
  • fundamentally different from Javascript (but perfectly compatible if necessary).

To name a few key distinctions:

Characteristic Rust Javascript
Implementation Compiled language Interpreted language
Typing Strictly static Dynamic
Memory Borrow checker Garbage Collector

Many sources on the internet assign Rust a steep learning curve. It'd be a great challenge to learn a system programming language.

The following article focuses on the four above characteristics. There will be descriptions and some code examples for you to follow.

For your best reading experience, you should be proficient with Javascript and the Linux / Windows terminal.

Install Rust

Before starting, you'll need rustup to install the Rust compiler and Cargo, Rust's package manager. For Linux and macOS, install it with the following script:

curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh

For Windows, download and run the rustup-init.exe. You can find it here:
https://forge.rust-lang.org/infra/other-installation-methods.html#other-ways-to-install-rustup

Now check if everything was installed properly:

rustup -V  # The Rustup toolchain
rustc -V   # The Rust compiler
cargo -V   # Rust's package manager

Comparing Rust to Javascript - my first impression

I find Rust 'harder' than Javascript in a few ways. Whereas the latter's compiler makes almost no assumptions about how you write code, Rust comes with some opinionated patterns. And that's good, for the language promises a secure ecosystem to develop with.

I felt like knowing Typescript made my experience a bit easier. If you're not ready for a hard cut, you should start off learning it first. You could start by migrating a Javascript app to Typescript.

A simple +1 - function - in Javascript and Rust  

Let's look at some code. Both of the following functions take in a user's input and add a 1, then print it to the console.

In Javascript (Node.js), type the following:

const readline = require('readline').createInterface({
  input: process.stdin,
  output: process.stdout,
});

function plusOne() {
  readline.question('Enter a number: ', (answer) => {
    console.log(`${answer} plus one is ` + (parseInt(answer) + 1));
    readline.close();
  });
}

plusOne();

The Rust implementation requires a bit more work:

use std::io;

pub fn plus_one() {
  let mut user_input = String::new();

  println!("Enter a number: ");
  io::stdin().read_line(&mut user_input).expect("Could not read user input");


  let user_input_san: &str = user_input.trim();
  let user_input_num: usize = user_input_san.parse().unwrap();
  let result: usize = user_input_num + 1;

  println!("{} plus 1 is {}", user_input_san, result)
}

fn main() {
  plus_one();
}

A simple +1 - function - differences in the code

My first thoughts were: This code looks super quirky. Let's take a look at the commons and differences:

  • You bring modules into play with the keyword use
  • Variables are declared using the keyword let and are immutable by default. The keyword mut allows you to change their value
  • The read_line function returns a Result type instead of a value. It can be either the expected value OR an Err object
  • Instead of accessing a variable directly, you can access its reference by prefixing it with & . This mechanism is essential to Rust's 'borrow checking' system
  • Typing is mandatory and there's an obligatory main function

And a few questions popped into my mind (hints included):

  • Why is there an exclamation mark behind println!()? Hint
  • What does String::new() mean? Is this a constructor? Hint
  • What in the world is the difference between Number and usize? Hint

You need to consider more concepts in Rust than in Javascript development. Reading about string types alone made me feel naive at times. And I still feel clumsy writing the code.

Let's wrap up the first impression and get to the filet bits.

Distinction 1: The Rust compiler

Javascript is executed in the browser or a Node.js runtime. The code you write will be compiled (= translated) while it runs. The resulting machine code gives your computer instructions.

This type of compilation classifies Javascript as an interpreted language.

When you ship a Rust program, it'll be a single executable file. This is the compiled machine code to be executed by the operating system. Software developed with a compiled language usually performs better. Since all variables, including their types, are known during compile time, the resulting software is less prone to errors as well.

You can compile a single Rust file with rustc <filename>. It's recommended to use cargo for bigger projects.

Distinction 2: Static types

The necessity to type variables felt unfamiliar when I did it for the first time. Let's look at the plusOne functions again for a second, especially at console.log, to see a possible implication.

function plusOne() {
  readline.question('Enter a number: ', (answer) => {
    console.log(`${answer} plus one is ` + (parseInt(answer) + 1));
    readline.close();
  });
}

Can you guess what happens when we remove the outer braces from  (parseInt(answer) + 1)?

A real classic.

Errors like this are less likely to happen in Rust programs. Let's review plus_one, remove all types and the .expect() method from io:

use std::io;

pub fn plus_one() {
    let mut user_input = String::new();

    println!("Enter a number: ");
    io::stdin().read_line(&mut user_input);

    let user_input_san = user_input.trim();
    let user_input_num = user_input_san.parse().unwrap();
    let result = user_input_num + 1;

    println!("{} plus 1 is {}", user_input_san, result)
}

fn main() {
    plus_one();
}

Let's try to compile and see what happens.

Rust cannot infer the type of user_input_num. We must ensure type safety before the compiler can do its job.

Since Rust is a statically typed language, the compiler must know the type of all variables during compile time.

Re-add the type annotation usize to user_input_num and compile again. You should see the following warning message:

The program still compiles, but it'll show you a possible bug in your code. You'll want to review this line and handle the possible edge case.

If compilation and typing look way too alien, try and give Typescript a shot before you move ahead.

Let's summarize.

Not only does the compiler handle early error detection, but it also warns you where your code may be unambiguous. In situations where you rely on the fail-safety of your program, this behavior is indispensable.

Distinction 3: Memory

When a program runs, its variables and functions are stored in the computer's RAM. It's a valuable resource and must be kept in check.

Javascript uses garbage collection

A garbage collection takes care of freeing memory after a defined ruleset. It regularly checks whether a variable is still in use or not.

The following code gives a simple example of how Javascript manages memory:

// Two objects are created and stored in memory.
// - One that's called `user`
// - One that's called `address`.
// Address is embedded into user.
let user = {
  firstName: "John",
  secondName: "Doe",
  address: {
    street: "123 Main Street",
    city: "Anytown",
  },
}

// We're creating a second reference here.
// `newUser` points to the same data in memory as `user`
let newUser = user;

// Let's also create a reference to the embeddedd address object.
let myAdress = user.address;

// By setting user to `null`, we remove the initial memory reference.
// It cannot be garbage collected yet because it's still referenced by newUser.
user = null;

// Removing the final reference of `newUser` will mark `user` as garbage.
// There's still a reference to `myAddress`, so it cannot be garbage collected yet.
newUser = null;

// By removing the final reference, `user` can be garbage collected.
myAdress = null;

Rust uses a borrow checker

Garbage collection comes with a few tradeoffs:

In languages such as C, you must manually allocate and free memory. Unfortunately, such manual procedures are often fertile soil for bugs.

Rust uses a unique approach to solve this issue - introducing ownership and the borrow checker. They break down to three simple rules:

  1. Each value in Rust has a variable that's called the value's owner
  2. There can only be one owner at a time
  3. When the owner goes out of scope, the value will be dropped (and memory is freed)

Let's look at some code to highlight how it works. We'll use the example from before and implement a separate function to prompt for the user input.

use std::io;

pub fn prompt() -> String {
    let mut user_input = String::new();
    println!("Enter a number: ");
    io::stdin().read_line(&mut user_input).expect("Could not read user input");
    let prompt_value = user_input.trim().to_string();
    return prompt_value;
}

pub fn plus_one(user_input: String) {
    let user_input_num: usize = user_input.parse().unwrap();
    let result = user_input_num + 1;
    println!("{} plus 1 is {}", user_input, result)
}

fn main() {
    let input = prompt();
    plus_one(input);
    println!("The initial value was {}", input);
}

If you try and compile the above code, you'll run into the following error:

Let's see why this happens:

  • We create the variable input. It becomes the owner of the value returned by prompt.
  • By using it as an argument for plus_one, ownership is passed into the function's scope
  • plus_one runs and input goes out of scope. It's not available to be printed afterward

We can solve this problem in two ways:

  1. Print the value before executing plus_one.
  2. If we don't want to change the order, we can have plus_one borrow input

Borrowing means: We pass a reference to the variable instead of the variable as a whole. Like this, ownership remains constant and the compiler is happy.

You can pass a variable's references by prefixing it with a &
use std::io;

pub fn prompt() -> String {
    let mut user_input = String::new();
    println!("Enter a number: ");
    io::stdin().read_line(&mut user_input).expect("Could not read user input");
    let prompt_value = user_input.trim().to_string();
    return prompt_value;
}

pub fn plus_one(user_input: &str) {
    let user_input_num: usize = user_input.parse().unwrap();
    let result = user_input_num + 1;
    println!("{} plus 1 is {}", user_input, result)
}

fn main() {
    let input = prompt();
    plus_one(&input);
    println!("The initial value was {}", input);
}

Moving ahead

Let's recap.

You've read a fair share about Rust, Javascript, commonalities, and differences. Especially how Rust implements stricter rules to keep your code efficient.

These concepts look exotic at first. In the end, It's a tradeoff you make. Follow a few simple guidelines. In return, you receive a program that's blazing fast and memory-efficient.

As web developer, there's more to look forward to. Web Assembly allows you to integrate Rust code into Javascript projects, taking full advantage of Rust's safety and performance features. The project maintains an ambitious roadmap which you can find here.

Further reading

Share this article