ミツモア Tech blog

「ミツモア」を運営する株式会社ミツモアの技術ブログです

Using Rust as a Typescript developer

Hello everyone, this is @GuillaumeDecMeetsMore from the Foundation team. For this advent calendar, I'll talk a little about our ongoing attempt at using Rust at Meetsmore.

As you may already know, we are a company that mostly uses Typescript at each layer of our tech stack. We use it on the frontend with React and NextJS, on the backend with NodeJS, and for managing our infrastructure by doing IaC (infrastructure as code) with CDK-TF.

This has worked well for us so far, and we don't have any intention to replace Typescript by something else anytime soon.

However, we are always on the lookout for new ways of doing things, and as Typescript isn't perfect for all situations (no language is), we thought that it could be interesting to test another language.

Choosing a language

Languages’ characteristics

When talking about programming languages, there are a few main characteristics that are interesting:

  • Statically typed (types checked at compile time) or dynamically typed (types “checked” at runtime)
  • Strong type system (types are strictly enforced) or weak type system (types aren't strictly enforced)
  • Compiled (native binary) or interpreted (”text files”)
  • With garbage collector or without garbage collector

For Typescript, we can say the following:

  • Statically typed
  • Strong type system
    • But any (and others) allows to avoid type-checking
  • Interpreted
  • With garbage collector

As we all know, Typescript is a superset of Javascript, and therefore, as it needs to “play nice” with Javascript, it allows to bypass type checks easily. This has its advantages, but it's also more risky and it makes it hard to trust 3rd party libraries. We can enforce internally to have good types, but we cannot really enforce 3rd party libraries/node modules to do so.

Therefore, when writing a function in Typescript, it's usually safer to check if the inputs are not undefined or null. This is something that, in some other languages, can be avoided thanks to the strong type system providing better guarantees.

Why Rust ?

In our case, Typescript already provides a lot of flexibility at the cost of some safety and some performance. So, our decision was guided by these 2 points. We wanted a programming language that is (1) more performant and (2) safer to use.

This reduced our possibilities quite heavily. To name well-known languages, this removes Python and Ruby (due to performance and safety), C and C++ (due to safety). Java and C# were good candidates, but we excluded them as they are too similar to Typescript.

Therefore, our choice was around 2 languages: Golang and Rust. Golang is an obvious choice for writing performant web services. It's one of its main objectives and it shines in that specific use case. Its simplicity is also a big advantage. On other hand, Rust is a harder language to learn and isn't used as much. However, Rust is often more performant than Golang, and one of its biggest selling points is the additional safety mechanisms it provides.

Therefore, as we believe safety is important (nobody likes bugs), we decided to try Rust. To be honest, Golang would have been fine too, but as Golang still has the concept of nil (this “How to break production on Black Friday” article regarding a bug in Nginx k8s controller is a perfect example), we thought using Golang wouldn't bring enough change in safety for our experiment compared to our good old undefined in TS/JS.

Comparisons with Typescript

Let's go through a few comparison points.

Dependencies management

First, Rust provides the cargo CLI to interact with projects. It allows to create new applications/libraries, to run them, to execute the tests, etc. This is the equivalent of npm , pnpm or yarn for NodeJS.

  • cargo newnpm init
  • cargo runnpm run dev (common custom command doing node myScript.js)
  • cargo testnpm run test (common custom command doing jest or other test runners)

For configuring a project, in NodeJS/Typescript, we use the package.json file. It allows to define the project name, the custom commands, and the external dependencies.

The equivalent in the Rust world is the Cargo.toml file, which allows in the same way to define the project name and the dependencies. However, unfortunately it doesn't provide a way to define common commands. To do so, tools such as Just can be used.

Safety guarantees (concurrent accesses)

We cannot talk about safety in Rust without talking about the borrow checker. One of the main selling points of Rust is that the compiler can guarantee (in most cases) that a variable

  • isn't used before initialization
  • isn't used after being deleted
  • isn't being changed/mutated by 2 or more threads

To do so, the Rust compiler statically analyzes the code, and thanks to concepts such as lifetimes, it can know when a variable is badly used. Lifetimes allow to specify to the compiler from which line of code to which other line of code a variable can be used.

More often than not, the compiler can create these lifetimes this by itself, but we, developers, can provide them ourselves if we want to.

If we compare the 3 guarantees above with Typescript:

  • The use before initialization is a relatively common error in TS/JS, as variables can be declared without a value: let variable: string . Obviously, with proper configuration, the Typescript compiler would detect such problem, but it shows that the language can be quite permissive.
  • Fortunately for us, the way the memory is managed by NodeJS, with garbage collector, allows us to avoid some problems linked to “using variables after deletion”, but we cannot avoid all of them.
  • Finally, regarding concurrency issues, the single-threaded nature of the event loop in NodeJS allows us to avoid some problems, but not all.

Safety guarantees (variable not present)

Let's dive a little more into the safety guarantees around variables.

In Typescript, we often have to do

if (!myVariable) {
  throw new Error("My variable isn't defined !")
}

This is due to the fact that Typescript can rarely ensure at 100% that something isn't undefined. In a reasonably well-configured project, this is usually still possible

function myFunction(myVariable: { data: string }) {
   console.log(myVariable.data) // Kaboom
}
myFunction((undefined as any))

Of course, using any can be caught by Typescript, but there's also unknown, and it's always possible to add specific comments in order to ignore errors…

In Rust, this is way less a problem as it doesn't allow to have undefined or null variables. Rust forces you to indicate and to handle the cases where something can be "not present”, and it does so via the enum Option<T> . In Rust, enums can contain data, and Option<T> has 2 possible values: Some<T> and None. As you can see, None is for when there's not data. On the other hand, Some<T> is used when there is data. So, for example, we can have Option<String> , which means we have inside Some<String> .

The Typescript example above, in Rust without Option<T>, would be

struct MyVariable {
  pub data: String
}

fn myFunction(myVariable: MyVariable) {
  // Do something
  println!("{}", myVariable.data);
}

In this case, we can only call it with a proper value.

There is an unsafe mode in Rust, but I admit I'm not super knowledgeable on this, and it can be easily disabled project-wide.

// This compiles
myFunction(MyVariable { data: String::from("value") });

// This doesn't compile
myFunction(null); // No null in Rust
myFunction(None); // Doesn't match the signature

If we want to allow the absence of a value for the parameter, we have to use Option<T>

fn myFunction(myVariable: Option<MyVariable>) {
  println!("{}", myVariable.data); // This fails at compile time
}

To properly handle it in the function, we need to do, for example

fn myFunction(myVariable: Option<MyVariable>) {
  println!(
      "{}",
      myVariable
              // Move from Option<MyVariable> to Option<String> (the data inside)
          .map(|m| m.data)
          // Take the value inside Option if any, or use the default "It was None!"
          .unwrap_or(String::from("It was None!")) // Either
    );
}

And to call this function, we could do

myFunction(
  Some( // "Some" when there is a value 
    MyVariable { data: String::from("value") }
  )
);

myFunction(None); // "None" when there is... none

This is a very rough overview, you can find more documentation online, for example here.

Safety guarantees (operation failed)

In the same way as an undefined variable, Rust provides better defaults regarding failed operations as it also forces the developer to decide explicitly what to do when something fails.

In Typescript, it's very easy to do something like the following, and not care about the result

async function sendSomething() {
  await axios.post("somewhere", { someData: "someValue" })
}

In the above example, what happens if axios throws an error due to the server not responding ? It goes up the chain, and potentially make the HTTP endpoint return a 500, or crashes the application.

We could add a “try...catch” to handle the error:

async function sendSomething() {
  try {
    await axios.get("somewhere")
  } catch (error) {
    console.error("Whoops, couldn't send something", { error })
  }
}

However, what is the type of Error ? We don't really know. We could add type-guards, but it quickly becomes very verbose.

In Rust, we have the Result<T, E> enum to help us properly show the intent. This enum also has 2 values: Ok(T) or Err(E). As the name suggests, T is the type when the operation succeeds, and E is the type when the operation fails (usually, the error's type).

The example above In Rust, using the reqwest library (~Axios equivalent in Rust world), would be

async fn sendSomething() {
    match reqwest::get("somewhere").await {
        Ok(_) => {} // Success, no need to do anything (and ignore the value with "_")
        Err(e) => {
            // Error, we extract it from the enum via pattern matching
            eprintln!("Whoops, couldn't send something {}", e); // Simplified
        }
    };
}

Doing the following, and therefore not handling the result, directly triggers a warning.

reqwest::get("somewhere").await;

Moreover, if we were to store the result in a variable, then we would need to handle it properly (like the match statement above) as the type of res includes the type of the potential error !

let res = reqwest::get("somewhere").await;
// Res is `Result<Response, Error>`

There is a lot more to explain on this, but this already gives a rough overview. You can find more information here.

Performance

Rust is well known to have great performance, and it shows.

There have been already a loooot of experiments regarding this. Recently, I've come across these 2 microbenchmarks

On our side, we definitely notice quite a few things.

⚠️ Note that we are not running proper benchmarks here. Both APIs aren't receiving exactly the same load, nor are they doing the same things !! These are only observations we are making, and trends we are seeing. We could be totally wrong.

First, compared to Typescript/NodeJS, the needed resources at rest for running the Rust application in Kubernetes are way less. The Typescript backend has access to a whole 1 vCPU. On the other hand, the Rust application has only 0.4 vCPU allocated. See the following graphs (CPU not normalized) for the usages at rest:

Comparison of resources needed at rest

We can see, at rest (except healthchecks)

  • The Rust application uses 0.05% of CPU in a constant manner, and ~12MB of memory
  • The Typescript/NodeJS application uses ~0.5% of CPU, with spikes reaching 2%, and ~100MB of memory

In both cases, the applications are receiving frequent health checks HTTP requests, have DB connections opened, have loggers and are also generating traces (APM). We didn't dive into why the NodeJS application have small spikes (could be GC, could be how APM is handled, could be anything), so we won't draw conclusions here.

The only thing we can really say is that, at rest, our Rust backend only needs 10% of the resources, CPU and memory, of the NodeJS backend. For reducing costs, this is already quite interesting !

What about under some load ?

Comparison of resources needed under load

Both applications here use the same physical Postgres DB (but different logical databases). The API endpoints are mostly I/O, doing queries on the database. Again, this is far from being a proper benchmark, but from what we see

  • The top lines are the NodeJS containers, we can see the autoscaling kicking it and reducing the load on the initial container
    • They stabilize at around 40% of CPU and 140MB of memory
  • The bottom lines are the Rust containers. We can also see the autoscaling kicking it. Again, as a reminder, the CPU graph above aren't normalized, so in reality they are using a big chunk of their allocated 0.4 vCPU under load, which explains why the autoscaling is triggered.
    • They stabilize at around 15% of CPU and 16.5MB of memory

Looking at this, we can see the difference of required resources under load. Regarding CPU, it's less obvious as it's mostly I/O (queries to database), yet the difference is noticeable. For the memory, we can see it's still using more or less 10% of what NodeJS needs.

Finally, we can try to compare during that same load the variation of latencies on the API endpoints.

  • NodeJS containers have 22.8ms as P50, and 146ms as P99
  • Rust containers have 4.90ms as P50, and 71.7ms as P99

This is harder to compare as both APIs are not doing exactly the same things, and the load wasn't exactly the same (the NodeJS containers received more requests).

However, to conclude this section, I think it's safe to say that Rust does indeed give better performance for a fraction of the resources needed by NodeJS, which usually means reduced cloud costs.

Difficulty to learn

One big downside of Rust is the difficulty to learn the language. Developing in Rust as a beginner is often synonym with fighting with the compiler. Personally, the Rust compiler definitely throws more errors at me at compile time that any other languages/compilers I have used before. This is often due to a lack of knowledge regarding the borrow checker's rules, and it's often something that needs to be learned the hard way.

But, even though it can be frustrating, the compiler is very often right. If the errors were to be shipped in production, it could create nasty bugs and incidents. So, in all fairness, this fight should be seen as a cooperation instead, in the same way as we now cooperate with AI.

It's worth noting that the compiler error messages are most of the times very good, as they often even directly show a way to fix the error !

But, in Rust, the most difficult part to understand, as said previously, is likely the borrow checker. Fortunately, there are some easy ways to fix, or should I say, avoid errors linked to it. These easy ways are not the most performant, but as we are not trying to implement a microprocessor used in an autonomous car, we can accept to lose some microseconds here and there.

Let's look at a few solutions:

  • .clone() (and Copy) allows to create a new copy of the data
    • As it's a full copy and not just a reference, it has an obvious CPU and memory cost, but it's an easy fix for small pieces of data that don't need to be mutated
  • Arc<T> is an atomic reference counter that allows to share multiple references to the same piece of data
    • This only allows to read the data from multiple places, but not to mutate it !
  • Arc<Mutex<T>> is an atomic reference counter wrapping a mutex on a piece of data
    • This allows to lock a piece of data, which effectively allows to mutate it as we can guarantee that only one place at a time can mutate the piece of data
    • Worth mentioning that RwLock can also be used (the differences are out of scope of this article)

There are likely more ways that I haven't listed, but the main point here is that it can be okay to waste some CPU cycles and some (kilo)bytes in memory for non-critical applications if it means that the code is easier to write and reason about.

Conclusion

The project and the experiment are not finished yet, it's ongoing as an open source project in https://github.com/meetsmore/nittei, but so far I can say that using Rust has been a good experience. I/we are still beginners, but the performance and the guarantees that Rust provides are very interesting.

There is the obvious cost of learning the language, which is a big barrier to take into account in an organization. But according to Google and other big users of Rust, the productivity quickly becomes equal to other languages. Of course, most of Google's developers are really good, so this is maybe a little biased.

However, it's worth noting that, even if the productivity was a little lower with Rust, applications written in it often have less bugs. This means that developers spend less time debugging bugs in production, and more time developing new features.

With this, we can understand why there is a shift happening for some (big) players in the industry, e.g. Android team using more and more Rust, Microsoft using Rust in Windows and even in replacement of C# in some places, AWS using it for powering (at least parts of) Lambda/EC2/S3, Meta declaring Rust has been added to its main backend languages. With this much reach, usage of Rust will likely keep increasing during the coming years.

That's all for today ! I hope this gives you some insights into our journey with Rust and inspires you to explore it as well. Don't hesitate to check the articles of my colleagues too, we are sharing a lot of our knowledge !