My experience learning Rust as a TypeScript developer

Like many developers, I began my career in programming by focusing on web technologies. I believe this is a great place to start and…

Published on
Feb 29, 2024

Read time
10 min read

Introduction

Like many developers, I began my career in programming by focusing on web technologies. I believe this is a great place to start and JavaScript, the language of the internet and a lot besides, is an incredibly versatile choice.

As I have become more experienced with high level languages like JavaScript, I have also become more interested in how they work: what choices and tradeoffs are they making, and what are the benefits and costs of higher level abstractions.

For me, one of the best ways to get this deeper understanding is to learn a low level programming language. After all, these are the languages that typically parse and interpret our JavaScript code. For example, both the V8 engine (used by Google Chrome and Node.js) and WebKit (used by Safari and Bun) are written in C++. But despite being a mainstay of low-level programming, C++ wasn’t my language of choice…

Why Rust?

Of the great low level programming languages, Rust is the most exciting to me. Last year, for the eighth year in a row, Rust was the most admired programming language in Stack Overflow’s annual survey.

The language promises runtime performance in the same league as C and C++, but with a strict type system, lots of memory safety features and a more proactive approach to error handling — so that you can avoid the overhead of garbage collector, safe from the risk of memory leaks that are much easier to create in a language like C.

Rust is versatile, featuring paradigms from both object-oriented and functional programming, for the third year running it has been the most popular language used to for Web Assembly, and it has even become an important language in the Linux Kernel. Rust is also making waves in the world of JavaScript, where it has been used to build important projects such as Deno and, more recently, LLRT (Amazon’s Low Latency Runtime for serverless functions).

How to learn Rust

Much like any other programming language, I believe the best way to learn Rust is by trying to program something in the language.

However, Rust’s initial learning curve seems steeper than other languages I have tried in recent years, so it’s worth taking longer going through introductory materials before jumping into using the language.

The Rust organization’s website has great recommendations. Right now, these are The Book, the Rustlings course, and Rust by Example. I also recommend Rust by Practice, which is an interactive course similar to Rustlings.

On YouTube, the NoBoilerplate channel helped get me excited about the language and is a great source of explanations about general Rust concepts. If you’re interested in hosting Rust, AWS has a good blog post about growing support for Rust on their platform.

The remainder of this article is not a beginner’s guide to Rust. If that’s what you’re looking for, I recommend following the links above. Instead, I share my thoughts on some of the most significant differences in developer experience using Rust in comparison to the language I use professionally every day, TypeScript.

The compiler

The Rust compiler is often cited as one of the best parts ofRust, though, for beginners, it can also feel like one of the most annoying!

Coming from TypeScript, I was surprised at how much the compiler changes the experience of coding. Like many developers, I typically eschew proper debugging tools in favour of liberally logging values. But in Rust, you can only log values once the compiler is happy.

There are cases where this has proved frustrating: for example, I wanted to log a requested JSON payload before writing strict types for the deserialization step. (Later, I learned that this can be done with the serde_json::Value type).

But in general, working to satisfy the compiler has meant that typically, when I run my code, it works as I expected. The tradeoff here seems pretty clear. For beginners, at least, it does take longer to get code running, but when that code does run, it is safer, more predictable and more performant. You put in more work at the writing stage, but the chance of errors or issues with memory of performance seems lower — and those benefits feel increasingly important in the context of large, growing projects.

Developers coming from languages where error messages are less useful may find that they’re preconditioned to scan over errors quickly. But so far, I have found Rust compiler errors to be very good, often telling you exactly what you need to do to get the code running — the more experienced I become with the language and its types, the better I am becoming at understanding what the compiler is trying to tell me!

The type system

I know not every JavaScript developer loves TypeScript —see, for example, this (in)famous blog post — but I can’t imagine writing large JavaScript apps without types. However, TypeScript has its weaknesses, and I have recognised that there are many benefits to having a language where types are a first class citizen. Rust’s type system is often praised as one of its best features.

That said, the parts of Rust’s type system that were new to me are not unique to Rust, but a feature of pretty-much all low level languages. For example, like other low level languages, Rust allows us to be very specific about how much space in memory we want a variable to take up. If we know a numerical value will always be an integer between 0 and 255, we can assign it to u8, which has an 8-bit length. Or if our number could be above 255, but we know if will be below 65,535, then we could assign it to the 16-bit u16 type— and so on.

In some ways, though, Rust does goes further than other low level languages. For example, it provides at least eight string types versus the one char[] type of C, which help us avoid footguns. (Though, fear not, as most use-cases are covered by &str and String!)

TypeScript, of course, doesn’t offer anywhere near this level of granularity because, by design, JavaScript doesn’t want us to worry about memory management and so allocates memory for us. This saves us a job, but is less efficient, as the JavaScript engine must allocate memory dynamically while the program is running. On a small scale, this makes very little difference. But in a large application, more efficient and purposeful memory allocation can make a programmes take up a much smaller memory footprint.

Memory allocation

In TypeScript, we superimpose type annotations on top of Javascript — a language which doesn’t read our types — and they are removed whenever our TypeScript code is built.

In Rust, like other languages where types are a first class citizen, a type annotation is more than just annotation—it allocates memory for that specific type and it assures us that the value will have the given type.

For example, if we pass the i8 type to the parse method below, it reserves 8 bits of memory for small_int.

let small_int = "127".parse::i8().unwrap();

The parse method can even infer the type from the variable type, so we can also write:

let small_int: i8 = "127".parse().unwrap();

In this instance, the compiler will also shout at us if we try to go beyond the memory allowed by the given type. So if we try to parse the string "128" as an i8, we’ll be unable to compile.

Compare TypeScript, where type markers are simply that — markers. They do not change the underlying type or the memory allocated. Below, TypeScript expects x to be a string. But in the underlying JavaScript, it will be a number.

const x = 10 as unknown as string;

This example is a little unfair on the TypeScript compiler; we are using unknownas an escape hatch to forcibly allocate the wrong type!

However, this is a simple example that is easy to catch. In real-world applications, when dealing with more complex data types or data fetched from third parties, it is easier for TypeScript to misrepresent reality.

Error handling

Let’s again take the example of converting a string to an integer. This time, let’s imagine our integer is a string supplied by the user, so we can no longer guarantee that we can parse it correctly.

let parsed_int = submitted_str.parse::i32().unwrap();

Here, we are using unwrap to get the value of a successful parse. But this approach is generally discouraged. Instead, Rust provides us with the Result enum, which forces us to handle errors manually.

We can still cause our program to panic with the panic! macro, but we can pass a custom error message which will help us quickly understand what went wrong:

let parsed_int_result = submitted_str.parse::i32();

let parsed_int = match parsed_int_result {
    Ok(data) => data,
    Err(error) => panic!(
        "The given string cannot be parsed to an integer: {:?}",
        error
    ),
};

Or we can return a default value — in this case 0:

let parsed_int_result = submitted_str.parse::i32();

let parsed_int = match parsed_int_result {
    Ok(data) => data,
    Err(error) => 0,
};

There is also a shorthand method for this: unwrap_or_default.

Of course, this sort of behaviour is possible in JavaScript, but the difference is that in JavaScript you have to opt in, whereas in Rust you have to opt out by using unwrap.

Or, to put it another way, in JavaScript you have to consciously handle errors. Whereas in Rust, you are forced to either handle the errors or consciously decide you only care about the successful path.

Optional values

Rust uses a similar approach to handling optional values. In TypeScript we can use the convenient ? to indicate a value may be undefined.

interface User {
  _id: string;
  name?: string;
}

function sayHello(user: User) {
  return `Hello ${user.name}!`;
}

This TypeScript code will compile without issues, even though there is a risk that we return something that we don’t want!

But if we write something similar in Rust using the Optionenum, we’ll get a compile-time error.

struct User {
  _id: String,
  name: OptionString,
}

fn say_hello(user: User) -> String {
    let name = user.name;
    format!("Hello {name}!")
}

The code above warns us that we cannot use Option inside our format! macro — preventing us from returning something unexpected. Instead, we are forced to handle this possibility. Here’s one solution, using match:

struct User {
  _id: String,
  name: OptionString,
}

fn say_hello(user: User) -> String {
  let name: String = match user.name {
    Some(name) => name,
    None => "world".to_string(),
  };

  format!("Hello {name}!")
}

Once again, this is achievable in TypeScript— and it’s much more concise. But the key difference between the two languages is that, in TypeScript, the onus is on the developer to recognise the potential problem, so we don’t end up returning "Hello undefined". But in Rust, our code will not compile unless we handle the scenario that name is not available.

In a simple example like this, it can be hard to recognise the benefits of the more long-winded approach, because it’s easy to see what could go wrong. But if you’ve ever worked on a large application, it’s clear that Rust’s opt-out approach can save us from lots of potential accidents.

Ownership and borrowing

Finally, I’d like to talk about ownership and borrowing, concepts which are much more meaningful in the context of a low-level language like Rust than a high level language like TypeScript.

In TypeScript, we need to be aware of whether we are mutating a value or cloning it.

const arrayToBeMutated: string[] = ["d", "c", "b", "a"];
const arrayToBeCloned: string[] = ["d", "c", "b", "a"];

arrayToBeMutated.sort();
arrayToBeCloned.toSorted();

console.log(arrayToBeMutated); // ["a", "b", "c", "d"]
console.log(arrayToBeCloned); // ["d", "c", "b", "a"]

In the TypeScript code above, sort mutates the array in-place, changing the original value. But toSorted creates a clone, which we could assign to a new variable, and leaves the original array untouched.

In general, non-destructive methods like toSorted are often preferred in languages like TypeScript, because keeping track of mutated variables can be tricky and — unless there are clear benefits to memory or performance — it’s typically considered better to avoid doing it altogether.

However, Rust allows us to go deeper and to be much more explicit about mutating or cloning values, with the benefit that we can be more efficient with memory and also free up memory more easily once a value has performed its glorious purpose.

For a start, all variables are immutable by default, and must be explicitly marked as mutable with the mut keyword.

This code throws an error:

let foo = 10;
foo += 10;

This code doesn’t:

let mut foo = 10;
foo += 10;

This feels roughly equivalent to let versus const in JavaScript. Bust Rust goes further.

For example, in JavaScript, certain variable types, such as arrays, are always mutable. Even if we instantiate them using const, we can push, pop and re-assign indexes. In Rust, we need mut to be able to do this:

let mut nums: Veci32 = vec![1, 2, 3, 4, 5];
nums.push(6);

Rust also allows us to move ownership of our values from one variable to another. Take the example below:

let nums: Veci32 = vec![1, 2, 3, 4, 5];
let doubles: Veci32 = nums.into_iter().map(|n| n * 2).collect();

dbg!(nums);     // this throws
dbg!(doubles);

This code throws, because the into_iter method creates a “consuming iterator”; in other words, takes ownership away from nums and gives it to doubles. We cannot, therefore, call dbg!(nums) after doubles has been created.

If we want to maintain access to nums and instead clone its values, we can use the iter method instead of into_iter. What’s important is that Rust gives us a choice, and the ability to transfer ownership can help us be more efficient with memory allocation.

We can also move simple values. In the code below, when our str variable is used as an argument for calculate_length, it is no longer accessible.

fn main() {
    let str = String::from("Hello world!");
    let len = calculate_length(str);
    dbg!(str); // this throws
}

fn calculate_length(s: String) -> usize {
    s.len()
}

Here, we can fix this by using an ampersand & to pass a reference to our string, rather than passing the string itself. We’ll also need to update the function argument to expect a reference:

fn main() {
    let str = String::from("hello");
    let len = calculate_length(&str);
    dbg!(str, len);
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

To do the opposite, and de-reference a value, we can use an asterisk *. Together, these features help us control memory safely and efficiently, and because of this, Rust doesn’t need to depend on a garbage collector, allowing us to unlock a greater level of performance without the risks of languages like C, which places a greater onus on the developer to understand what they’re doing!

Overall, my early experience learning and using Rust has been a very positive one. Getting started has felt more difficult than other languages I have tried my hand at in recent years, but I believe learning Rust has already made me more aware about the underlying workings of the high level languages that I use every day and I am excited to use it more in my personal projects.

If you’re new to Rust or curious about picking up the language — especially coming from a higher level language — then a hope you found this article useful. This is, of course, only a cursory view and there are plenty of topics, like traits and lifetimes, which are beyond the scope of the article. So if you want to go further, head over to The Book!

© 2024 Bret Cameron