29. Numbers, strings, names and keywords
Your scanner can already cut out + and ==. Real programs are mostly made of longer words: 4.25, "hello", total, while. Here is how the scanner reads each one, and how it tells your names apart from the language’s own.
- Scan a number literal, using two characters of lookahead to decide whether a dot belongs to it.
- Scan a string literal, and say what its lexeme and its literal value each hold.
- Scan identifiers, and pick out keywords with a reserved-word table.
- Explain why the scanner reads a whole word before deciding whether it is a keyword.
The long tokens
Here is one line of Drip:
let total = 4.25;
The scanner from the last chapter handles = and ; already. But the
other three tokens are longer than one or two characters, and each needs
its own small loop: a number, a name chosen by the programmer, and the
word let, which belongs to the language. This chapter writes those
loops. They are the last pieces of Drip’s scanner, which lives in
companion/ts/src/scanner.ts. The excerpts below are lightly simplified
to fit a phone screen.
Numbers
A literal is a value written down as itself. 4.25 doesn’t point at a
number somewhere else, the way a variable name does. It is the number,
spelled out in the source, like a price printed on a tag.
A Drip number is one or more digits, optionally followed by a dot and
more digits. So 7, 007 and 4.25 are numbers. The scanner knows a
number has started when it sees a digit:
if (isDigit(c)) {
while (isDigit(peek())) advance();
if (peek() === '.' &&
isDigit(peekNext())) {
advance(); // the '.'
while (isDigit(peek())) advance();
}
const text = source.slice(start, current);
add('number', Number(text));
}
The interesting line is the dot test. When the scanner reaches a .
after some digits, one character of lookahead isn’t enough to decide.
4.25 is one number, but in 4. the dot is not part of the number.
In Drip, a dot can also mean “get a property”, as in point.x. So the
scanner peeks two characters ahead, and only takes the dot if a digit
follows it.
if (isDigit(c)) {while (isDigit(peek())) advance();if (peek() === '.' &&isDigit(peekNext())) {advance(); // the '.'while (isDigit(peek())) advance();}add('number', Number(lexeme()));} else if (isAlpha(c)) {while (isAlphaNum(peek())) advance();add(KEYWORDS.get(lexeme())?? 'identifier');}
The last line hands the text to Number, JavaScript’s own converter,
which turns '4.25' into a 64-bit float[2]. Drip
gets its number format for free from its host language. The token now
carries two things: the lexeme '4.25', exactly as written, and the
value 4.25 in its literal field.
A literal is a value written directly in the source code: a number
such as 4.25 or a string such as "hi". The scanner records both the
lexeme (the characters) and the literal value they stand for, which
is what the running program will use.
Strings
A Drip string starts with " and runs to the next ". Unlike
JavaScript, Drip has no escape sequences like \n, and a string may run
across several lines:
case '"': {
while (peek() !== '"' && !atEnd()) {
if (peek() === '\n') line++;
advance();
}
if (atEnd()) {
error(line, 'Unterminated string.');
break;
}
advance(); // the closing "
// the value leaves out both quotes
add('string',
source.slice(start + 1, current - 1));
break;
}
Two details matter. The loop still counts newlines, so every token after
a long string still gets the right line number. And if the file ends
before the closing quote, the scanner reports Unterminated string.
instead of quietly inventing a quote. For "hi", the lexeme is the four
characters "hi", quotes included. The literal value is the two-letter
string hi.
Names
An identifier is a label you write on a box yourself. Drip doesn’t care what the label says, as long as it is spelled with the allowed letters.
An identifier starts with a letter or an underscore, and continues with
letters, digits or underscores. So total, _count2 and x1 are
identifiers. 2x is not: the scanner sees the digit first, scans the
number 2, and then starts a fresh identifier x.
const isAlpha = (c: string) =>
(c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') || c === '_';
// in the scanner's switch:
} else if (isAlpha(c)) {
while (isAlpha(peek()) ||
isDigit(peek())) advance();
const text = source.slice(start, current);
add(KEYWORDS.get(text) ?? 'identifier');
}
An identifier is a name chosen by the programmer: a variable,
function or class name. In Drip, it is a letter or _ followed by any
number of letters, digits and _.
Keywords
Look at the last line of that code again. let, if and while are
spelled exactly like identifiers. The scanner reads them with the same
loop, and only then asks a table whether the word is special:
const KEYWORDS = new Map([
['and', 'and'], ['class', 'class'],
['else', 'else'], ['false', 'false'],
['fn', 'fn'], ['for', 'for'],
['if', 'if'], ['nil', 'nil'],
['or', 'or'], ['print', 'print'],
['return', 'return'],
['super', 'super'], ['this', 'this'],
['true', 'true'], ['let', 'let'],
['while', 'while'],
]);
Each keyword gets its own token type, named after itself. If the table
has no entry, get returns undefined, and ?? falls back to
'identifier'.
Why not spot if as soon as the scanner sees an i and an f? Because
of maximal munch. In let iffy = 1;, a scanner that stopped at if
would produce the keyword if and then a stray fy. Reading the whole
word first, then looking it up, gets iffy right.
A keyword is a word with a fixed meaning in the language, such as
if, while or let. In Drip, every keyword is reserved: it can’t
be used as a name. The scanner finds keywords with a reserved-word
table, a map from each keyword’s spelling to its token type, consulted
after a whole word has been scanned.
Because keywords are reserved, let if = 3; is an error. The parser gets
the token if where it needs an identifier, and Drip reports
Error at 'if': Expect variable name. Notice also that true, false
and nil are keywords to the scanner. The parser, a few chapters on,
turns them into literal values.
print 42; print 4.250; print "Drip strings can span lines"; let iffy = "a name, not if"; print iffy; let _count2 = 7; print _count2; print true; print nil; // Try: let if = 3; // Try: print 2x;
JavaScript’s keywords are less tidy. Its specification separates
keywords (words with a job in the grammar) from reserved words
(words you can’t use as names). if and while are both, but async
is a keyword that is not reserved, so let async = 1 is legal
JavaScript[3]. Whether a word like async
acts as a keyword depends on where it appears, which a lookup table on
its own can’t know. Drip
keeps every keyword reserved, so one table is enough. The companion’s
bytecode VM (Part IV) has a second scanner, which hands out one token at a
time as the compiler asks for it, but it looks words up in this same
KEYWORDS table. Drip itself is a dialect of Lox, the language of
Crafting Interpreters, with fn and let in place of Lox’s fun
and var[1].
Your turn
Write scanNumber(s, i). A number starts at index i of the string s. Return { value, end }: value is the number, and end is the index just after its last character. A number is digits, optionally followed by a . and more digits. Take the dot only if a digit comes right after it. (s[j] past the end is undefined, and isDigit(undefined) is false.)
const isDigit = (c) =>
c >= '0' && c <= '9';
function scanNumber(s, i) {
// your code here
}
console.log(scanNumber('4.25;', 0));
console.log(scanNumber('7.x', 0));Write scanWord(s, i). A word starts at index i (its first character is a letter or _). Take letters, digits and underscores, then return { type, lexeme }. The type comes from the KEYWORDS table, or is 'identifier' if the word isn’t in it.
const KEYWORDS = new Map([
['fn', 'fn'], ['if', 'if'],
['let', 'let'], ['while', 'while'],
]);
const isAlpha = (c) =>
(c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') || c === '_';
const isDigit = (c) =>
c >= '0' && c <= '9';
function scanWord(s, i) {
// your code here
}
console.log(scanWord('iffy = 1', 0));What you learned
- A literal is a value written in the source. Its token keeps the
lexeme (
'4.25') and the literal value (4.25). - Numbers need two characters of lookahead: the dot is taken only if a digit follows it.
- A string’s value leaves out the quotes. The scanner counts newlines inside it and reports a string that never ends.
- An identifier is a letter or
_, then letters, digits and_. - A keyword is scanned like an identifier, then found in a reserved-word table. Reading the whole word first is maximal munch.
The scanner is finished. Next: rules for which token sequences make sense.
Check your understanding
0/6- 1.
Which of these is a literal in the Drip line
let total = 4.25;? - 2.
Drip’s scanner reads the source
7.foo. What does it make of the first two characters? - 3.
What are the lexeme and the literal value of the Drip token for
"ok"? - 4.
A Drip scanner checked for keywords as soon as their letters appeared, before reading the whole word. What would go wrong with
let format = 1;? - 5.
In Drip’s scanner, what happens to a word that is not in the
KEYWORDStable? - 6.
JavaScript lets you write
let async = 1, but Drip rejectslet fn = 1;. Why the difference?
References
- [1] Robert Nystrom (2021). Crafting Interpreters. Free web edition at craftinginterpreters.com (© 2015–2021); source, text and test suite at github.com/munificent/craftinginterpreters. github.com/munificent/craftinginterpreters (checked 2026-10-31)
- [2] MDN contributors. Number. MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number (checked 2026-10-31)
- [3] Ecma International, TC39. ECMAScript Language Specification (ECMA-262), “Keywords and Reserved Words”. Ecma International; living draft at tc39.es/ecma262. tc39.es/ecma262/#sec-keywords-and-reserved-words (checked 2026-10-31)