Viscosity
Review
Part X · Drip in Rust · Day 37

146. Tokens as enums

In TypeScript a token’s type was a string that the type checker watched over. In drip-rs it is a one-byte enum, and the token itself is a 32-byte value that points into the source instead of copying it.

About 16 minutes, including the exercises and quiz
By the end you will be able to
  • Translate the TypeScript TokenType union into a Rust enum, and say what each derive buys.
  • Read Token<'src>, and account for each of its 32 bytes.
  • Explain how error and synthetic tokens carry text that isn’t in the source.
  • Write the keyword lookup as a match that returns Option.

Forty names

Open companion/ts/src/token.ts and the token types are strings:

type TokenType =
  | '(' | ')' | '{' | '}' | ','
  // ...
  | 'identifier' | 'string' | 'number'
  | 'and' | 'class' | 'else' // ...
  | 'eof';

The VM’s scanner adds one more, type VmTokenType = TokenType | 'error', for forty in all. At run time each one is an ordinary JavaScript string, and the compiler tests them with current.type === 'identifier'. The union type is what makes a typo like 'identifer' a compile error.

Rust has no string literal types. It has something better suited to the job, and you met it in chapter 128.

Intuition

A TypeScript token type is a name tag with the word written on it, and the type checker reads every tag to make sure the word is on the list. A Rust enum is a set of numbered coat-check tickets. The list is printed on the counter, there is nothing to misspell, and the ticket is tiny.

TokenKind

Here is the start of drip-rs’s version, from scanner.rs:

#[derive(Clone, Copy, Debug,
         PartialEq, Eq)]
pub enum TokenKind {
    // Single-character tokens.
    LeftParen,
    RightParen,
    // ...
    Identifier,
    String,
    Number,
    And,
    Class,
    // ...
    Error,
    Eof,
}

Forty variants, the same forty as the TypeScript union, Error included. Two changes are forced. A variant must be an identifier, so '(' becomes LeftParen and '>=' becomes GreaterEqual. And Rust can’t extend an enum the way TokenType | 'error' extends a union, so Error lives in the one enum from the start.

The derive line asks the compiler to write four trait impls (chapter 131). Copy (with Clone) lets a kind be passed around like an integer. PartialEq and Eq give ==, which the compiler’s check needs:

fn check(&self, kind: TokenKind)
    -> bool {
    self.current.kind == kind
}

Debug prints a variant’s name, Let, for free. Chapter 148 will lean on that.

Definition

A token enum is an enum with one fieldless variant per kind of token. In drip-rs it is TokenKind. Each value is just a discriminant, a small integer chosen by the compiler[2], so it is Copy, compares with ==, and takes 1 byte: forty variants fit in a u8 with room to spare.

Token<‘src>

A token is a kind, the text it came from, and a line:

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

The TypeScript VmToken has the same three fields, but its lexeme is a new string made by source.slice(start, current). Here it is a &str slice: a pointer into the source and a length. Step through the tokens of one line:

Step throughTokens borrow the source
The source bytes of let x = 1.5; with the current token's lexeme bracketedsource: &strl0e1t2␠3x4␠5=6␠718.9510;1112Token {kind: TokenKind::Let,lexeme: "let" (bytes 0..3),line: 1 }
The scanner reads the source and hands out one Token at a time. The first is Let: its kind is TokenKind::Let, and its lexeme is a &str covering bytes 0 to 3 of the source. No text is copied.
1/5

Chapter 133 took this struct apart: 'src ties every token to the text it slices, and E0597 stops a token from outliving it. In drip-rs that never comes up, because the compiler holds the source for the whole compile, and its Compiler<'src, 'a> struct says so in its type. What this chapter adds is the size. A Token is 32 bytes on this machine: 1 byte of kind, a 16-byte slice and an 8-byte line, padded to a multiple of 8 (chapter 139). It is Copy, so the compiler’s previous and current are plain copies, not borrows of the scanner.

Error tokens

When the scanner meets @, it doesn’t stop. It returns a token of kind Error whose lexeme is the message, exactly as the TypeScript scanner does; chapter 133 showed why a &'static str message fits in the &'src str field. That one variant is why Error had to be in the enum from the start, where TypeScript could bolt 'error' on.

The same trick makes tokens the compiler invents. To compile this inside a method, or super in a subclass, drip-rs calls Token::synthetic("this", line): an Identifier whose lexeme is a literal, so the compiler can resolve it like any other variable name.

Keywords

The TypeScript scanner looks words up in a Map from text to type, KEYWORDS. drip-rs uses a match on the text instead:

fn keyword(text: &str)
    -> Option<TokenKind> {
    use TokenKind as T;
    Some(match text {
        "and" => T::And,
        "class" => T::Class,
        // ... 14 more
        _ => return None,
    })
}

Two small idioms here. use TokenKind as T gives the enum a short local name. And the _ arm says return None, which leaves the function, so every arm that finishes yields a TokenKind and the Some(...) wraps it. The caller turns “not a keyword” into an identifier:

let kind = keyword(text)
    .unwrap_or(TokenKind::Identifier);

There is no table to build at startup and nothing to allocate. And the match patterns are exact strings, so classy and Class both fall through to _.

Try itKinds, tokens and keywords
#[derive(Clone, Copy, Debug, PartialEq)]
enum TokenKind {
    LeftParen,
    Identifier,
    Number,
    Fn,
    Let,
    Error,
    Eof,
}

#[derive(Clone, Copy, Debug)]
struct Token<'src> {
    kind: TokenKind,
    lexeme: &'src str,
    line: usize,
}

fn keyword(text: &str)
    -> Option<TokenKind> {
    use TokenKind as T;
    Some(match text {
        "fn" => T::Fn,
        "let" => T::Let,
        _ => return None,
    })
}

fn word<'s>(text: &'s str)
    -> Token<'s> {
    use TokenKind as T;
    let kind = keyword(text)
        .unwrap_or(T::Identifier);
    let line = 1;
    Token { kind, lexeme: text, line }
}

fn main() {
    let src = String::from("let fun");
    let a = word(&src[0..3]);
    let b = word(&src[4..7]);
    println!("{:?}", a);
    println!("{:?}", b.kind);
    let t = TokenKind::Let;
    println!("{}", a.kind == t);

    // An error token: its lexeme is a
    // &'static str message, not source.
    let e = Token {
        kind: TokenKind::Error,
        lexeme: "Unexpected character.",
        line: 3,
    };
    let (n, m) = (e.line, e.lexeme);
    println!("[line {n}] {m}");

    use std::mem::size_of;
    let k = size_of::<TokenKind>();
    let t = size_of::<Token>();
    println!("kind {k} B, token {t} B");
}
Open in Rust Playground ↗
Under the hood

Couldn’t the token carry its value, Number(f64) or String(&str), like the tree-walker’s literal field? That would be a design choice, and drip-rs doesn’t make it. Its kinds stay fieldless, so every kind is Copy, 1 byte, and comparable with == against a constant, which is how check, consume and matches work. The compiler parses numbers itself when it emits them, with self.previous.lexeme.parse(), and strips the quotes from strings the same way the TypeScript compiler does. TypeScript also hid a cost here: each token’s lexeme was a fresh string, while the Rust scanner, its file’s comment says, never allocates.

Your turn

ExerciseAll sixteen keywords

Drip has sixteen reserved words: and class else false fn for if let nil or print return super this true while. Finish keyword so it returns the right Kind for each and None for anything else (keywords are case-sensitive). Then write word_kind, which the scanner calls after reading a word: a keyword’s kind, or Kind::Identifier.

#[derive(Clone, Copy, Debug, PartialEq)]
enum Kind {
    And, Class, Else, False, Fn, For,
    If, Let, Nil, Or, Print, Return,
    Super, This, True, While,
    Identifier,
}

fn keyword(text: &str) -> Option<Kind> {
    use Kind as K;
    Some(match text {
        "and" => K::And,
        "class" => K::Class,
        // ...the other fourteen
        _ => return None,
    })
}

fn word_kind(text: &str) -> Kind {
    todo!()
}
Open in Rust Playground ↗
Extra practiceBack to TypeScript names

To compare drip-rs’s scanner with the TypeScript one token by token, you need each Kind written the TypeScript way. Write ts_name: punctuation is named by its own text (LeftParen is "(", BangEqual is "!="), and the others by a lowercase word (Identifier is "identifier", Str is "string", Fn is "fn", Error is "error", Eof is "eof").

#[derive(Clone, Copy, Debug, PartialEq)]
enum Kind {
    LeftParen, RightParen, Bang,
    BangEqual, Less, LessEqual,
    Identifier, Str, Number,
    Fn, Error, Eof,
}

fn ts_name(k: Kind) -> &'static str {
    todo!()
}
Open in Rust Playground ↗

What you learned

  • drip-rs’s TokenKind is a token enum: forty fieldless variants, one per TypeScript token type, Error included. Punctuation gets names like LeftParen.
  • #[derive(Clone, Copy, Debug, PartialEq, Eq)] makes kinds cheap to copy, comparable with == and printable by name. A kind is 1 byte.
  • Token<'src> holds a kind, a line and a &'src str lexeme that points into the source. A token is 32 bytes and can’t outlive its source.
  • Error and synthetic tokens use &'static str literals, which outlive any 'src.
  • Keywords are a match on the text that returns Option<TokenKind>.

Check your understanding

0/6
  1. 1.

    How does drip-rs represent the TypeScript token type '>='?

  2. 2.

    What is size_of::<TokenKind>() in drip-rs?

  3. 3.

    Why does the compiler’s check(kind) compile as self.current.kind == kind?

  4. 4.

    drip-rs’s Token is 32 bytes. Where do they go?

  5. 5.

    To compile this inside a method, drip-rs needs an Identifier token whose text isn’t at that spot in the source. How does it get one?

  6. 6.

    What does keyword("Class") return in drip-rs?

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. Enumerations. The Rust Reference. doc.rust-lang.org/reference/items/enumerations.html (checked 2026-10-31)