Tokenizing text
Turn a string into a list of meaningful pieces, called tokens, by scanning it once and grouping characters by kind.
O(n)Space O(n)11 practice problemsStep-through pictureThe idea
A computer can't work with price * (qty + 2) as one long string. It first splits it into tokens: the name price, the symbol *, the symbol (, the name qty, the symbol +, the number 2, and the symbol ). This step is called tokenizing, or lexing, and the code that does it is a tokenizer.
A tokenizer walks the text with an index i. It looks at the current character and decides what kind of token starts here. A digit starts a number, so it keeps moving i while digits continue. A letter starts a name. A symbol like + is a token on its own. Spaces are skipped.
Each token is cut out with source.slice(start, i), where start is where the token began. The result is a list of small objects like { type: "number", value: "2" } that the next step, such as a calculator or a parser, can read easily.
An everyday example
Sorting a sentence into word cards
You copy a sentence onto cards, one card per word or punctuation mark. You put your finger on the first letter, slide it right until the word ends, and write that word on a card. A comma gets its own card. Spaces don't get cards at all. Each card also gets a small label: word, number, or punctuation.
| In the example | In the code |
|---|---|
| Your finger on the sentence | The index i |
| Where the current word began | start |
| Sliding to the end of the word | while (... /[0-9.]/.test(source[i])) i++ |
| Writing a card | tokens.push({ type, value: source.slice(start, i) }) |
| Skipping spaces | if (char === " ") i++ |
From the slow way to tokenizing text
- A first try is
source.split(" "). Forrate * (h1 - 0.5)it givesrate,*,(h1,-, and0.5): the brackets stick to their neighbors. Andrate*(h1-0.5), with no spaces, wouldn't split at all. Spaces aren't what separates tokens. - So what decides where a token ends? Its kind. A number continues while digits or
.follow; a name continues while letters, digits, or_follow; a symbol like*is always one character. The first character of a token tells you its kind, and the kind tells you where it stops. - That suggests reading with an index
ithat only moves forward. Look atsource[i], pick the branch for its kind, rememberstart = i, moveiwhile the token continues, and cut the token out withsource.slice(start, i). Spaces get a branch of their own that just movesiby one. - Anything that fits no branch is an error, and reporting the character and its index right away makes it easy to fix. Pick a kind, consume it, push a token, and repeat until
ireaches the end: that loop is a tokenizer.
How it works
- Make an empty
tokenslist and seti = 0. - While
iis inside the text, readchar = source[i]. - If
charis a space, skip it withi++. - If
charis a digit, rememberstart = i, moveiforward while digits or.continue, and push anumbertoken withsource.slice(start, i). - If
charis a letter or_, do the same for letters, digits, and_, and push anametoken. - If
charis one of the symbols+-*/(), push it as asymboltoken and moveiby one. - Anything else is an error: throw with the character and its position so the user can fix it.
Watch it run
This picture runs the code below on a real input. Press Step to move one step at a time, or Play to watch. The highlighted line is the one that just ran. Change the input to see other cases.
See every step at onceThe same run, laid out side by side
The code
Point at a note to light up the lines it explains.
function tokenize(source) {const tokens = [];let i = 0;while (i < source.length) {const char = source[i];if (char === " ") {i++;} else if (/[0-9]/.test(char)) {const start = i;while (i < source.length && /[0-9.]/.test(source[i])) i++;tokens.push({ type: "number", value: source.slice(start, i) });} else if (/[A-Za-z_]/.test(char)) {const start = i;while (i < source.length && /[A-Za-z0-9_]/.test(source[i])) i++;tokens.push({ type: "name", value: source.slice(start, i) });} else if ("+-*/()".includes(char)) {tokens.push({ type: "symbol", value: char });i++;} else {throw new Error(`Unexpected character ${char} at ${i}`);}}return tokens;}
How fast it is
| Time | O(n) |
|---|---|
| Extra memory | O(n) |
i only moves forward and each character is handled once. The token list holds all the characters at most once.
- Best case
O(n)Only spaces, like
" ": each character is skipped after one check, andtokensstays empty. - Average case
O(n)A normal expression: each character is tested a few times by the
ifchain or once by an innerwhile. - Worst case
O(n)Long names or numbers, like a 10,000-digit number: the inner
whilereads them in one loop, still once per character, andslicecopies each token once.
Common mistakes
- Using
source.split(" "). It breaks(qty+2)into one piece and can't tell numbers from names. - Forgetting to move
iin a branch. If a symbol is pushed withouti++, the loop pushes it forever. - Reading past the end in the inner
while. Checki < source.lengthbeforesource[i], or the loop testsundefined. - Checking one-character symbols before two-character ones, so
<=becomes<and=. Try the longest symbols first.
When to use it
Look for these clues in a problem:
- The input is text with structure: a formula, a query string, a CSS rule, a template, or a command.
- You need to split text, but a simple
splitwould break things like quoted strings or multi-digit numbers. - The next step needs to know what kind each piece is: number, name, operator, or text.
- The problem mentions parsing, evaluating, or validating an expression.
Check yourself
Answer each one in your head first, then open it.
Why can't source.split(" ") tokenize rate*(h1-0.5)?
There are no spaces, so it returns the whole text as one piece. Tokens are separated by changes of kind, not by spaces.
What happens if the symbol branch forgets i++?
i never moves, so the same symbol is pushed again and again, and the loop never ends.
Why does the name branch accept digits in its while but not in its first test?
So h1 is one name, while 1h starts with a number. If digits could start names, a token like 12 would be ambiguous.
How many tokens does "(a)" produce, and of which kinds?
3: the symbol (, the name a, and the symbol ).
Practice problems
Stuck on one? Tuto is in the playground too, and gives you hints, never the answer.
0 of 11 solved. Solve one to earn the bronze medal, half for silver, and all of them for gold.
Start practicingGo further
You don't need these to start practicing. Open one when you want more.
Why it works
The reasoning that makes it always right
The rule at the top of each loop: tokens holds exactly the tokens of source.slice(0, i), and i sits at the start of a token or at a space. At i = 0 nothing has been read, so the rule holds.
Each branch keeps it. The first character picks one kind, and the inner while moves i to the first character that can't continue that kind, so source.slice(start, i) is the longest token of that kind starting at start. The character at the new i must begin the next token or be a space, so i is at a boundary again. Every branch moves i forward by at least one, so the loop can't get stuck.
The loop ends when i reaches source.length, or throws at the first character no branch accepts. In the first case the rule covers the whole text, so tokens is its complete list of tokens. Since i only moves forward and each character is read by one branch, the work is O(n).
Going deeper
3 more ideas
Why one pass is enough
The first character of a token decides its kind, and the token continues as long as the next characters fit that kind. So the tokenizer never needs to look back. Every character is either skipped, added to a token, or reported as an error, and i only moves forward.
How fast it is
Every character is visited once by the outer loop or an inner while, never both, so the time is O(n). The tokens together hold at most n characters, so the memory is O(n).
Strings, escapes, and longer symbols
Real tokenizers handle more: a " starts a string token that runs to the next unescaped ", and two-character symbols like <= or === are checked before one-character ones, so <= isn't split into < and =. The structure stays the same: look at the first character, then consume while the token continues.
Work it by hand
Every step in a table, with why
Split a small expression into tokens. Some symbols have no spaces around them, a name contains a digit, and a number has a decimal point.
source = "rate*(h1 - 0.5)"
| Step | i before | char | Branch | i after | Token pushed | What happens | Why |
|---|---|---|---|---|---|---|---|
| 1 | 0 | r | name | 4 | name rate | read until * | r is a letter, so start = 0, and i moves while letters, digits, or _ continue. It stops at *, index 4. |
| 2 | 4 | * | symbol | 5 | symbol * | one character | No space was needed between rate and *; the kind of character decides. |
| 3 | 5 | ( | symbol | 6 | symbol ( | one character | Symbols are always a single character in this tokenizer. |
| 4 | 6 | h | name | 8 | name h1 | a digit inside a name | Inside a name, digits are allowed, so h1 is one token. A digit can't start a name, though. |
| 5 | 8 | space | space | 9 | - | skip | Spaces only move i; they never become tokens. |
| 6 | 9 | - | symbol | 10 | symbol - | one character | The tokenizer doesn't know whether this is minus or subtraction; that's the parser's job. |
| 7 | 10 | space | space | 11 | - | skip | Another space. |
| 8 | 11 | 0 | number | 14 | number 0.5 | read digits and . | The number branch accepts ., so 0.5 is one token, ending at ). |
| 9 | 14 | ) | symbol | 15 | symbol ) | one character | i is now 15, the length of source, so the loop ends. |
It returns 7 tokens: rate, *, (, h1, -, 0.5, and ). The outer loop ran 9 times, and every character was handled by exactly one branch.
Compared with other ways
When to pick something else
| Approach | Time | Extra memory | Choose it when |
|---|---|---|---|
| Tokenizing text | O(n) | O(n) | You control the grammar and want clear errors with positions. |
split with a separator | O(n) | O(n) | Very simple formats, like words separated by commas, where each piece's kind doesn't matter. |
| One sticky regular expression | O(n) | O(n) | Short code for a small grammar, if weaker error messages are acceptable. |
| A parser generator | O(n) | O(n) | Big grammars, like a full programming language, where hand-written branches get hard to maintain. |
Extra memory: O(n): the tokens together hold at most n characters, plus one small object per token.
How the steps grow
- Tokenizing text,
splitwith a separator, One sticky regular expression, A parser generatorO(n)
| Approach | n = 10 | n = 1,000 | n = 1,000,000 |
|---|---|---|---|
| Tokenizing text | 10 | 1,000 | 1 million |
split with a separator | 10 | 1,000 | 1 million |
| One sticky regular expression | 10 | 1,000 | 1 million |
| A parser generator | 10 | 1,000 | 1 million |
Inputs that need care
6 tricky inputs
- Empty text:
"" - The loop never runs and it returns
[]. - A character with no branch:
"a = 1" - It pushes
a, skips the space, and throwsUnexpected character = at 2. Callers should catch it and show the message with its position. - Two dots in a number:
"1.2.3" - The number branch accepts every
., so it returns one number token,1.2.3. Check it later withNumber(value), or end the number at its second.. - A digit followed by a letter:
"2x" - It returns the number
2and the namex. Whether that's an error or means2 * xis for the parser to decide. - A minus sign before a number:
"-4" - It returns the symbol
-and the number4. Telling a negative number from subtraction needs context, so the parser handles it. - Tabs or new lines:
"a\t1" - The space branch only checks
" ", so the tab at index 1 throws. Use/\s/.test(char)to skip every kind of whitespace.
Variations and follow-ups
Twists interviewers add
Tracking lines and columns
For good error messages in text with many lines, keep line and column next to i: add one to line and reset column at each new line. Store the position in each token, so later errors can point at it too.
A tokenizer as a generator
Instead of building the whole list, write function* tokenize(source) and yield each token. The parser pulls tokens one at a time, which saves memory on large inputs and lets it stop at the first error.
One sticky regular expression
A regular expression with one alternative per kind, like /\s+|\d+(\.\d+)?|[A-Za-z_]\w*|[-+*\/()]/y, can match at lastIndex again and again. The y flag makes each match start exactly where the last one ended. It's shorter, but harder to give good error messages.
Template and markup tokenizers
Template engines and markdown parsers start the same way: walk the text, notice where a special marker like {{ begins, and switch to another kind of token until its closing marker.
In an interview
Spot it, explain it, answer follow-ups
- Spot it from "evaluate this expression", "basic calculator", "parse this string", or "decode this format". Almost every such task starts by splitting the text into tokens with one forward index.
- Explain the structure before coding: "look at the current character, pick its kind, consume while the kind continues, push a token". Write one branch fully, and the others follow the same shape.
- Make sure every branch moves
i. An endless loop from a branch that forgetsi++is the most common bug, so point at the move in each branch as you write it. - Test by hand: no spaces at all, extra spaces, a decimal number, and a character you don't support. The usual follow-up is turning the tokens into a value, with two stacks or a recursive descent parser.
Where frontend code uses it
The same idea in real UI code
- Every JavaScript engine and bundler starts by tokenizing your code. Babel, TypeScript, and esbuild all have a lexer that turns source text into tokens before parsing.
- Template engines tokenize text like
Hello {{ name }}into text pieces and placeholders. Browsers also tokenize HTML and CSS before building the DOM and style rules. URLSearchParamssplits a query string likea=1&b=2into pairs, and syntax highlighters in code editors tokenize each line to color it.