Viscosity
Review
Part IX · Rust for TypeScript developers · Day 32

126. Slices and strings

A scanner spends its whole life pointing at pieces of the source. Rust gives you a type for “a piece of something someone else owns”, and the scanner in the Rust Drip VM never copies a byte of text.

About 18 minutes, including the exercises and quiz
By the end you will be able to
  • Take a slice of an array or a string with a range, and say what a slice holds.
  • Write functions that accept any slice, of any length.
  • Choose between String and &str, and convert between them.
  • Explain why a slice keeps its owner borrowed, and why string slices count bytes.

A token is a piece of the source

In chapter 28 your scanner made a token for every word of a Drip program, and each token carried its lexeme: the exact text it came from. The TypeScript scanner gets that text with

lexeme: source.slice(start, current)

which hands you a new string value. But the text already exists. It is sitting right there in source, between start and current. All a token really needs is a way to say “those bytes, over there”.

Rust has a type for exactly that, and it builds on the last chapter: it is a borrow of part of a value.

Intuition

A highlighter pen on a printed page. You don’t photocopy the sentence you care about. You mark where it starts and how far it goes, and the page stays where it is, owned by whoever printed it.

Slices of arrays

Write a range in square brackets after a borrow:

let a = [10, 20, 30, 40, 50, 60];
let mid = &a[1..4];   // 20, 30, 40
let head = &a[..2];   // 10, 20
let tail = &a[3..];   // 40, 50, 60

The range 1..4 starts at 1 and stops before 4. Leave out a side and it runs to the start or the end. Step through what each one points at:

Step throughA slice is a window, not a copy
An array or string as a row of cells, with a slice drawn as a pointer and a length covering some of the cellslet a = [10, 20, 30, 40, 50, 60];OWNER a: [i32; 6]100201302403504605
An array of six i32s, side by side in memory. It owns its numbers. The small grey numbers are the indices.
1/7
Definition

A slice is a borrowed view of a contiguous run of elements owned by something else. The type &[T] is a slice of Ts. A slice value holds a pointer to its first element and a length, so it is twice the size of a plain reference[3]. Every access through a slice is bounds-checked[2].

The real payoff is in function parameters. A function that takes &[i32] doesn’t care how long the array is, or whether it is getting all of it:

fn sum(xs: &[i32]) -> i32 {
    let mut total = 0;
    let mut i = 0;
    while i < xs.len() {
        total += xs[i];
        i += 1;
    }
    total
}

sum(&a), sum(&a[1..4]) and sum(&a[3..]) all work. A borrow of a whole array turns into a slice automatically.

Ask for a range that isn’t there, like &a[2..9], and the program stops at run time with a message (Rust calls this a panic):

range end index 9 out of range for
slice of length 6
Try itOne function, many windows
fn sum(xs: &[i32]) -> i32 {
    let mut total = 0;
    let mut i = 0;
    while i < xs.len() {
        total += xs[i];
        i += 1;
    }
    total
}

fn main() {
    let a = [10, 20, 30, 40, 50, 60];
    let mid = &a[1..4];
    println!("len {}", mid.len());
    println!("mid {}", sum(mid));
    println!("all {}", sum(&a));
    println!("tail {}", sum(&a[3..]));
}
Open in Rust Playground ↗

String and &str

You met String in chapter 124. It owns its text on the heap, and it can grow. A string slice, written &str, is a view of some text that somebody else owns. That text might live in a String, or in the compiled program itself: a string literal like "print" is a &str, which is why literals can’t be changed[1].

let mut own = String::from("let");
own.push_str(" x");     // String: grows

let view: &str = &own;  // whole String
let kw = &own[0..3];    // "let"
let lit = "print";      // &str literal

let copy = lit.to_string(); // new String

Converting from String to &str is free: it is just a borrow. Going the other way, String::from(x) or x.to_string(), copies the bytes into a fresh allocation.

So which should a function take? Prefer &str. A &str parameter accepts literals and slices, and when you pass &own for a String, Rust converts the &String to a &str for you[1].

fn lexeme(
    src: &str,
    start: usize,
    end: usize,
) -> &str {
    &src[start..end]
}
Definition

String vs &str: a String is an owned, growable buffer of UTF-8 text on the heap. A &str is a borrowed string slice: a pointer and a length into UTF-8 text owned by something else, such as a String or the program binary. Take &str in parameters. Make a String when you need to own or grow text.

Try itLexemes without copying
fn lexeme(
    src: &str,
    start: usize,
    end: usize,
) -> &str {
    &src[start..end]
}

fn main() {
    let s = String::from("let x = 10;");
    let name = lexeme(&s, 4, 5);
    let num = lexeme(&s, 8, 10);
    println!("{name} and {num}");

    let lit = "print";
    println!("{}", lexeme(lit, 0, 2));

    let mut own = String::from(name);
    own.push_str("_2");
    println!("{own} {}", own.len());
}
Open in Rust Playground ↗

Slices keep their owner borrowed

A slice is a borrow, so last chapter’s rule applies. This doesn’t compile:

let mut s = String::from("let x;");
let word = &s[0..3];
s.clear();
println!("{word}");

clear needs to change s, so it takes a mutable borrow, and word is still reading. The error is the same E0502 (real rustc 1.97 output, trimmed):

error[E0502]: cannot borrow `s` as
  mutable because it is also borrowed
  as immutable

In TypeScript, the token’s lexeme would be its own string and the question never comes up. In Rust a slice is cheaper, and the price is that the source has to stay put while tokens point into it. For a scanner that is exactly right: the source doesn’t change while you scan it.

Pitfall

String indices count bytes, not characters. "héllo".len() is 6, because é takes two bytes in UTF-8. Slice in the middle of one and the program panics (real output, trimmed):

end byte index 2 is not a char boundary;
it is inside 'é' (bytes 1..3 of string)

This is different from TypeScript, whose length counts UTF-16 code units (chapter 10). Neither one counts “characters”.

Under the hood

Here is the token type from the Rust Drip scanner, companion/rust/src/scanner.rs, lightly trimmed:

pub struct Token<'src> {
    pub kind: TokenKind,
    pub lexeme: &'src str,
    pub line: usize,
}

and here is how it makes one:

lexeme: &self.source[
    self.start..self.current],

Ignore the 'src for now: it is a lifetime, the subject of chapter 133, and it tells the compiler that a token can’t outlive the source it points into. The file’s own comment says the result: a token borrows its lexeme straight out of the source text, so scanning never allocates. The scanner also keeps bytes: &[u8], a byte view of the same text, because every character that matters to Drip is ASCII.

Slices matter for Viscosity too. In a struct-of-arrays layout every x is in one array, and &xs[from..to] hands a chunk of that column to any function that takes &[f64], with no copying. You will lean on that in chapter 140.

Your turn

ExerciseCut an identifier out of the source

Write take_ident(src), which returns the identifier at the very start of src: the longest run of ASCII letters, digits and _ (use the is_ident helper). Return a **slice of src**, not a new String. If src starts with anything else, return an empty slice.

fn is_ident(c: u8) -> bool {
    c.is_ascii_alphanumeric()
        || c == b'_'
}

fn take_ident(src: &str) -> &str {
    // your code here
    ""
}
Open in Rust Playground ↗
Extra practiceSum part of a column

A struct-of-arrays layout keeps every particle’s x in one array. Write sum_range(xs, from, to) that adds up xs[from..to] by slicing first, then summing the slice.

fn sum_range(
    xs: &[f64],
    from: usize,
    to: usize,
) -> f64 {
    0.0
}
Open in Rust Playground ↗

What you learned

  • A slice &[T] is a borrowed window on a run of elements: a pointer and a length. &a[1..4] excludes its end, and every access is bounds-checked.
  • Functions that take slices work on whole arrays and on parts of them.
  • String owns growable UTF-8 text. &str borrows text that lives elsewhere, including string literals. Take &str in parameters.
  • A slice keeps its owner borrowed, so the owner can’t be changed while the slice is in use.
  • String lengths and indices are in bytes.

Next: structs. You have seen one already, Token. Time to write your own, and give them methods.

Check your understanding

0/6
  1. 1.

    let a = [5, 6, 7, 8]; What is &a[1..3]?

  2. 2.

    What is stored in a &[i32] slice value?

  3. 3.

    Which function signature accepts "lit", &my_string and &my_string[2..] all three?

  4. 4.

    Does this compile?

    let mut s = String::from("let x;");
    let word = &s[0..3];
    s.push_str(" y");
    println!("{word}");
  5. 5.

    In let lit = "print";, where do the five bytes live?

  6. 6.

    Why does the Rust Drip scanner make each lexeme a &str slice of the source?

References

  1. [1] Steve Klabnik, Carol Nichols, Chris Krycho (2026). The Rust Programming Language (3rd edition). No Starch Press; free online at doc.rust-lang.org/book. doc.rust-lang.org/book/ (checked 2026-10-31)
  2. [2] The Rust Reference contributors. Slice types. The Rust Reference. doc.rust-lang.org/reference/types/slice.html (checked 2026-10-31)
  3. [3] The Rust Reference contributors. Dynamically sized types. The Rust Reference. doc.rust-lang.org/reference/dynamically-sized-types.html (checked 2026-10-31)