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

Prerequisites

Just a computer. That is it. No prior programming experience is required. If you can type and follow instructions, you are ready.

Before you begin: Make sure Quill is installed. Run 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.
Prefer learning by doing? Run 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
Tip: Good comments explain why you did something, not what the code does. The code itself shows the "what."

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.

Try it yourself: Change the name and age to your own. Add a line that prints how old the person will be in 10 years. Add a second person and print a card for them too.

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"
Tip: Use 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
Try it yourself: Change the score to different values and watch the output change. Add a "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
Tip: Be careful with 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]
How pipes work: The value on the left of | 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.

Try it yourself: Add a 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.

Try it yourself: Add a 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!
Key concepts: The 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!

Try it yourself: Add an 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"
Tip: Never hard-code secrets like API keys in your source code. Use environment variables and a .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!

Try it yourself: Add an 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.

Try it yourself: Add a 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.

Try it yourself: Add tests for edge cases like passwords with only numbers, only special characters, or extremely long passwords. Run 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")
How it works: 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!

Try it yourself: Add more questions. Add a timer that limits each question to 15 seconds. Show which questions the user got wrong on the results screen. Add a "hint" button that eliminates two wrong answers.

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.

Try it yourself: Add private messaging (DMs) between users. Add a /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

Deepen your knowledge

You did it! You went from zero to writing chat servers, REST APIs, and interactive UIs. That is a real accomplishment. Keep building, keep experimenting, and welcome to the Quill community!