Rust for web devs: Destructuring

Paul Butler – February 19, 2022

This is the first of an unbounded and weakly-ordered series of posts aimed at developers coming to Rust from a JavaScript/TypeScript background. It’s “for web devs” because my imagined reader is a web developer, but it’s not about web development. Nor is it meant to be a comprehensive introduction to Rust, just a series of essays on the parts I find interesting. If you’re brand new to Rust, you might want to start with The Book or Rust in Action.

Destructuring Assignment in JavaScript

One of the most universal tasks in programming is putting data into and taking data out of composite data types. Composite data types is just a fancy way of saying data types that can contain other data types (like lists and objects), in contrast to primitive types which are the “atoms” (like numbers and booleans) that can’t be broken down.

In JavaScript, we could say something like:

let user = new Object();
user.name = "Tim";
user.city = "Ottawa, ON";
user.country = "Canada";

(aside: we met Tim last week. Thanks to an encouraging reader for the character development assist.)

This gets a bit tedious, though, so usually we write an object literal:

let user = {
    name: "Tim",
    city: "Ottawa, ON",
    country: "Canada"
}

The same applies in reverse. We could pull fields out of user like this:

let name = user.name;
let city = user.city;
let country = user.country;

In modern JavaScript, it’s more common to see a version that mirrors the object literal construction:

let {name, city, country} = user;

This hasn’t always been the case; JavaScript hasn’t always had destructuring assignment. If we dial back the TypeScript compiler to target a prehistoric version of JavaScript (ES3), it converts the code above into this:

var name = user.name, city = user.city, country = user.country;

I say all this just to make the point clear: there’s nothing magical about destructuring. It’s syntactic sugar: a language feature that saves keystrokes for you, the programmer, and makes your code more readable. It doesn’t fundamentally make the language more capable, but it’s a nice quality-of-life improvement.

In JavaScript, destructuring also works with the Array type. One place you tend to see this is React hooks like useState that return multiple values. In theory, you could use it like this:

var myStatePair = React.useState(null);
myStatePair[1]("my new value");

But nobody does that, because it’s bonkers. Instead, you typically see:

var [myState, setMyState] = React.useState(null);
setMyState("my new value");

React uses an Array here because JavaScript doesn’t have a “tuple” type, but it uses an array like a tuple to return a fixed number (two) of things with different types, rather than a variable-length list.

Destructuring Assignment in Rust

Destructuring assignment in Rust is conceptually similar to JavaScript. That is to say: there are cases where there is syntax sugar for extracting elements from a data structure that mirrors the syntax for creating that same data structure.

We can see this in action by destructuring a tuple.

fn my_function(data: &(u32, &str)) {
    let (my_num, my_str) = data;
    println!("my_num: {}, my_str: {}", my_num, my_str);
}

fn main() {
    let data = (4, "ok");
    my_function(&data);
}

As you might expect, this prints: (see for yourself)

my_num: 4, my_str: ok

We can also destructure types we define ourself. Here’s an example similar to the object example we saw in JavaScript above. (try it)

struct User {
    name: String,
    city: String,
    country: String,
}

fn print_user(user: &User) {
    let User {
        name, city, country
    } = user;
    println!("User {} is from {}, {}", name, city, country);
}

fn main() {
    let user = User {
        name: "Tim".to_string(),
        city: "Ottawa, ON".to_string(),
        country: "Canada".to_string(),
    };
    print_user(&user);
}

Sometimes, we don’t need all the fields. Let’s see what happens if we omit one:

struct User {
    name: String,
    city: String,
    country: String,
}

fn city_name(user: &User) -> String {
    let User {city, country} = user;
    format!("{}, {}", city, country)
}

In JavaScript, or even TypeScript, this wouldn’t be a problem. We help ourselves to the fields we need and ignore the rest. But not Rust, which spits back at us:

   Compiling playground v0.0.1 (/playground)
error[E0027]: pattern does not mention field `name`
 --> src/lib.rs:8:9
  |
8 |     let User {city, country} = user;
  |         ^^^^^^^^^^^^^^^^^^^^ missing field `name`

The apparent solution is to add name, like this:

fn city_name(user: &User) -> String {
    let User {city, country, name} = user;
    format!("{}, {}", city, country)
}

The compiler accepts this, but begrudgingly. It compiles our code, but complains:

warning: unused variable: `name`
 --> src/lib.rs:8:30
  |
8 |     let User {city, country, name} = user;
  |                              ^^^^ help: try ignoring the field: `name: _`
  |
  = note: `#[warn(unused_variables)]` on by default

Helpfully, it tells us how to fix it: we can explicitly ignore the name field:

fn city_name(user: &User) -> String {
    let User {city, country, name: _} = user;
    format!("{}, {}", city, country)
}

To understand how this works, it helps to understand that each field in the destructuring expression serves two purposes. It tells the compiler the name of the field from User you want to extract from user, and it tells the compiler the local name you want to assign it to.

It happens to be good programming practice to use names consistently, so it’s often a good default to use the field names as local variables. Naming things is one of the two hard problems in computer science, and here we get to outsource our variable naming to whomever wrote the data structure we’re using.

But it’s important to know that there’s nothing stopping us from renaming fields as part of the destructuring assignment, we just have to be a bit more explicit about it:

fn print_user(user: &User) {
    let User {
        name: fullname, city: metro, country: nation
    } = user;
    println!("User {} is from {}, {}", fullname, metro, nation);
}

This works in TypeScript and modern JavaScript, too, by the way. It even happens to use the same syntax.

Returning to the compiler warning above, Rust complained about the unused name for the same reason it would complain about the unused variable name in this code:

fn main() {
    let name = "Tim";
}

If we instead assign the value to _, it doesn’t complain, even though the assignment is equally pointless:

fn main() {
    let _ = "Tim";
}

When destructuring, _ acts as a sort of black hole. We can match it to any value, and we can’t get the value back. Another place we see this is destructuring tuples where we only care about some of the values:

fn main() {
    let my_tuple = (4, "foo", false);
    
    let (num, _, truthy) = my_tuple;
    
    println!("{} {}", num, truthy);
}

You often see something similar in idiomatic JavaScript, something like this:

let [_, triggerRerender] = React.useState();

The intention that the programmer is signaling to the reader of their code is the same, but in JavaScript the _ is actually just a variable name that programmers treat like a black hole. You could read the value assigned to it if you wanted to (though build-time tools may give it special meaning and complain if you try reading it). In Rust, _ is part of the language, not a variable. If you assign a value to it, it really does nothing.

So to return to our code, when we say this:

fn city_name(user: &User) -> String {
    let User {city, country, name: _} = user;
    format!("{}, {}", city, country)
}

What we mean is “assign user.city to the variable city, assign user.country to the variable country, and do nothing with user.name”.

I took a detour here because I wanted to show you _, which is a fundamental concept that will come up again in a future post when we look at pattern matching. But in the case of destructuring fields, there’s actually a better way. In fact, if we had read the full compiler error (which I snipped above), it would have helpfully guided us in the right direction:

   Compiling playground v0.0.1 (/playground)
error[E0027]: pattern does not mention field `name`
 --> src/lib.rs:8:9
  |
8 |     let User {city, country} = user;
  |         ^^^^^^^^^^^^^^^^^^^^ missing field `name`
  |
help: include the missing field in the pattern
  |
8 |     let User {city, country, name } = user;
  |                            ~~~~~~~~
help: if you don't care about this missing field, you can explicitly ignore it
  |
8 |     let User {city, country, .. } = user;
  |                            ~~~~~~

For more information about this error, try `rustc --explain E0027`.
error: could not compile `playground` due to previous error

“help: if you don’t care about this missing field” - that sure sounds like us. Let’s try it:

struct User {
    name: String,
    city: String,
    country: String,
}

fn city_name(user: &User) -> String {
    let User {city, country, ..} = user;
    format!("{}, {}", city, country)
}

Rust’s philosophy

So, it turns out the compiler was fussing over a couple missing dots!

This may seem tedious. In the same situation, JavaScript knew what we meant. Even TypeScript, which is basically JavaScript plus nitpicking, doesn’t nitpick it. The Rust compiler was smart enough to suggest a change that would fix the problem, why does it choose instead to berate us?

This question hits on the general character of Rust. Anyone who sets out to create a programming language has to make decisions between assuming intent and requiring explicit, disambiguous instructions from the programmer. Rust’s general approach is to err on the side of requiring explicitness. This means that beginner Rust developers will spend a fair bit time trying to appease the compiler in ways they’re not used to, regardless of how smart or experienced they are in other languages.

By forcing you to be explicit, Rust forces you to be deliberate and thoughtful in your code. Treat it like a posture training device: instead of resenting it, use it to develop good habits until the nags become infrequent. Over time, I think you’ll find that it makes you a more thoughtful programmer, even when you’re using languages other than Rust.


To be notified of new posts, follow me on Twitter.