Collections in Rust: Vectors, Strings, and HashMaps

I walk through Vec<T>, the String vs &str distinction, and HashMap<K,V> with the entry API — covering the ownership pitfalls TypeScript developers hit first.

Collections in Rust: Vectors, Strings, and HashMaps

TypeScript arrays take anything — strings, numbers, objects, functions, all mixed in the same declaration. Rust’s Vec<T> has a push method, an iter method, and indexed access, so it looks familiar. Write a few programs and it feels like a typed array you already know.

Then you push a String into a vec![], try to print that same String one line later, and the compiler refuses with “value used after move.” The ownership rules from part 3 don’t pause for standard library types.

This post covers the three collections you’ll reach for constantly: Vec<T>, String (and why &str exists alongside it), and HashMap<K, V>. All three are from chapter 8 of The Rust Programming Language. I’ll compare them to TypeScript’s Array and Map where the comparison is useful, and flag the specific places where ownership catches you first.

Series: Part 10 of 12. Start with Part 1: Learning Rust as a TypeScript Developer · Previous: Error Handling in Rust with Result and Option

On this page

Vec<T>: the growable list

Vec<T> stores values of a single type on the heap, in contiguous memory, with a length you don’t have to know at compile time. It can grow as you push. The TypeScript equivalent is Array<T>, with one key difference: a Vec<String> holds only String values — no mixed types without some extra work.

let mut scores: Vec<i32> = Vec::new();
scores.push(42);
scores.push(17);
scores.push(8);

// Or use the vec! macro when you have initial values:
let primes = vec![2, 3, 5, 7, 11];

The type annotation on the first line (Vec<i32>) is only required when you don’t give the compiler something to infer from. Vec::new() alone tells Rust nothing about what you plan to store.

Iteration uses borrowing. The for loop borrows each element; it doesn’t consume the vector:

let scores = vec![10, 20, 30];

// Immutable: borrow each element
for score in &scores {
    println!("{score}");
}

// Mutable: borrow each element mutably, then dereference to modify
let mut scores = vec![10, 20, 30];
for score in &mut scores {
    *score *= 2;
}
// scores is now [20, 40, 60]

The *score *= 2 dereferences the mutable borrow. score is &mut i32 — you have to go through it to reach the value underneath.

Slicing gives you a view into the existing memory without copying:

let v = vec![10, 20, 30, 40, 50];
let middle: &[i32] = &v[1..4]; // [20, 30, 40] — no allocation

When a Vec goes out of scope, Rust drops every element and frees the heap memory automatically. No garbage collector involved.

Reading elements without panicking

Two ways to access an element: index syntax and .get(). The behaviour under a bad index is completely different.

let v = vec![10, 20, 30];

let second = v[1];      // panics if index is out of bounds
let third  = v.get(2);  // returns Option<&i32>, never panics
let absent = v.get(99); // returns None, not a crash

Index syntax (v[1]) behaves like TypeScript accessing a valid index — fine until it isn’t, then it crashes at runtime. The difference from JavaScript is that Rust’s crash is an explicit, handled panic rather than a silent undefined.

.get() returns Option<&T>. You handle it with match or if let. Any time the index might be out of range — user input, external data, anything you don’t fully control — .get() is the right choice.

Before you continue: What does v.get(99) return on a Vec with three elements?

The answer is None. Not a panic, not a crash, not an undefined value. Just a clear signal from the type system that the index doesn’t exist.

Check this before moving on

  • You can create a Vec<i32> with initial values using the vec![] macro
  • You know to write for score in &mut scores when you need to modify elements in place
  • You understand why v.get(2) returns Option<&i32> while v[2] returns i32

String and &str: two types for text

TypeScript has one string type. Rust has two you’ll see constantly, and they serve different purposes:

  • &str: a string slice — an immutable reference to UTF-8 bytes stored somewhere (a string literal lives in the binary; a slice of a String borrows from heap memory). You can’t grow or modify a &str.
  • String: an owned, growable, heap-allocated UTF-8 string. It’s implemented as a wrapper around Vec<u8> with extra guarantees. You can push to it, modify it, and transfer ownership.
let greeting: &str = "good morning";           // string literal, backed by the binary
let name: String = String::from("Soumitra");    // heap-allocated, owned

The rule for function signatures: accept &str unless you genuinely need ownership. A function that takes &str works with both string literals and &String values — the compiler coerces &String to &str automatically. A function that takes String forces the caller to either own or clone:

// Accepts both: greet("literal") and greet(&owned_string)
fn greet(name: &str) {
    println!("Hi, {name}");
}

let owned = String::from("Soumitra");
greet(&owned);  // &String coerces to &str
greet("world"); // &str directly

Growing a String:

let mut s = String::from("hello");
s.push_str(", world"); // appends a &str — doesn't take ownership of it
s.push('!');           // appends a single char

For concatenating three or more strings, format! is cleaner than the + operator:

let first  = String::from("tic");
let second = String::from("tac");
let third  = String::from("toe");

let joined = format!("{first}-{second}-{third}");
// All three Strings remain valid — format! borrows, never moves

The + operator works but has a sharp edge: it calls add(self, s: &str), which takes ownership of its left operand. So first + "-" + &second + "-" + &third would move first and make it unusable afterward. With only two strings that’s manageable; past that, format! is almost always the right choice.

Indexing into a String doesn’t compile. s[0] is a compile error. The reason is UTF-8 encoding: a single character might occupy 1, 2, 3, or 4 bytes. A byte-level integer index can land in the middle of a multi-byte character and produce garbage. Rust surfaces this at compile time rather than silently returning a wrong value at runtime — which is the trade-off the language consistently makes.

If you need to iterate by character:

for c in "café".chars() {
    println!("{c}");
}
// prints: c  a  f  é

The characters c, a, f are each one byte in UTF-8. The é is two bytes. The .chars() iterator gives you actual Unicode scalar values regardless of byte width.

HashMap<K, V>: keys and values

HashMap<K, V> maps keys of type K to values of type V using a hash function. The TypeScript equivalent is Map<K, V>. Unlike Vec and String, HashMap isn’t in Rust’s prelude — you have to bring it into scope:

use std::collections::HashMap;

let mut word_count: HashMap<String, u32> = HashMap::new();
word_count.insert(String::from("hello"), 1);
word_count.insert(String::from("world"), 2);

Getting a value:

let key = String::from("hello");
let count = word_count.get(&key)  // returns Option<&u32>
    .copied()                      // turns Option<&u32> into Option<u32>
    .unwrap_or(0);                 // default to 0 if the key is absent

The .copied() call converts Option<&u32> to Option<u32>. Without it, you hold a reference into the map that borrows it until the Option is dropped. For Copy types like integers, .copied() is the cleaner idiom.

Iterating over all key-value pairs:

for (word, count) in &word_count {
    println!("{word}: {count}");
}

The iteration order is not guaranteed. HashMap uses SipHash by default, chosen for security rather than sorted output. If your tests ever depend on the loop order, they’ll pass sometimes and fail others. Sort before comparing:

let mut pairs: Vec<_> = word_count.iter().collect();
pairs.sort_by_key(|(word, _)| *word);

Ownership on insert: When you insert a String as a key or value, the HashMap takes ownership. The original variables become invalid:

let key   = String::from("color");
let value = String::from("blue");
word_count.insert(key, value);
// key and value were moved — can't use them here

Types that implement Copy — integers, floats, booleans — are copied in, so the originals stay valid. String values are moved.

The entry API: insert-or-update in one step

The most common HashMap pattern is: check whether a key exists; if it doesn’t, insert a default; if it does, update the existing value. The entry API handles this without a double lookup:

use std::collections::HashMap;

let text = "rust is great and rust is fast";
let mut counts: HashMap<&str, u32> = HashMap::new();

for word in text.split_whitespace() {
    let count = counts.entry(word).or_insert(0);
    *count += 1;
}

println!("{counts:?}");
// {"rust": 2, "is": 2, "great": 1, "and": 1, "fast": 1}

entry(word) returns an Entry — a handle that inspects whether the key exists. .or_insert(0) either returns a mutable reference to the existing value, or inserts 0 and returns a mutable reference to it. Either way, you get &mut u32 back. The *count += 1 increments through the mutable reference.

The TypeScript equivalent requires two separate map operations:

const current = counts.get(word) ?? 0;
counts.set(word, current + 1);

The Rust version hashes the key once. There’s also .or_insert_with(|| expensive_default()) for when computing the default itself is worth skipping if the key already exists.

Three pitfalls TypeScript developers hit first

Pitfall 1 — moving a String into a Vec:

let name = String::from("Soumitra");
let names = vec![name]; // name moved into the Vec
println!("{name}");     // ❌ error: value used after move

TypeScript arrays hold references. Rust’s Vec<String> takes ownership of each String element. If you need the value in both places, clone it:

let name = String::from("Soumitra");
let names = vec![name.clone()]; // explicit clone: new heap allocation
println!("{name}");              // ✅ original still valid

Or store &str slices if you only need to read, not own.

Pitfall 2 — holding a borrow while pushing:

let mut v = vec![1, 2, 3];
let first = &v[0];  // immutable borrow of an element
v.push(4);          // ❌ mutable borrow while immutable borrow is active
println!("{first}");

Pushing might reallocate the Vec’s backing buffer to make room. If that happens, first would point to freed memory. The borrow checker catches this at compile time. The fix is to not hold the element reference across the push:

let mut v = vec![1, 2, 3];
let first = v[0]; // i32 implements Copy — value copied out, no borrow held
v.push(4);
println!("{first}"); // ✅ fine

Pitfall 3 — relying on HashMap iteration order:

HashMap does not guarantee any insertion order, key order, or stable output order between runs. The TypeScript Map preserves insertion order. This catches TypeScript developers who write a test that asserts the output of a loop over a HashMap and wonder why it passes on their machine but fails in CI.

Collections are just the ownership rules applied to data

Nothing in Vec<T>, String, or HashMap<K, V> introduces new rules. The ownership system works the same way here as it does everywhere else — a push moves an owned value, a borrow of an element limits what you can do with the collection, and the borrow checker surfaces violations before the code runs.

If you’re coming from JavaScript, the iteration patterns you already know from array methods map closely: .map(), .filter(), and .fold() exist on Rust’s iterator adapters with almost identical semantics. The calling convention differs — Rust iterators are lazy, collected explicitly — but the mental model transfers.

The thing that doesn’t transfer is the ownership distinction. A TypeScript array is always a shared view you can read from anywhere. A Vec<T> is owned data with one current owner. Hold that difference clearly, and the borrow checker’s responses to collection operations become predictable.

Sources