Building an Interpreter in Go - Chapter 1: Understanding the Basics

Developer from India.
Search for a command to run...

Developer from India.
Amazing work.
How I scaled a simple crawler into a "Staff-Level" automated research assistant by overcoming flaky inputs, hardware limits, and API rot. As engineers, we are expected to stay on top of everything: Netflix’s latest architecture, AWS updates, Go 1.23...

A deep dive into scaling, reliability, and system design

Introduction Load balancers are fundamental to distributed systems. They determine how evenly traffic is distributed, how failures are handled, and how fast your service can grow. Over a weekend, I built a lightweight but production-style load balanc...

“Every simple system starts as a toy and evolves into infrastructure. The art lies in knowing when to evolve.” 🧭 Why This Article Exists Most system design discussions about URL shorteners stop at “put a DB behind an API.”This piece goes all the w...

TL;DR We had a subtle but critical payroll bug: deductions were getting silently dropped due to timezone mismatches between us (Rippling) and a partner system (Employee Navigator). What looked like a simple effective_date == pst_now() check turned in...

Starting the journey of building an interpreter is like learning to be a translator between two worlds - the world of human-readable code and the world of machine instructions. This first chapter lays the foundation by explaining what an interpreter is, how it differs from a compiler, and the basic components we'll be building.
An interpreter is a program that directly executes instructions written in a programming language without requiring them to be compiled into machine language first. Think of it as a real-time translator who reads your code line by line and performs the actions immediately.
Lexer (Tokenizer)
Breaks down source code into tokens
Example: let x = 5; becomes [LET, IDENTIFIER("x"), EQUALS, NUMBER(5), SEMICOLON]
Acts as the "eyes" of our interpreter
Parser
Transforms tokens into an Abstract Syntax Tree (AST)
Understands the grammar of our language
Catches syntax errors
Abstract Syntax Tree (AST)
Tree representation of the code's structure
Each node represents an operation or value
Example:
CopyAssignment
├── Identifier(x)
└── Value(5)
package token
type TokenType string
type Token struct {
Type TokenType
Literal string
}
var keywords = map[string]TokenType {
"fn" : FUNCTION,
"let" : LET,
"int" : INT,
}
func LookupIdent(ident string) TokenType {
if tok, ok := keywords[ident]; ok {
return tok
}
return IDENT
}
const (
ILLEGAL = "ILLEGAL"
EOF = "EOF"
IDENT = "IDENT"
INT = "INT"
ASSIGN = "="
PLUS = "+"
COMMA = ","
SEMICOLON = ";"
LPAREN = "("
RPAREN = ")"
LBRACE = "{"
RBRACE = "}"
FUNCTION = "fn"
LET = "let"
)
package lexer
import (
"testing"
"interpreter/src/monkey/token"
)
func TestNextToken(t *testing.T) {
input := `
let five = 5;
let ten = 10;
let add = fn(x, y) {
x + y;
};
let result = add(five, ten);
`
tests := []struct {
expectedType token.TokenType
expectedLiteral string
}{
{token.LET, "let"},
{token.IDENT, "five"},
{token.ASSIGN, "="},
{token.INT, "5"},
{token.SEMICOLON, ";"},
{token.LET, "let"},
{token.IDENT, "ten"},
{token.ASSIGN, "="},
{token.INT, "10"},
{token.SEMICOLON, ";"},
{token.LET, "let"},
{token.IDENT, "add"},
{token.ASSIGN, "="},
{token.FUNCTION, "fn"},
{token.LPAREN, "("},
{token.IDENT, "x"},
{token.COMMA, ","},
{token.IDENT, "y"},
{token.RPAREN, ")"},
{token.LBRACE, "{"},
{token.IDENT, "x"},
{token.PLUS, "+"},
{token.IDENT, "y"},
{token.SEMICOLON, ";"},
{token.RBRACE, "}"},
{token.SEMICOLON, ";"},
{token.LET, "let"},
{token.IDENT, "result"},
{token.ASSIGN, "="},
{token.IDENT, "add"},
{token.LPAREN, "("},
{token.IDENT, "five"},
{token.COMMA, ","},
{token.IDENT, "ten"},
{token.RPAREN, ")"},
{token.SEMICOLON, ";"},
{token.EOF, ""},
}
l := New(input)
for i, tt := range tests {
tok := l.NextToken()
if tok.Type != tt.expectedType {
t.Fatalf("tests[%d] - tokentype wrong. expected=%q got=%q", i, tt.expectedType, tok.Type)
}
if tok.Literal != tt.expectedLiteral {
t.Fatalf("tests[%d] - tokenliteral wrong. expected=%q got=%q", i, tt.expectedLiteral, tok.Literal)
}
}
}
package lexer
import (
"interpreter/src/monkey/token"
)
type Lexer struct {
input string
position int
readPosition int
ch byte
}
func New(input string) *Lexer{
l := &Lexer{input : input}
l.readChar()
return l
}
func (l *Lexer) readChar() {
if l.readPosition >= len(l.input) {
l.ch = 0
} else {
l.ch = l.input[l.readPosition]
}
l.position = l.readPosition
l.readPosition += 1
}
func (l *Lexer) NextToken() token.Token {
var tok token.Token
l.skipWhitespace() // Add this line
switch l.ch {
case '=':
tok = newToken(token.ASSIGN, l.ch)
case '+':
tok = newToken(token.PLUS, l.ch)
case '(':
tok = newToken(token.LPAREN, l.ch)
case ')':
tok = newToken(token.RPAREN, l.ch)
case '{':
tok = newToken(token.LBRACE, l.ch)
case '}':
tok = newToken(token.RBRACE, l.ch)
case ',':
tok = newToken(token.COMMA, l.ch)
case ';':
tok = newToken(token.SEMICOLON, l.ch)
case 0:
tok.Literal = ""
tok.Type = token.EOF
default:
if isLetter(l.ch) {
tok.Literal = l.readIdentifier()
tok.Type = token.LookupIdent(tok.Literal)
return tok
} else if isDigit(l.ch) {
tok.Type = token.INT
tok.Literal = l.readNumber()
return tok
} else {
tok = newToken(token.ILLEGAL, l.ch)
}
}
l.readChar()
return tok
}
// Add this new method
func (l *Lexer) skipWhitespace() {
for l.ch == ' ' || l.ch == '\t' || l.ch == '\n' || l.ch == '\r' {
l.readChar()
}
}
func (l *Lexer) readIdentifier() string {
position := l.position
for isLetter(l.ch){
l.readChar()
}
return l.input[position:l.position]
}
func (l *Lexer) readNumber() string {
position := l.position
for isDigit(l.ch) {
l.readChar()
}
return l.input[position:l.position]
}
func isLetter(ch byte) bool {
return 'a' <= ch && ch <= 'z' || 'A' <= ch && ch <= 'Z' || ch == '_'
}
func isDigit(ch byte) bool {
return '0' <= ch && ch <= '9'
}
func newToken(tokenType token.TokenType, ch byte) token.Token {
return token.Token {Type: tokenType, Literal: string(ch)}
}
Understanding Lexical Analysis
Challenge: Grasping how to break down source code into meaningful tokens
Solution: Created visual diagrams of the tokenization process
Learning: The importance of handling edge cases in lexical analysis
Go's Type System
Challenge: Adapting to Go's strict typing after working with dynamic languages
Solution: Studied Go's type system documentation and examples
Learning: How Go's type system helps catch errors at compile time
Custom type definitions using type
Constants and iota
Struct composition
Go's testing framework
String manipulation in Go
[✓] Understanding Interpreters vs Compilers
[✓] Basic Lexer Implementation
[✓] Token Structure
[✓] Handles Whitespace, functions, digits, basic mathematical operations
Parser Implementation
AST Construction
GitHub Repository: https://github.com/AkshayContributes/interpreter
Branch: chapter_1
Pull Request: https://github.com/AkshayContributes/interpreter/pull/1
Implement more token types
Handle whitespace and comments
Begin parser implementation
Study AST structures
Time spent on this chapter: 6 hours Initial confidence level: Beginner Current confidence level: Intermediate
The lexer is simpler than I initially thought - it's just pattern matching
Test-Driven Development is incredibly helpful when building an interpreter
Go's strict typing helps catch potential issues early
Draw out the token structure before coding
Write tests for edge cases first
Keep the lexer simple - resist the urge to make it too clever