Learn Quill — The Complete Tutorial
Welcome! This page is your one-stop guide to learning Quill from scratch. By the time you finish, you will have built 10 real projects and be confident writing Quill programs of any complexity.
Why Quill?
Quill is a programming language designed to read like plain English. Instead of cryptic symbols, you write name is "Alice" instead of const name = "Alice";. Under the hood, Quill compiles to JavaScript, so your programs can run anywhere — servers, browsers, mobile apps, and more.
What you'll learn
- Variables, data types, and output
- Control flow (if/otherwise, pattern matching)
- Loops, lists, and the pipe operator
- Functions, lambdas, and error handling
- Classes, inheritance, and visibility
- File I/O and JSON
- Web servers and REST APIs
- Testing your code
- Reactive frontend components
- Async, concurrency, generics, and more
Prerequisites
Just a computer. That is it. No prior programming experience is required. If you can type and follow instructions, you are ready.
quill --version in your terminal. If it is not installed, visit the Getting Started page for instructions. Alternatively, you can use the online playground for every example on this page.
quill learn in your terminal for an interactive 10-lesson tutorial. It guides you step-by-step with instant feedback — no reading required.
Chapter 1: The Basics
Every journey starts with the fundamentals. In this chapter you will learn how to store information, display it on screen, and understand the different kinds of data Quill can work with.
Variables with is and are
A variable is a named container for a value. In Quill you create one with the word is:
name is "Alice"
age is 25
happy is yes
When storing a list of things, you can use are for natural readability. It works exactly the same as is:
colors are ["red", "green", "blue"]
scores are [95, 87, 100]
Printing with say
The say keyword prints a value to the screen:
say "Hello, World!"
say 42
say name
Data types
Quill has six core data types:
greeting is "Hello" -- Text (string)
temperature is 72.5 -- Number (integer or decimal)
isRaining is no -- Boolean (yes or no)
fruits are ["apple", "pear"] -- List (ordered collection)
person is {name: "Bob", age: 30} -- Object (named values)
result is nothing -- Nothing (no value)
String interpolation
You can embed variables and expressions inside strings using curly braces:
name is "World"
say "Hello, {name}!" -- Hello, World!
say "2 + 2 = {2 + 2}" -- 2 + 2 = 4
Comments with --
Comments are notes for humans. Quill ignores them completely:
-- This line is ignored by Quill
say "This line runs" -- This part is ignored too
Mini Project: Greeting Card Generator
What you'll build: A program that takes a name and age, then prints a beautifully formatted birthday message.
-- greeting-card.quill
-- A birthday greeting card generator
name is "Sarah"
age is 28
favorite_color is "purple"
-- Build the card
border is "=================================="
say border
say " Happy Birthday, {name}!"
say border
say ""
say " You are turning {age} today!"
say " That is {age * 365} days of awesome."
say ""
say " Your favorite color is {favorite_color},"
say " so here is a {favorite_color} cake for you!"
say ""
say " Wishing you an incredible year ahead."
say border
Run it with quill run greeting-card.quill and you should see:
==================================
Happy Birthday, Sarah!
==================================
You are turning 28 today!
That is 10220 days of awesome.
Your favorite color is purple,
so here is a purple cake for you!
Wishing you an incredible year ahead.
==================================
You just wrote your first real program! It stores data in variables, does math, and formats output beautifully.
Chapter 2: Making Decisions
Programs need to make choices. Should we show a welcome message or an error? Is the user old enough? Does the password match? In this chapter you will learn how to write code that makes decisions.
if / otherwise
The most basic decision is "if this is true, do that":
age is 20
if age is greater than 17:
say "You are an adult"
otherwise:
say "You are a minor"
You can chain multiple conditions with otherwise if:
temp is 35
if temp is greater than 30:
say "It's hot outside!"
otherwise if temp is greater than 15:
say "Nice weather"
otherwise:
say "Bundle up!"
Comparisons
Quill uses English-like comparisons:
-- Numeric comparisons
if score is greater than 90:
say "Excellent!"
if balance is less than 0:
say "Overdrawn!"
-- Equality
if color is "blue":
say "Great choice"
-- Contains (for strings and lists)
if email contains "@":
say "Looks like a valid email"
Combining conditions
Use and, or, and not to combine conditions:
age is 25
hasLicense is yes
if age is greater than 17 and hasLicense:
say "You can drive"
if not hasLicense:
say "You need a license first"
if day is "Saturday" or day is "Sunday":
say "It's the weekend!"
Pattern matching with match
When you have many possible values to check, match is cleaner than a chain of if/otherwise:
day is "Wednesday"
match day:
when "Monday":
say "Start of the work week"
when "Friday":
say "TGIF!"
when "Saturday":
say "Weekend vibes"
when "Sunday":
say "Rest day"
otherwise:
say "Just another day"
match when you are comparing one value against several possibilities. Use if/otherwise when you need complex conditions with and/or.
Mini Project: Grade Calculator
What you'll build: A program that takes a numeric score (0-100) and outputs a letter grade with a custom message, using pattern matching.
-- grade-calculator.quill
-- Converts a numeric score to a letter grade with feedback
score is 87
studentName is "Jordan"
say "Grade Report for {studentName}"
say "Score: {score}/100"
say "---"
-- Determine the letter grade
grade is "F"
message is ""
if score is greater than 96:
grade is "A+"
message is "Outstanding! You are at the top of the class."
otherwise if score is greater than 92:
grade is "A"
message is "Excellent work! Keep it up."
otherwise if score is greater than 89:
grade is "A-"
message is "Great job! Almost perfect."
otherwise if score is greater than 86:
grade is "B+"
message is "Very good! Just a bit more effort."
otherwise if score is greater than 82:
grade is "B"
message is "Good work! Solid understanding."
otherwise if score is greater than 79:
grade is "B-"
message is "Above average. Keep pushing!"
otherwise if score is greater than 76:
grade is "C+"
message is "Not bad! Room for improvement."
otherwise if score is greater than 72:
grade is "C"
message is "Average. You can do better!"
otherwise if score is greater than 69:
grade is "C-"
message is "Below average. Let's study more."
otherwise if score is greater than 59:
grade is "D"
message is "Barely passing. Time to focus."
otherwise:
grade is "F"
message is "Did not pass. Don't give up!"
say "Letter Grade: {grade}"
say "Feedback: {message}"
-- Bonus: pass/fail summary using match
say ""
match grade:
when "A+":
say "*** HONOR ROLL ***"
when "A":
say "*** HONOR ROLL ***"
when "F":
say "*** SEE INSTRUCTOR ***"
otherwise:
say "Status: Passing"
Run it: quill run grade-calculator.quill
Grade Report for Jordan
Score: 87/100
---
Letter Grade: B+
Feedback: Very good! Just a bit more effort.
Status: Passing
"D+" and "D-" range. Add a second student and print both reports.
Chapter 3: Loops & Lists
So far, every line of our code runs exactly once. But what if you need to do something 100 times? Or process every item in a list? That is where loops come in. This chapter also covers powerful list operations that let you transform data with minimal code.
for each loops
The most common loop in Quill iterates over a list:
fruits are ["apple", "banana", "cherry"]
for each fruit in fruits:
say "I like {fruit}"
Output:
I like apple
I like banana
I like cherry
while loops
A while loop keeps running as long as a condition is true:
count is 1
while count is less than 6:
say "Count: {count}"
count is count + 1
while loops. If the condition never becomes false, your program will run forever! Always make sure something inside the loop changes the condition.
range()
Need to loop a specific number of times? Use range():
for each i in range(1, 5):
say "Step {i}"
Step 1
Step 2
Step 3
Step 4
Step 5
List operations
Quill has powerful built-in list operations:
numbers are [3, 1, 4, 1, 5, 9]
-- Add an item
push(numbers, 2)
-- Filter: keep only items that match a condition
big is filter(numbers, with n: n is greater than 3)
say big -- [4, 5, 9]
-- Map: transform every item
doubled is map_list(numbers, with n: n * 2)
say doubled -- [6, 2, 8, 2, 10, 18, 4]
-- Sort
sorted is sort(numbers)
say sorted -- [1, 1, 2, 3, 4, 5, 9]
-- Find: get the first item matching a condition
found is find(numbers, with n: n is greater than 4)
say found -- 5
-- Reduce: combine all items into one value
total is reduce(numbers, 0, with sum, n: sum + n)
say total -- 25
The pipe operator
The pipe operator | lets you chain operations together, passing the result of one into the next. It reads left-to-right, like an assembly line:
numbers are [3, 1, 4, 1, 5, 9, 2, 6]
result is numbers | filter(with n: n is greater than 3) | sort()
say result -- [4, 5, 6, 9]
| gets passed as the first argument to the function on the right. So numbers | filter(...) is the same as filter(numbers, ...). This becomes incredibly powerful once you chain multiple operations.
Mini Project: To-Do List Manager
What you'll build: A to-do list program that can add tasks, remove tasks, list all tasks, filter by status, and count completed items.
-- todo.quill
-- A simple to-do list manager
todos are []
-- Helper: add a new task
to addTask name:
push(todos, {name: name, done: no})
say "Added: {name}"
-- Helper: mark a task as done
to completeTask name:
for each todo in todos:
if todo.name is name:
todo.done is yes
say "Completed: {name}"
-- Helper: remove a task
to removeTask name:
todos is filter(todos, with t: t.name is not name)
say "Removed: {name}"
-- Helper: show all tasks
to showAll:
say ""
say "=== TO-DO LIST ==="
if length(todos) is 0:
say " (empty)"
otherwise:
for each todo in todos:
status is "[ ]"
if todo.done:
status is "[x]"
say " {status} {todo.name}"
-- Helper: show stats
to showStats:
total is length(todos)
completed is todos | filter(with t: t.done) | length()
pending is total - completed
say ""
say "Stats: {completed}/{total} completed, {pending} remaining"
-- Let's use it!
addTask("Buy groceries")
addTask("Walk the dog")
addTask("Write Quill tutorial")
addTask("Clean the house")
addTask("Read a book")
showAll()
-- Complete some tasks
completeTask("Buy groceries")
completeTask("Write Quill tutorial")
-- Remove one
removeTask("Clean the house")
showAll()
showStats()
-- Filter: show only pending tasks
say ""
say "=== PENDING ==="
pending is filter(todos, with t: not t.done)
for each task in pending:
say " - {task.name}"
Run it: quill run todo.quill
Added: Buy groceries
Added: Walk the dog
Added: Write Quill tutorial
Added: Clean the house
Added: Read a book
=== TO-DO LIST ===
[ ] Buy groceries
[ ] Walk the dog
[ ] Write Quill tutorial
[ ] Clean the house
[ ] Read a book
Completed: Buy groceries
Completed: Write Quill tutorial
Removed: Clean the house
=== TO-DO LIST ===
[x] Buy groceries
[ ] Walk the dog
[x] Write Quill tutorial
[ ] Read a book
Stats: 2/4 completed, 2 remaining
=== PENDING ===
- Walk the dog
- Read a book
Look at that! You just built a functional task manager using lists, loops, objects, filtering, and pipes.
showCompleted function that only shows done tasks. Add a priority field (high/medium/low) to each task and sort by priority. Add a clearCompleted function that removes all done tasks at once.
Chapter 4: Functions & Organization
Functions let you wrap up a block of code, give it a name, and reuse it. They are the building blocks of well-organized programs. In this chapter you will learn how to define functions, return values, use type annotations, write lambdas, handle errors, and import code from other files.
Defining functions with to
In Quill, you define a function using the to keyword. Think of it as giving instructions: "to greet someone, do this..."
to greet name:
say "Hello, {name}!"
greet("Alice") -- Hello, Alice!
greet("Bob") -- Hello, Bob!
Returning values with give back
Functions can compute a result and give it back to the caller:
to double n:
give back n * 2
result is double(5)
say result -- 10
Type annotations
You can add type annotations to make your functions self-documenting and catch errors early:
to add a as number, b as number -> number:
give back a + b
to greet name as string -> string:
give back "Hello, {name}!"
The as keyword declares the type of each parameter, and -> declares the return type.
Lambdas (anonymous functions)
Sometimes you need a small throwaway function. Lambdas let you write one inline:
numbers are [1, 2, 3, 4, 5]
-- Lambda with "with" keyword
doubled is map_list(numbers, with x: x * 2)
say doubled -- [2, 4, 6, 8, 10]
-- Lambdas are great with filter
evens is filter(numbers, with x: x % 2 is 0)
say evens -- [2, 4]
Error handling with try / catch
Things go wrong sometimes. Error handling lets your program recover gracefully instead of crashing:
try:
result is riskyOperation()
say "It worked: {result}"
catch err:
say "Something went wrong: {err}"
You can also use the alternative syntax if it fails:
try:
data is readFile("config.json")
if it fails err:
say "Could not read config: {err}"
data is "{}" -- use empty default
Imports with use
As your programs grow, split them into multiple files:
-- math-helpers.quill
to square n:
give back n * n
to cube n:
give back n * n * n
-- main.quill
use "math-helpers.quill"
say square(5) -- 25
say cube(3) -- 27
Mini Project: Password Strength Checker
What you'll build: A program with multiple functions that analyze a password's strength, checking length, special characters, common passwords, and producing a final score.
-- password-checker.quill
-- A comprehensive password strength checker
-- List of common (bad) passwords
commonPasswords are [
"password", "123456", "qwerty", "abc123",
"password1", "letmein", "welcome", "admin"
]
-- Check 1: Is it long enough?
to checkLength pw as string -> number:
len is length(pw)
if len is greater than 15:
give back 3
otherwise if len is greater than 11:
give back 2
otherwise if len is greater than 7:
give back 1
otherwise:
give back 0
-- Check 2: Does it have special characters?
to checkSpecialChars pw as string -> number:
specials are ["!", "@", "#", "$", "%", "&", "*", "?"]
score is 0
for each char in specials:
if pw contains char:
score is score + 1
if score is greater than 2:
give back 3
otherwise if score is greater than 0:
give back 1
otherwise:
give back 0
-- Check 3: Is it a common password?
to checkCommon pw as string -> number:
lower is lowercase(pw)
found is find(commonPasswords, with p: p is lower)
if found is not nothing:
give back -5 -- big penalty!
otherwise:
give back 2
-- Check 4: Does it have mixed case?
to checkMixedCase pw as string -> number:
hasUpper is pw is not lowercase(pw)
hasLower is pw is not uppercase(pw)
if hasUpper and hasLower:
give back 2
otherwise:
give back 0
-- Main scoring function
to checkPassword pw as string:
say "Checking password: {pw}"
say "---"
lengthScore is checkLength(pw)
specialScore is checkSpecialChars(pw)
commonScore is checkCommon(pw)
caseScore is checkMixedCase(pw)
total is lengthScore + specialScore + commonScore + caseScore
say " Length: {lengthScore}/3"
say " Special: {specialScore}/3"
say " Not common: {commonScore}/2"
say " Mixed case: {caseScore}/2"
say " -----"
say " Total: {total}/10"
say ""
if total is greater than 7:
say " STRONG password!"
otherwise if total is greater than 4:
say " FAIR password. Could be better."
otherwise:
say " WEAK password. Please choose a stronger one."
say ""
-- Test with different passwords
checkPassword("password")
checkPassword("Hello123")
checkPassword("My$ecure#Pass!word99")
Run it: quill run password-checker.quill
Checking password: password
---
Length: 1/3
Special: 0/3
Not common: -5/2
Mixed case: 0/2
-----
Total: -4/10
WEAK password. Please choose a stronger one.
Checking password: Hello123
---
Length: 1/3
Special: 0/3
Not common: 2/2
Mixed case: 2/2
-----
Total: 5/10
FAIR password. Could be better.
Checking password: My$ecure#Pass!word99
---
Length: 3/3
Special: 3/3
Not common: 2/2
Mixed case: 2/2
-----
Total: 10/10
STRONG password!
This is a serious program! Multiple functions, each with a clear responsibility, type annotations, and a scoring system that brings it all together.
checkNumbers function that gives points for having digits. Add a checkRepeating function that penalizes passwords like "aaaa". Make the output use emoji or ASCII art for the strength rating.
Chapter 5: Classes & Objects
So far, you have used objects as simple containers: {name: "Alice", age: 25}. Classes take this further by letting you define blueprints for objects — complete with their own functions (called methods) and rules. This is called Object-Oriented Programming (OOP).
Defining a class with describe
In Quill, you create a class with the describe keyword:
describe Animal:
to create name, sound:
my name is name
my sound is sound
to speak:
say "{my name} says {my sound}!"
dog is Animal("Rex", "Woof")
dog.speak() -- Rex says Woof!
create method is the constructor — it runs when you create a new instance. The my keyword refers to the current instance's properties, like "my name" and "my sound."
Inheritance with extends
A class can inherit from another, getting all its properties and methods:
describe Dog extends Animal:
to create name:
my name is name
my sound is "Woof"
to fetch item:
say "{my name} fetches the {item}!"
rex is Dog("Rex")
rex.speak() -- Rex says Woof! (inherited)
rex.fetch("ball") -- Rex fetches the ball!
Private and public visibility
By default, all properties and methods are public. Use private to hide them:
describe SecretBox:
to create:
private my secret is "hidden treasure"
to hint:
say "The secret has {length(my secret)} characters"
box is SecretBox()
box.hint() -- The secret has 15 characters
-- box.secret would cause an error (it's private)
Mini Project: Bank Account System
What you'll build: An Account class with deposit, withdraw, transfer, and transaction history. This uses everything you have learned about classes.
-- bank.quill
-- A simple bank account system
describe Account:
to create owner as string, initialBalance as number:
my owner is owner
private my balance is initialBalance
private my history are []
push(my history, {type: "open", amount: initialBalance, note: "Account opened"})
to getBalance -> number:
give back my balance
to deposit amount as number:
if amount is less than 0:
say " Error: Cannot deposit a negative amount."
give back no
my balance is my balance + amount
push(my history, {type: "deposit", amount: amount, note: "Deposit"})
say " Deposited ${amount}. New balance: ${my balance}"
give back yes
to withdraw amount as number:
if amount is greater than my balance:
say " Error: Insufficient funds. Balance: ${my balance}"
give back no
if amount is less than 0:
say " Error: Cannot withdraw a negative amount."
give back no
my balance is my balance - amount
push(my history, {type: "withdraw", amount: amount, note: "Withdrawal"})
say " Withdrew ${amount}. New balance: ${my balance}"
give back yes
to transfer amount as number, recipient:
say " Transferring ${amount} from {my owner} to {recipient.owner}..."
success is my withdraw(amount)
if success:
recipient.deposit(amount)
push(my history, {type: "transfer", amount: amount, note: "Transfer to {recipient.owner}"})
to printStatement:
say ""
say "=== Statement for {my owner} ==="
for each entry in my history:
say " [{entry.type}] {entry.note}: ${entry.amount}"
say " Current balance: ${my balance}"
say "================================="
-- Create two accounts
say "--- Creating accounts ---"
alice is Account("Alice", 1000)
bob is Account("Bob", 500)
-- Do some transactions
say ""
say "--- Transactions ---"
alice.deposit(250)
bob.deposit(100)
alice.withdraw(75)
alice.transfer(300, bob)
-- Try to overdraw
say ""
say "--- Trying to overdraw ---"
bob.withdraw(5000)
-- Print statements
alice.printStatement()
bob.printStatement()
Run it: quill run bank.quill
--- Creating accounts ---
--- Transactions ---
Deposited $250. New balance: $1250
Deposited $100. New balance: $600
Withdrew $75. New balance: $1175
Transferring $300 from Alice to Bob...
Withdrew $300. New balance: $875
Deposited $300. New balance: $900
--- Trying to overdraw ---
Error: Insufficient funds. Balance: $900
=== Statement for Alice ===
[open] Account opened: $1000
[deposit] Deposit: $250
[withdraw] Withdrawal: $75
[withdraw] Withdrawal: $300
[transfer] Transfer to Bob: $300
Current balance: $875
=================================
=== Statement for Bob ===
[open] Account opened: $500
[deposit] Deposit: $100
[deposit] Deposit: $300
Current balance: $900
=================================
You just built a real bank account system with private state, validation, transfers, and transaction history. That is object-oriented programming in action!
interest method that adds 5% to the balance. Create a SavingsAccount that extends Account and limits withdrawals to 3 per month. Add a toString method that returns a summary string.
Chapter 6: Working with Files & Data
Real programs need to read and write data that persists beyond a single run. In this chapter you will learn how to work with files, JSON data, HTTP requests, and environment variables.
Reading and writing files
-- Write to a file
writeFile("hello.txt", "Hello from Quill!")
-- Read from a file
content is readFile("hello.txt")
say content -- Hello from Quill!
-- Append to a file
appendFile("log.txt", "New log entry\n")
JSON: reading, writing, and parsing
JSON is the standard format for structured data. Quill makes it easy:
-- Write an object as JSON
user is {name: "Alice", age: 25, hobbies: ["coding", "reading"]}
writeJSON("user.json", user)
-- Read JSON back into an object
loaded is readJSON("user.json")
say loaded.name -- Alice
-- Parse a JSON string
jsonString is "{\"x\": 10, \"y\": 20}"
point is parseJSON(jsonString)
say point.x -- 10
HTTP requests
-- Fetch JSON from an API
data is await fetchJSON("https://api.example.com/users")
say data
-- Post JSON to an API
response is await postJSON("https://api.example.com/users", {
name: "Bob",
email: "bob@example.com"
})
say response
Environment variables
-- Read an environment variable
apiKey is env("API_KEY")
if apiKey is nothing:
say "Warning: API_KEY is not set"
.env file instead.
Mini Project: Contact Book
What you'll build: A contact book that stores contacts in a JSON file and supports add, search, delete, and list operations. Data persists between runs!
-- contacts.quill
-- A persistent contact book using JSON storage
filename is "contacts.json"
-- Load existing contacts (or start fresh)
to loadContacts:
try:
give back readJSON(filename)
catch err:
give back [] -- file doesn't exist yet
-- Save contacts to disk
to saveContacts contacts:
writeJSON(filename, contacts)
-- Add a new contact
to addContact name as string, phone as string, email as string:
contacts is loadContacts()
newContact is {name: name, phone: phone, email: email}
push(contacts, newContact)
saveContacts(contacts)
say "Added contact: {name}"
-- Search for a contact by name
to searchContact query as string:
contacts is loadContacts()
results is filter(contacts, with c: lowercase(c.name) contains lowercase(query))
if length(results) is 0:
say "No contacts found matching \"{query}\""
otherwise:
say "Found {length(results)} contact(s):"
for each c in results:
say " {c.name} | {c.phone} | {c.email}"
-- Delete a contact by name
to deleteContact name as string:
contacts is loadContacts()
before is length(contacts)
contacts is filter(contacts, with c: c.name is not name)
after is length(contacts)
if before is after:
say "Contact \"{name}\" not found."
otherwise:
saveContacts(contacts)
say "Deleted contact: {name}"
-- List all contacts
to listAll:
contacts is loadContacts()
if length(contacts) is 0:
say "Contact book is empty."
give back nothing
say "=== All Contacts ({length(contacts)}) ==="
for each c in contacts:
say " {c.name}"
say " Phone: {c.phone}"
say " Email: {c.email}"
-- Demo: build up a contact book
say "--- Adding contacts ---"
addContact("Alice Johnson", "555-0101", "alice@example.com")
addContact("Bob Smith", "555-0202", "bob@example.com")
addContact("Charlie Brown", "555-0303", "charlie@example.com")
addContact("Alice Cooper", "555-0404", "cooper@example.com")
say ""
listAll()
say ""
say "--- Searching for 'alice' ---"
searchContact("alice")
say ""
say "--- Deleting Bob ---"
deleteContact("Bob Smith")
say ""
listAll()
Run it: quill run contacts.quill
--- Adding contacts ---
Added contact: Alice Johnson
Added contact: Bob Smith
Added contact: Charlie Brown
Added contact: Alice Cooper
=== All Contacts (4) ===
Alice Johnson
Phone: 555-0101
Email: alice@example.com
Bob Smith
Phone: 555-0202
Email: bob@example.com
Charlie Brown
Phone: 555-0303
Email: charlie@example.com
Alice Cooper
Phone: 555-0404
Email: cooper@example.com
--- Searching for 'alice' ---
Found 2 contact(s):
Alice Johnson | 555-0101 | alice@example.com
Alice Cooper | 555-0404 | cooper@example.com
--- Deleting Bob ---
Deleted contact: Bob Smith
=== All Contacts (3) ===
Alice Johnson
Phone: 555-0101
Email: alice@example.com
Charlie Brown
Phone: 555-0303
Email: charlie@example.com
Alice Cooper
Phone: 555-0404
Email: cooper@example.com
The best part? Run it again and your contacts are still there, stored in contacts.json. You just built persistent data storage!
updateContact function that changes a contact's phone or email. Add a exportCSV function that writes contacts as a CSV file. Add tags to each contact and filter by tag.
Chapter 7: Web Servers & APIs
Time to build something that runs on the internet! In this chapter you will create a web server that responds to HTTP requests. This is how every website and mobile app backend works.
Creating a server
use "server" from "quill:http"
app is createServer()
app.get("/", with req, res:
res.send("Hello from Quill!")
)
app.listen(3000)
say "Server running at http://localhost:3000"
Run it with quill run server.quill, then open http://localhost:3000 in your browser. You just built a web server!
Routes: GET, POST, PUT, DELETE
-- GET: retrieve data
app.get("/users", with req, res:
res.json(users)
)
-- POST: create data
app.post("/users", with req, res:
newUser is req.body
push(users, newUser)
res.json({success: yes, user: newUser})
)
-- PUT: update data
app.put("/users/:id", with req, res:
-- update logic here
res.json({success: yes})
)
-- DELETE: remove data
app.delete("/users/:id", with req, res:
-- delete logic here
res.json({success: yes})
)
JSON responses
Use res.json() to send structured data back to the client:
app.get("/status", with req, res:
res.json({
status: "ok",
uptime: 12345,
version: "1.0.0"
})
)
Middleware
Middleware runs before your route handlers, useful for logging, authentication, etc.:
-- Log every request
app.use(with req, res, next:
say "{req.method} {req.path}"
next()
)
Serving static files
-- Serve files from a "public" folder
app.static("public")
Mini Project: Bookmark API
What you'll build: A full REST API for managing bookmarks. It supports Create, Read, Update, and Delete (CRUD) operations and stores data in a JSON file.
-- bookmark-api.quill
-- A REST API for managing bookmarks
use "server" from "quill:http"
app is createServer()
dataFile is "bookmarks.json"
-- Load bookmarks from disk
to loadBookmarks:
try:
give back readJSON(dataFile)
catch err:
give back []
-- Save bookmarks to disk
to saveBookmarks data:
writeJSON(dataFile, data)
-- Generate a simple ID
nextId is 1
to generateId:
id is nextId
nextId is nextId + 1
give back id
-- Middleware: log requests
app.use(with req, res, next:
say "{req.method} {req.path}"
next()
)
-- GET /bookmarks - List all bookmarks
app.get("/bookmarks", with req, res:
bookmarks is loadBookmarks()
res.json({count: length(bookmarks), bookmarks: bookmarks})
)
-- GET /bookmarks/:id - Get a single bookmark
app.get("/bookmarks/:id", with req, res:
bookmarks is loadBookmarks()
found is find(bookmarks, with b: b.id is toNumber(req.params.id))
if found is nothing:
res.status(404).json({error: "Bookmark not found"})
otherwise:
res.json(found)
)
-- POST /bookmarks - Create a new bookmark
app.post("/bookmarks", with req, res:
bookmarks is loadBookmarks()
if req.body.url is nothing:
res.status(400).json({error: "URL is required"})
give back nothing
newBookmark is {
id: generateId(),
title: req.body.title or "Untitled",
url: req.body.url,
tags: req.body.tags or [],
createdAt: now()
}
push(bookmarks, newBookmark)
saveBookmarks(bookmarks)
res.status(201).json(newBookmark)
)
-- PUT /bookmarks/:id - Update a bookmark
app.put("/bookmarks/:id", with req, res:
bookmarks is loadBookmarks()
targetId is toNumber(req.params.id)
found is find(bookmarks, with b: b.id is targetId)
if found is nothing:
res.status(404).json({error: "Bookmark not found"})
give back nothing
if req.body.title is not nothing:
found.title is req.body.title
if req.body.url is not nothing:
found.url is req.body.url
if req.body.tags is not nothing:
found.tags is req.body.tags
saveBookmarks(bookmarks)
res.json(found)
)
-- DELETE /bookmarks/:id - Delete a bookmark
app.delete("/bookmarks/:id", with req, res:
bookmarks is loadBookmarks()
targetId is toNumber(req.params.id)
filtered is filter(bookmarks, with b: b.id is not targetId)
if length(filtered) is length(bookmarks):
res.status(404).json({error: "Bookmark not found"})
give back nothing
saveBookmarks(filtered)
res.json({success: yes, message: "Bookmark deleted"})
)
-- Start the server
app.listen(3000)
say "Bookmark API running at http://localhost:3000"
say "Try: curl http://localhost:3000/bookmarks"
Run it: quill run bookmark-api.quill
Then test it with curl:
# Create a bookmark
curl -X POST http://localhost:3000/bookmarks \
-H "Content-Type: application/json" \
-d '{"title": "Quill Docs", "url": "https://quill.sh/docs", "tags": ["programming", "docs"]}'
# List all bookmarks
curl http://localhost:3000/bookmarks
# Delete a bookmark
curl -X DELETE http://localhost:3000/bookmarks/1
You just built a fully functional REST API! This is the same pattern used by real-world web services everywhere.
GET /bookmarks/search?q=quill route that searches bookmarks by title. Add a GET /bookmarks/tags/:tag route that filters by tag. Add pagination with ?page=1&limit=10.
Chapter 8: Testing Your Code
How do you know your code works? You test it! Automated tests are code that verifies your other code. Once written, you can re-run them any time to make sure nothing is broken. This chapter teaches you how to write and run tests in Quill.
test blocks and expect
A test in Quill uses the test keyword and expect to check results:
to add a, b:
give back a + b
test "add works with positive numbers":
expect add(2, 3) toBe 5
test "add works with negative numbers":
expect add(-1, -2) toBe -3
test "add works with zero":
expect add(0, 5) toBe 5
Common matchers
-- Equality
expect result toBe 42
-- Truthiness
expect isValid toBeTrue
expect isEmpty toBeFalse
-- Comparisons
expect score toBeGreaterThan 90
expect count toBeLessThan 100
-- Strings
expect message toContain "error"
-- Lists
expect items toHaveLength 3
Testing edge cases
test "handles empty input":
expect processData("") toBe nothing
test "handles very large numbers":
expect add(999999, 1) toBe 1000000
test "throws on invalid input":
expect divide(1, 0) toThrow
Running tests
# Run all tests in the current directory
quill test
# Run tests in a specific file
quill test password-checker.test.quill
# Run with coverage report
quill test --coverage
Mini Project: Test Suite for the Password Checker
What you'll build: A complete test suite for the password strength checker from Chapter 4. This demonstrates how to test functions, edge cases, and overall behavior.
-- password-checker.test.quill
-- Tests for the password strength checker
use "password-checker.quill"
-- Test: checkLength
test "checkLength returns 0 for very short passwords":
expect checkLength("abc") toBe 0
expect checkLength("1234567") toBe 0
test "checkLength returns 1 for 8-11 character passwords":
expect checkLength("abcdefgh") toBe 1
expect checkLength("12345678901") toBe 1
test "checkLength returns 2 for 12-15 character passwords":
expect checkLength("abcdefghijkl") toBe 2
test "checkLength returns 3 for 16+ character passwords":
expect checkLength("abcdefghijklmnop") toBe 3
-- Test: checkSpecialChars
test "checkSpecialChars returns 0 with no specials":
expect checkSpecialChars("hello") toBe 0
test "checkSpecialChars returns 1 with 1-2 specials":
expect checkSpecialChars("hello!") toBe 1
expect checkSpecialChars("h@llo!") toBe 1
test "checkSpecialChars returns 3 with 3+ specials":
expect checkSpecialChars("h@l!o#") toBe 3
-- Test: checkCommon
test "checkCommon penalizes common passwords":
expect checkCommon("password") toBe -5
expect checkCommon("123456") toBe -5
expect checkCommon("qwerty") toBe -5
test "checkCommon rewards uncommon passwords":
expect checkCommon("myUniquePass") toBe 2
-- Test: checkMixedCase
test "checkMixedCase returns 2 for mixed case":
expect checkMixedCase("HelloWorld") toBe 2
test "checkMixedCase returns 0 for all lowercase":
expect checkMixedCase("hello") toBe 0
test "checkMixedCase returns 0 for all uppercase":
expect checkMixedCase("HELLO") toBe 0
-- Integration tests: overall behavior
test "a common password scores very low":
score is checkLength("password") + checkSpecialChars("password") + checkCommon("password") + checkMixedCase("password")
expect score toBeLessThan 0
test "a strong password scores high":
pw is "My$ecure#Pass!word99"
score is checkLength(pw) + checkSpecialChars(pw) + checkCommon(pw) + checkMixedCase(pw)
expect score toBeGreaterThan 7
test "empty password scores 0 or below":
score is checkLength("") + checkSpecialChars("") + checkCommon("") + checkMixedCase("")
expect score toBeLessThan 3
Run the tests: quill test password-checker.test.quill
PASS password-checker.test.quill
checkLength returns 0 for very short passwords
checkLength returns 1 for 8-11 character passwords
checkLength returns 2 for 12-15 character passwords
checkLength returns 3 for 16+ character passwords
checkSpecialChars returns 0 with no specials
checkSpecialChars returns 1 with 1-2 specials
checkSpecialChars returns 3 with 3+ specials
checkCommon penalizes common passwords
checkCommon rewards uncommon passwords
checkMixedCase returns 2 for mixed case
checkMixedCase returns 0 for all lowercase
checkMixedCase returns 0 for all uppercase
a common password scores very low
a strong password scores high
empty password scores 0 or below
15 passed, 0 failed
Now you can refactor the password checker with confidence. If you break something, the tests will catch it immediately.
quill test --coverage to see which lines of code your tests cover.
Chapter 9: Reactive Components
Quill can build interactive web UIs with a built-in reactive component system. If you have used React, this will feel familiar but simpler. If you have not, do not worry — we will start from the beginning.
Your first component
A component is a reusable piece of UI with its own state and behavior:
component Counter:
state count is 0
to increment:
count is count + 1
to render:
<div>
<h1>Count: {count}</h1>
<button onClick={increment}>Add one</button>
</div>
mount(Counter, "#app")
state declares reactive data. When state changes, the component automatically re-renders. mount attaches the component to a DOM element.
Event handlers
Handle user interactions with events like onClick, onInput, onChange, etc.:
component Greeter:
state name is ""
to render:
<div>
<input
placeholder="Enter your name"
value={name}
onInput={with e: name is e.target.value}
/>
<p>Hello, {name or "stranger"}!</p>
</div>
Rendering lists and conditionals
component TaskList:
state tasks are ["Learn Quill", "Build an app"]
state newTask is ""
to addTask:
if newTask is not "":
push(tasks, newTask)
newTask is ""
to render:
<div>
<input value={newTask} onInput={with e: newTask is e.target.value} />
<button onClick={addTask}>Add</button>
<ul>
{for each task in tasks:
<li>{task}</li>
}
</ul>
{if length(tasks) is 0:
<p>No tasks yet. Add one above!</p>
}
</div>
Mini Project: Interactive Quiz App
What you'll build: A quiz app with multiple-choice questions, a progress bar, scoring, and a results screen.
-- quiz-app.quill
-- An interactive quiz application
questions are [
{
question: "What keyword creates a variable in Quill?",
options: ["let", "var", "is", "set"],
correct: 2
},
{
question: "How do you print to the screen?",
options: ["print", "echo", "say", "log"],
correct: 2
},
{
question: "What keyword defines a function?",
options: ["function", "def", "to", "fn"],
correct: 2
},
{
question: "How do you return a value?",
options: ["return", "give back", "output", "yield"],
correct: 1
},
{
question: "What keyword defines a class?",
options: ["class", "type", "describe", "define"],
correct: 2
}
]
component QuizApp:
state currentQuestion is 0
state score is 0
state showResults is no
state selectedAnswer is -1
state answered is no
to selectAnswer index:
if answered:
give back nothing -- prevent double-answering
selectedAnswer is index
answered is yes
q is questions[currentQuestion]
if index is q.correct:
score is score + 1
to nextQuestion:
if currentQuestion + 1 is length(questions):
showResults is yes
otherwise:
currentQuestion is currentQuestion + 1
selectedAnswer is -1
answered is no
to restart:
currentQuestion is 0
score is 0
showResults is no
selectedAnswer is -1
answered is no
to render:
if showResults:
<div class="quiz">
<h1>Quiz Complete!</h1>
<p class="score">You scored {score} out of {length(questions)}</p>
{if score is length(questions):
<p>Perfect score! You are a Quill master!</p>
}
{if score is greater than 2 and score is less than length(questions):
<p>Great job! You know your Quill.</p>
}
{if score is less than 3:
<p>Keep learning! Re-read the tutorial and try again.</p>
}
<button onClick={restart}>Try Again</button>
</div>
otherwise:
q is questions[currentQuestion]
progress is ((currentQuestion + 1) / length(questions)) * 100
<div class="quiz">
<div class="progress-bar">
<div class="progress-fill" style="width: {progress}%"></div>
</div>
<p class="progress-text">Question {currentQuestion + 1} of {length(questions)}</p>
<h2>{q.question}</h2>
<div class="options">
{for each option, index in q.options:
className is "option"
if answered and index is q.correct:
className is "option correct"
if answered and index is selectedAnswer and index is not q.correct:
className is "option wrong"
<button class={className} onClick={with: selectAnswer(index)}>
{option}
</button>
}
</div>
{if answered:
<button class="next-btn" onClick={nextQuestion}>
{if currentQuestion + 1 is length(questions): "See Results"}
{if currentQuestion + 1 is not length(questions): "Next Question"}
</button>
}
</div>
mount(QuizApp, "#app")
To run this in the browser, create an index.html file that includes a <div id="app"></div> and link the compiled JavaScript. Or use quill run quiz-app.quill with the Quill dev server.
You just built an interactive web application with state management, conditional rendering, and user input handling. That is frontend development!
Chapter 10: Advanced Patterns
You have come a long way! This final chapter covers the advanced features that make Quill a truly powerful language: async/await, concurrency, generics, algebraic data types, decorators, and lazy pipe chains.
Async / Await
When your code needs to wait for something (like a network request or file read), use await:
to fetchUserData userId:
user is await fetchJSON("https://api.example.com/users/{userId}")
say "Got user: {user.name}"
give back user
Parallel blocks
Run multiple async operations at the same time:
-- Fetch all three in parallel (much faster!)
parallel:
users is await fetchJSON("/api/users")
posts is await fetchJSON("/api/posts")
comments is await fetchJSON("/api/comments")
say "Got {length(users)} users, {length(posts)} posts, {length(comments)} comments"
Channels and concurrency
Channels let concurrent tasks communicate safely:
messages is Channel()
-- Producer task
spawn:
for each i in range(1, 5):
messages.send("Message {i}")
messages.close()
-- Consumer task
for each msg in messages:
say "Received: {msg}"
Generics
Write functions that work with any type:
to first<T> items as list<T> -> T:
give back items[0]
say first([1, 2, 3]) -- 1
say first(["a", "b", "c"]) -- a
Algebraic data types
Model data that can be one of several variants:
type Shape:
Circle(radius as number)
Rectangle(width as number, height as number)
Triangle(base as number, height as number)
to area shape as Shape -> number:
match shape:
when Circle(r):
give back 3.14159 * r * r
when Rectangle(w, h):
give back w * h
when Triangle(b, h):
give back 0.5 * b * h
say area(Circle(5)) -- 78.53975
say area(Rectangle(4, 6)) -- 24
Decorators
Decorators wrap functions with extra behavior:
to @logged fn:
give back with args...:
say "Calling {fn.name} with {args}"
result is fn(args...)
say "Result: {result}"
give back result
@logged
to multiply a, b:
give back a * b
multiply(3, 4)
-- Calling multiply with [3, 4]
-- Result: 12
Pipe chains with lazy evaluation
Pipes can be chained extensively for clean data processing:
results is users
| filter(with u: u.age is greater than 18)
| map_list(with u: {name: u.name, email: u.email})
| sort(with a, b: a.name < b.name)
for each r in results:
say "{r.name}: {r.email}"
Mini Project: Mini Chat Server
What you'll build: A WebSocket chat server with channels, message history, and user management. This is the grand finale — it uses async, concurrency, classes, and channels all in one program.
-- chat-server.quill
-- A real-time chat server with WebSockets
use "server" from "quill:http"
use "WebSocket" from "quill:ws"
-- Chat room manager
describe ChatRoom:
to create name as string:
my name is name
my users are []
my history are []
my channel is Channel()
to join username as string, socket:
user is {name: username, socket: socket}
push(my users, user)
my broadcast({
type: "system",
text: "{username} joined the room",
time: now()
})
say "[{my name}] {username} joined ({length(my users)} users)"
to leave username as string:
my users is filter(my users, with u: u.name is not username)
my broadcast({
type: "system",
text: "{username} left the room",
time: now()
})
say "[{my name}] {username} left ({length(my users)} users)"
to sendMessage username as string, text as string:
msg is {
type: "message",
user: username,
text: text,
time: now()
}
push(my history, msg)
my broadcast(msg)
to broadcast msg:
for each user in my users:
try:
user.socket.send(toJSON(msg))
catch err:
say "Failed to send to {user.name}: {err}"
to getHistory count as number:
start is length(my history) - count
if start is less than 0:
start is 0
give back slice(my history, start)
-- Create rooms
rooms is {
general: ChatRoom("general"),
random: ChatRoom("random"),
help: ChatRoom("help")
}
-- Set up HTTP server
app is createServer()
app.get("/", with req, res:
res.json({
name: "Quill Chat Server",
rooms: ["general", "random", "help"],
online: length(rooms.general.users) + length(rooms.random.users) + length(rooms.help.users)
})
)
app.get("/rooms/:name/history", with req, res:
room is rooms[req.params.name]
if room is nothing:
res.status(404).json({error: "Room not found"})
give back nothing
res.json(room.getHistory(50))
)
-- Set up WebSocket server
wss is WebSocket.Server({server: app})
wss.on("connection", with socket:
username is nothing
currentRoom is nothing
socket.on("message", with raw:
data is parseJSON(raw)
match data.action:
when "join":
username is data.username
roomName is data.room or "general"
currentRoom is rooms[roomName]
if currentRoom is not nothing:
currentRoom.join(username, socket)
-- Send last 20 messages as history
history is currentRoom.getHistory(20)
socket.send(toJSON({type: "history", messages: history}))
when "message":
if currentRoom is not nothing and username is not nothing:
currentRoom.sendMessage(username, data.text)
when "switch":
if currentRoom is not nothing:
currentRoom.leave(username)
newRoom is rooms[data.room]
if newRoom is not nothing:
currentRoom is newRoom
currentRoom.join(username, socket)
otherwise:
socket.send(toJSON({type: "error", text: "Unknown action"}))
)
socket.on("close", with:
if currentRoom is not nothing and username is not nothing:
currentRoom.leave(username)
)
)
app.listen(3000)
say "Chat server running at http://localhost:3000"
say "WebSocket at ws://localhost:3000"
say "Rooms: general, random, help"
Run it: quill run chat-server.quill
Connect with any WebSocket client and send:
// Join a room
{"action": "join", "username": "Alice", "room": "general"}
// Send a message
{"action": "message", "text": "Hello everyone!"}
// Switch rooms
{"action": "switch", "room": "random"}
Congratulations! You just built a real-time chat server with multiple rooms, message history, and WebSocket communication. This is the kind of program that powers apps like Slack and Discord.
/users command that lists who is online. Add rate limiting so users cannot spam messages. Build a simple HTML client that connects to the WebSocket server.
What's Next?
You have completed the entire Quill tutorial. You now know variables, control flow, loops, functions, classes, file I/O, web servers, testing, reactive components, and advanced patterns. That is a lot! Here is where to go from here:
Build something real
The best way to solidify your skills is to build a project you care about. Here are some ideas at increasing complexity.
Explore the ecosystem
- Mobile apps with Expo — Build iOS and Android apps using Quill with Expo and React Native.
- Discord bots — Build bots that interact with Discord servers, handle commands, and automate moderation.
- AI with Claude — Integrate Anthropic's Claude API for natural language processing, chatbots, and AI-powered features.
- Cloudflare Workers — Deploy serverless functions at the edge that run in milliseconds across the globe.
- Crypto & Security — Hash passwords, encrypt data, generate tokens, and build secure applications.
Deepen your knowledge
- Language Reference — The complete syntax reference with every feature explained.
- Standard Library — All built-in functions, from string manipulation to networking.
- Testing Guide — Advanced testing patterns, mocks, and CI integration.
- Examples — Complete programs you can study and extend.