Learn Quill, A Beginner's Guide
Welcome! This guide will teach you everything you need to start writing real programs in Quill. No prior programming experience is required, we will explain every concept from the ground up.
What is Quill?
Quill is a programming language designed to read like plain English. Instead of cryptic symbols and jargon, Quill uses everyday words like is, say, give back, and for each. Under the hood, Quill compiles to JavaScript, which means your Quill programs can do anything JavaScript can, build websites, create servers, automate tasks, and more.
Think of Quill as a translator: you write instructions in something close to English, and Quill converts them into code that your computer understands.
Your first program
Every programming journey begins with a "Hello, World!" program. In Quill, it is just one line:
say "Hello, World!"
That is it! The word say tells Quill to print something to the screen, and "Hello, World!" is the text you want to display.
How to run it
1. Create a file called hello.quill and type the line above into it.
2. Open your terminal (command prompt) and run:
quill run hello.quill
3. You should see Hello, World! printed on the screen. Congratulations, you just ran your first program!
say "Hello, my name is Alex!", and run it again.
Comments
What are comments? Comments are notes you write inside your code that the computer completely ignores. They exist solely for humans, to explain what your code does, why you made a certain decision, or to leave a reminder for your future self.
Think of comments like sticky notes on a recipe. The oven does not read them, but they help you remember why you added extra garlic.
In Quill, comments start with two dashes (--) and continue to the end of the line:
-- This is a comment. Quill ignores this entire line.
say "Hello!" -- You can also put comments at the end of a line
Why use comments?
- To explain complex logic so others (or future you) can understand it
- To temporarily disable a line of code without deleting it
- To leave TODO notes for things you want to fix later
-- TODO: add error handling here later
-- Calculate the user's age from their birth year
age is 2026 - birthYear
Variables
What is a variable? A variable is like a labeled box where you store information. You give the box a name and put a value inside it. Later, you can look at what is inside the box, change its contents, or use it somewhere else in your program.
In Quill, you create a variable with the word is:
name is "Alice"
age is 25
active is yes
Reading that out loud: "name is Alice, age is 25, active is yes." It reads like English!
Using are for lists
When your variable holds a collection of things (a list), you can use are instead of is for better readability. They work exactly the same way, it is purely a style choice:
colors are ["red", "green", "blue"]
scores are [95, 87, 100, 72]
Reassigning variables
You can change the value inside a variable at any time by using is again:
count is 0
say count -- prints 0
count is count + 1
say count -- prints 1
count is count + 1
say count -- prints 2
Think of it like erasing the old label on your box and writing a new value inside.
What kinds of values can go in a variable?
Quill has six types of values (we will cover each in detail in the next section):
greeting is "Hello there" -- Text (a string of characters)
temperature is 72.5 -- Number (whole or decimal)
isRaining is no -- Boolean (yes or no)
fruits are ["apple", "pear"] -- List (an ordered collection)
person is {name: "Bob"} -- Object (a collection of named values)
result is nothing -- Nothing (absence of a value)
firstName or first_name instead of first name.
say to print each one.
Data Types in Depth
Every value in Quill has a type, it is a category that describes what kind of information the value holds. Understanding types will help you use them correctly.
Text (Strings)
Text is any sequence of characters wrapped in double quotes. Programmers often call these "strings" (as in, a string of characters).
greeting is "Hello, World!"
empty is "" -- an empty string (no characters)
sentence is "I have 3 cats." -- numbers inside quotes are still text
String interpolation lets you embed variables and expressions inside a string using curly braces {}:
name is "World"
say "Hello, {name}!" -- Hello, World!
say "2 + 2 = {2 + 2}" -- 2 + 2 = 4
say "Name length: {length(name)}" -- Name length: 5
Escape sequences let you include special characters:
\", a literal double quote inside a string\\, a literal backslash\n, a new line (moves to the next line)\t, a tab (adds horizontal spacing)
say "She said \"hello\" to me" -- She said "hello" to me
say "Line one\nLine two" -- prints on two separate lines
Multiline Strings
For text that spans multiple lines, use triple quotes ("""):
poem is """
Roses are red,
Violets are blue,
Quill is great,
And so are you.
"""
say poem
Triple-quoted strings preserve newlines and indentation. They are useful for:
- Long text blocks (poems, stories, templates)
- SQL queries or code snippets
- Multi-line messages or emails
- Any text where readability matters
-- SQL query
query is """
SELECT name, email
FROM users
WHERE active = true
ORDER BY name
"""
-- HTML template
html is """
<div class="card">
<h2>{title}</h2>
<p>{description}</p>
</div>
"""
-- Multi-line error message
message is """
Something went wrong!
Please check:
- Your internet connection
- Your API key
- The server status
"""
{variable} works inside them, just like regular strings.
Escaping Braces
If you need a literal { or } in a string without triggering interpolation, use \{ and \}:
-- Literal braces (not interpolation)
say "CSS: \{color: red\}"
-- Output: CSS: {color: red}
-- Inline JavaScript
say "function()\{location.reload()\}"
-- Output: function(){location.reload()}
-- Mix interpolation and literal braces
name is "Alice"
say "{name} says \{hello\}"
-- Output: Alice says {hello}
Numbers
Numbers in Quill work like you would expect. They can be whole numbers (integers) or decimals:
wholeNumber is 42
decimal is 3.14
negative is -10
zero is 0
You can do math with numbers (covered in the Operators section), and you can mix them into strings with interpolation:
price is 9.99
say "The total is ${price}" -- The total is $9.99
Booleans (Yes / No)
A boolean is a value that is either true or false, like a light switch that is either on or off. In Quill, you can write booleans as yes/no or true/false:
isLoggedIn is yes
hasPermission is no
gameOver is false
ready is true
Booleans are essential for making decisions in your code (which we will cover in the Control Flow section).
Lists
A list is an ordered collection of values, wrapped in square brackets and separated by commas. Think of it like a numbered shopping list:
fruits are ["apple", "banana", "cherry"]
numbers are [10, 20, 30]
mixed are ["hello", 42, yes] -- lists can hold different types
empty are [] -- an empty list
Access individual items by their position (index). Important: counting starts at 0, not 1:
colors are ["red", "green", "blue"]
say colors[0] -- red (first item)
say colors[1] -- green (second item)
say colors[2] -- blue (third item)
[0], the second at [1], and so on. This catches almost every beginner off guard!
Objects
An object is a collection of named values (called "properties"), wrapped in curly braces. Think of it like a contact card where each piece of info has a label:
user is {name: "Alice", age: 30, active: yes}
Access properties using a dot:
say user.name -- Alice
say user.age -- 30
say user.active -- yes
Nothing
nothing represents the absence of a value. It is like an empty box, the box exists, but there is nothing inside it. In other programming languages, this concept is called null or nil.
result is nothing
if result is nothing:
say "No result yet"
Functions that do not explicitly return a value give back nothing automatically.
say to print them all. Try using string interpolation to build a sentence like "My name is {name} and I am {age} years old."
Operators
What are operators? Operators are symbols that perform actions on values, like the + sign on a calculator. They take one or more values and produce a result.
Math operators
These work just like the math you already know:
| Operator | What it does | Example | Result |
|---|---|---|---|
+ | Addition | 2 + 3 | 5 |
- | Subtraction | 10 - 4 | 6 |
* | Multiplication | 3 * 7 | 21 |
/ | Division | 15 / 4 | 3.75 |
% | Modulo (remainder) | 10 % 3 | 1 |
Here is a practical example:
price is 29.99
quantity is 3
subtotal is price * quantity
tax is subtotal * 0.08
total is subtotal + tax
say "Your total is ${total}" -- Your total is $97.1676
What is modulo? The % operator gives you the remainder after division. For example, 10 % 3 is 1 because 10 divided by 3 is 3 with a remainder of 1. This is useful for checking if a number is even or odd:
number is 7
if number % 2 is 0:
say "Even"
otherwise:
say "Odd" -- prints "Odd"
Negative numbers
x is -5
y is -x -- y is 5 (flips the sign)
String concatenation with +
The + operator also works with text. It joins (concatenates) two strings together:
first is "Hello"
second is " World"
combined is first + second
say combined -- Hello World
"Hello, {name}!") is usually cleaner than concatenation ("Hello, " + name + "!").
width and height, multiply them, and print the result.
Comparisons
What are comparisons? Comparisons check the relationship between two values and give back a boolean (yes or no). They are how your program asks questions like "is the user old enough?" or "does this list contain the item?"
Quill comparisons read like English sentences:
| Quill | What it means | Example |
|---|---|---|
x is greater than y | Is x larger than y? | 10 is greater than 5 gives yes |
x is less than y | Is x smaller than y? | 3 is less than 7 gives yes |
x is equal to y | Are x and y the same? | 5 is equal to 5 gives yes |
x is y | Shorthand for equal | name is "Alice" gives yes if name is Alice |
x is not y | Are x and y different? | 5 is not 3 gives yes |
list contains x | Is x inside the list? | colors contains "red" |
Here are several examples:
age is 20
if age is greater than 18:
say "You are an adult"
if age is equal to 20:
say "You are exactly 20"
if age is not 21:
say "You are not 21"
Using contains
The contains keyword checks whether a list includes a particular item, or whether a string includes a particular substring:
colors are ["red", "blue", "green"]
if colors contains "red":
say "Red is in the list!"
if colors contains "purple":
say "This will not print"
message is "Hello, World!"
if message contains "World":
say "Found it!"
contains to check if "pizza" is in the list. Print a different message depending on the result.
Logic Operators
What are logic operators? Logic operators let you combine multiple conditions together. They answer questions like "is the user logged in AND an admin?" or "is it Saturday OR Sunday?"
| Operator | What it means | Gives yes when... |
|---|---|---|
and | Both must be true | yes and yes |
or | At least one must be true | yes or no, no or yes, yes or yes |
not | Flips the value | not no |
Simple example:
age is 25
name is "Alice"
if age is greater than 18 and name is "Alice":
say "Hello, adult Alice!"
Using or:
day is "Saturday"
if day is "Saturday" or day is "Sunday":
say "It's the weekend!"
otherwise:
say "It's a weekday."
Using not:
isLoggedIn is no
if not isLoggedIn:
say "Please log in first."
Real-world example: Checking if someone can rent a car (must be at least 21 and have a license):
age is 23
hasLicense is yes
if age is greater than 20 and hasLicense:
say "You can rent a car!"
otherwise:
say "Sorry, you cannot rent a car."
and is stricter than or. With and, ALL conditions must be true. With or, just ONE needs to be true.
Output
What is output? Output is how your program communicates with the outside world, specifically, by printing text to the screen. In Quill, you use the say keyword.
You can say anything, text, numbers, variables, expressions, even entire lists:
say "Hello!" -- prints text
say 42 -- prints a number
say 2 + 2 -- prints 4 (evaluates the expression first)
name is "Alice"
say name -- prints Alice
say "Hi, {name}!" -- prints Hi, Alice!
colors are ["red", "blue"]
say colors -- prints ["red", "blue"]
Control Flow, if / otherwise
What is control flow? Control flow is how your program makes decisions. Without it, your code would run every line from top to bottom with no ability to react to different situations. With if and otherwise, your program can choose different paths based on conditions.
Think of it like a fork in the road: "IF it is raining, take an umbrella. OTHERWISE, wear sunglasses."
Basic if
The simplest form, do something only when a condition is true:
age is 20
if age is greater than 18:
say "You are an adult"
Notice the colon : at the end of the if line, and that the next line is indented (shifted to the right with spaces). This indentation tells Quill which code belongs inside the if block.
if / otherwise
When you want to do one thing if the condition is true and a different thing if it is false:
temperature is 35
if temperature is greater than 30:
say "It's hot outside!"
otherwise:
say "It's not too hot."
Chained conditions: if / otherwise if / otherwise
When you have more than two possibilities, chain them together:
temp is 72
if temp is greater than 80:
say "It's hot!"
otherwise if temp is greater than 60:
say "It's nice"
otherwise if temp is greater than 40:
say "It's chilly"
otherwise:
say "It's cold!"
Quill checks each condition from top to bottom and runs the first one that is true. If none are true, it runs the otherwise block.
Nested ifs
You can put an if inside another if for more complex logic:
isLoggedIn is yes
isAdmin is no
if isLoggedIn:
say "Welcome back!"
if isAdmin:
say "You have admin access."
otherwise:
say "You have regular access."
otherwise:
say "Please log in."
Real-world example: grading system
score is 85
if score is greater than 89:
say "Grade: A"
otherwise if score is greater than 79:
say "Grade: B"
otherwise if score is greater than 69:
say "Grade: C"
otherwise if score is greater than 59:
say "Grade: D"
otherwise:
say "Grade: F"
: at the end of the if, otherwise if, or otherwise line. Every condition line must end with a colon.
hour (a number from 0 to 23) and prints "Good morning", "Good afternoon", or "Good evening" depending on the time of day.
Loops
What is a loop? A loop repeats a block of code multiple times. Instead of writing the same thing over and over, you tell the computer "do this for each item" or "keep doing this while some condition is true."
Think of it like a factory assembly line: the same action is performed on each item that comes down the belt.
for each, iterating over a list
The for each loop goes through every item in a list, one at a time:
fruits are ["apple", "banana", "cherry"]
for each fruit in fruits:
say "I like {fruit}"
This prints:
-- I like apple
-- I like banana
-- I like cherry
The variable fruit automatically takes on the value of each item as the loop runs. You can name it anything you want.
Use range() to loop over a sequence of numbers:
for each i in range(1, 6):
say i -- prints 1, 2, 3, 4, 5
while, repeating until something changes
A while loop keeps running as long as its condition is true:
count is 0
while count is less than 5:
say count
count is count + 1
This prints 0, 1, 2, 3, 4 and then stops because count is no longer less than 5.
break, stopping a loop early
Sometimes you want to stop a loop before it finishes naturally. The break keyword exits the loop immediately:
numbers are [3, 7, 2, 9, 1, 4]
for each n in numbers:
if n is 9:
say "Found 9!"
break -- stop searching, we found it
say "Checking {n}..."
continue, skipping an item
The continue keyword skips the rest of the current iteration and jumps to the next one:
for each i in range(1, 6):
if i is 3:
continue -- skip the number 3
say i -- prints 1, 2, 4, 5
Real-world example: finding a user
users are [
{name: "Alice", role: "admin"},
{name: "Bob", role: "user"},
{name: "Charlie", role: "user"}
]
for each user in users:
if user.role is "admin":
say "Admin found: {user.name}"
break
Real-world example: summing numbers
prices are [9.99, 24.50, 3.75, 15.00]
total is 0
for each price in prices:
total is total + price
say "Total: ${total}" -- Total: $53.24
while loop that never has its condition become false will run forever and freeze your program. Always make sure something inside the loop changes the condition. For example, forgetting count is count + 1 inside a while count is less than 5: loop means count stays at 0 forever.
% and continue.)
Functions
What is a function? A function is a reusable block of code that performs a specific task. Think of it like a recipe: you define it once with a name and instructions, then you can use (or "call") it whenever you need it.
Without functions, you would have to copy and paste the same code every time you wanted to use it. Functions let you write it once and reuse it everywhere.
Defining a function
In Quill, you define a function with the word to, followed by the function name and its parameters:
to greet name:
say "Hello, {name}!"
Read it like English: "To greet (someone by) name: say Hello, name!"
Calling a function
To use a function you have defined, write its name followed by the values you want to pass in (called "arguments"), wrapped in parentheses:
greet("Alice") -- Hello, Alice!
greet("Bob") -- Hello, Bob!
greet("Charlie") -- Hello, Charlie!
See how we wrote the greeting logic once but used it three times with different names? That is the power of functions.
Returning values with give back
Functions can calculate something and give the result back to you using give back:
to add a b:
give back a + b
result is add(3, 4)
say result -- 7
The function add takes two numbers, adds them, and gives the result back. You can store that result in a variable or use it directly.
Multiple parameters
Functions can accept any number of parameters, listed by name in the definition:
to fullName first last:
give back "{first} {last}"
say fullName("John", "Doe") -- John Doe
Functions with no parameters
to sayHello:
say "Hello, World!"
sayHello() -- Hello, World!
Real-world example: a simple calculator
to calculate a operation b:
if operation is "add":
give back a + b
otherwise if operation is "subtract":
give back a - b
otherwise if operation is "multiply":
give back a * b
otherwise if operation is "divide":
give back a / b
otherwise:
say "Unknown operation"
give back nothing
say calculate(10, "add", 5) -- 15
say calculate(20, "divide", 4) -- 5
give back in a function that should return a value. Without it, the function returns nothing by default.
isEven that takes a number and gives back yes if the number is even, or no if it is odd.
Lambda Functions
What is a lambda? A lambda is a small, anonymous (unnamed) function that you write in a single line. Think of it as a mini function, a quick shortcut when you need a simple operation and do not want to define a whole named function.
In Quill, you create a lambda with the with keyword:
double is with x: x * 2
say double(5) -- 10
say double(3) -- 6
This is equivalent to writing a full function:
-- This does the same thing as the lambda above
to double x:
give back x * 2
Multiple parameters
add is with a, b: a + b
say add(3, 4) -- 7
Using lambdas with list operations
Lambdas really shine when used with functions like map, filter, and reduce that transform lists:
numbers are [1, 2, 3, 4, 5]
-- Double every number
doubled is map(numbers, with x: x * 2)
say doubled -- [2, 4, 6, 8, 10]
-- Keep only even numbers
evens is filter(numbers, with x: x % 2 is 0)
say evens -- [2, 4]
-- Sum all numbers
total is reduce(numbers, with a, b: a + b, 0)
say total -- 15
to instead, it will be easier to read.
Type Annotations
What are type annotations? Type annotations are optional labels you add to your function parameters and return values to specify what kind of data they expect. They are like signs on the doors of a building: "Numbers Only" or "Text Required."
They do not change how your code runs, but they help catch mistakes early and serve as built-in documentation.
Basic syntax
Use as after a parameter name to declare its type, and -> before the colon to declare the return type:
to add a as number, b as number -> number:
give back a + b
This says: "The add function takes two numbers and gives back a number."
More examples
to greet name as text -> text:
give back "Hello, {name}!"
to isAdult age as number -> boolean:
give back age is greater than 17
Generic types with of
For lists, you can specify what type of items they contain using of:
to sum items as list of number -> number:
total is 0
for each item in items:
total is total + item
give back total
to first items as list of text -> text:
give back items[0]
Checking types with quill check
Type annotations are optional and do not affect how your program runs. However, you can use the quill check command to analyze your code and catch potential type mismatches before you run it:
quill check myprogram.quill
Pattern Matching
What is pattern matching? Pattern matching is like a smart switch statement. Instead of writing a long chain of if/otherwise if, you say "match this value" and list the possible cases. It is cleaner and easier to read when you are comparing one value against many options.
Think of it like a vending machine: you insert a code, and the machine matches it to the right product.
Basic syntax
status is "active"
match status:
when "active":
say "User is active"
when "inactive":
say "User is inactive"
when "banned":
say "User is banned"
otherwise:
say "Unknown status"
Matching numbers
code is 404
match code:
when 200:
say "OK"
when 404:
say "Not found"
when 500:
say "Server error"
otherwise:
say "Unexpected code: {code}"
When to use match vs if/otherwise
Use match when you are comparing one value against several specific options. Use if/otherwise when your conditions involve different variables, ranges, or complex expressions.
-- Good use of match: one value, many options
match dayOfWeek:
when "Monday":
say "Start of the work week"
when "Friday":
say "Almost weekend!"
otherwise:
say "Just another day"
-- Better as if/otherwise: complex conditions
if age is greater than 18 and hasLicense:
say "Can drive"
otherwise clause is optional but recommended. It catches any value you did not explicitly list, preventing unexpected behavior.
Error Handling
What are errors? Errors happen when something goes wrong while your program is running, like trying to read a file that does not exist, dividing by zero, or receiving unexpected data. Without error handling, your program would crash. With it, you can catch the problem and respond gracefully.
Think of it like a safety net under a tightrope walker. If they fall, the net catches them instead of letting disaster happen.
Basic syntax: try / if it fails
try:
result is riskyOperation()
if it fails error:
say "Something went wrong: {error}"
The code inside try: runs normally. If anything inside it causes an error, Quill immediately jumps to the if it fails block. The variable after if it fails (here called error) holds the error message.
Real-world example: loading a configuration file
try:
data is readFile("config.json")
say "Config loaded successfully"
if it fails error:
say "Could not load config, using defaults"
data is defaultConfig()
Real-world example: parsing user input
to parseAge input:
try:
age is toNumber(input)
if age is less than 0:
say "Age cannot be negative"
give back nothing
give back age
if it fails err:
say "'{input}' is not a valid number"
give back nothing
try if it might genuinely fail. Do not use it to hide bugs, fix the bug instead.
Raising Errors
Use raise to throw an error. This immediately stops execution and can be caught with try/if it fails.
-- Raise an error with a message
raise "something went wrong"
-- Raise inside a function
to divide a b:
if b is 0:
raise "cannot divide by zero"
give back a / b
-- Catch a raised error
try:
result is divide(10, 0)
if it fails err:
say "Error: " + err
raise with a string creates a new Error object automatically. You can also raise a variable that already holds an error: raise err.
Classes
What is a class? A class is a blueprint for creating things (called "objects" or "instances"). Imagine you are building houses: the blueprint (class) describes what every house looks like, how many rooms, what color the door is, how to open it. Each actual house you build from that blueprint is an instance.
Defining a class
In Quill, you define a class with describe:
describe Dog:
name is ""
breed is ""
age is 0
to bark:
say "{my.name} says: Woof!"
to describe:
say "{my.name} is a {my.age}-year-old {my.breed}"
Notice the my keyword, it refers to the specific instance's own properties. Think of it like saying "my own name" or "my own age."
Creating instances
Create a new instance of a class using new:
rex is new Dog()
rex.name is "Rex"
rex.breed is "German Shepherd"
rex.age is 5
rex.bark() -- Rex says: Woof!
rex.describe() -- Rex is a 5-year-old German Shepherd
Inheritance
A class can inherit (extend) another class, gaining all of its properties and methods. The child class can add new ones or override existing ones:
describe Animal:
name is ""
sound is ""
to speak:
say "{my.name} says {my.sound}"
describe Cat extends Animal:
sound is "Meow"
to purr:
say "{my.name} purrs..."
kitty is new Cat()
kitty.name is "Whiskers"
kitty.speak() -- Whiskers says Meow
kitty.purr() -- Whiskers purrs...
Real-world example: a game character
describe Character:
name is "Hero"
health is 100
attackPower is 10
to attack target:
say "{my.name} attacks {target.name} for {my.attackPower} damage!"
target.health is target.health - my.attackPower
to isAlive:
give back my.health is greater than 0
to status:
say "{my.name}: {my.health} HP"
hero is new Character()
hero.name is "Aria"
hero.attackPower is 25
villain is new Character()
villain.name is "Goblin"
villain.health is 50
hero.attack(villain) -- Aria attacks Goblin for 25 damage!
villain.status() -- Goblin: 25 HP
my. when accessing a class's own properties inside its methods. Without my., Quill looks for a regular variable instead of the instance's property.
BankAccount class with a balance property and deposit/withdraw methods.
Algebraic Data Types
What are algebraic data types? They let you define your own custom categories of data. Think of them like creating a custom label maker: you define a set of specific variants that a value can be, and nothing else.
For example, a traffic light can only be Red, Yellow, or Green, never Purple. Algebraic data types let you express exactly that.
Simple variants (enumerations)
The simplest form lists the possible values:
define Color:
Red
Green
Blue
Now Color can only ever be Red, Green, or Blue.
Variants with data
Some variants can carry additional information using of:
define Shape:
Circle of radius
Rectangle of width, height
Triangle of base, height
A Shape is always one of these three things, and each one carries exactly the data it needs.
Using with pattern matching
Algebraic types work naturally with match/when:
to area shape:
match shape:
when Circle radius:
give back 3.14159 * radius * radius
when Rectangle width height:
give back width * height
when Triangle base height:
give back 0.5 * base * height
say area(Circle(5)) -- 78.53975
say area(Rectangle(4, 6)) -- 24
When to use define vs describe
Use define (algebraic types) when you have a fixed set of distinct categories, especially when different variants carry different data. Use describe (classes) when you need objects with shared behavior (methods), properties that change over time, and inheritance.
Result type with two variants: Success of value and Error of message. Write a function that returns one or the other and use match to handle both cases.
Pipe Operator
What is the pipe operator? The pipe operator (|) lets you chain transformations together in a readable left-to-right sequence. Think of it like an assembly line in a factory: raw material goes in one end, gets transformed at each station, and the finished product comes out the other end.
Basic usage
Instead of nesting function calls inside each other (which reads inside-out), you can pipe values through functions:
-- Without pipes (nested, reads inside-out):
result is trim(upper(" hello "))
-- With pipes (reads left-to-right):
result is " hello " | upper | trim
Both produce the same result, but the pipe version reads in the order the operations happen: take the string, make it uppercase, then trim the whitespace.
Pipes with arguments
When a function takes extra arguments, the piped value becomes the first argument:
result is "hello world" | replace_text("world", "Quill")
say result -- hello Quill
Chaining multiple steps
You can chain as many pipes as you need. For readability, you can put each step on its own line:
output is rawInput
| trim
| lower
| replace_text(" ", "-")
-- If rawInput is " Hello World ", output is "hello-world"
Object Literals
What is an object? An object is a way to group related pieces of information together using named labels (keys). Think of it like a contact card: instead of having separate variables for name, phone, and email, you bundle them into one object.
Creating objects
person is {name: "Alice", age: 25, active: yes}
say person.name -- Alice
say person.age -- 25
Multi-line objects
For objects with many properties, spread them across multiple lines for readability:
config is {
host: "localhost",
port: 3000,
debug: yes
}
Nested objects
Objects can contain other objects. This is useful for organizing related settings together:
config is {
db: {
host: "localhost",
port: 5432
},
app: {
name: "MyApp",
debug: yes
}
}
say config.db.host -- localhost
say config.db.port -- 5432
say config.app.name -- MyApp
Modifying properties
You can change the value of a property after the object is created, just like reassigning a variable:
user is {name: "Alice", age: 30}
say user.name -- Alice
-- Change the name
user.name is "Bob"
say user.name -- Bob
-- Add a new property
user.email is "bob@example.com"
say user.email -- bob@example.com
Accessing properties with bracket notation
Besides dot notation (user.name), you can also use bracket notation with a string. This is useful when the property name is stored in a variable or contains special characters:
user is {name: "Alice", age: 30}
-- These two are the same:
say user.name -- Alice
say user["name"] -- Alice
-- Bracket notation lets you use a variable as the key
field is "age"
say user[field] -- 30
Checking if a key exists
To check whether an object has a particular property, you can check if the value is nothing:
user is {name: "Alice", age: 30}
if user.email is nothing:
say "No email on file"
if user.name is not nothing:
say "Name: {user.name}"
title, author, and pages. Then print each property with say.
Spread Operator
What is the spread operator? The spread operator (...) "unpacks" a list or object, spreading its contents into another list or object. Think of it like pouring the contents of one box into another.
Combining lists
first are [1, 2, 3]
second are [4, 5, 6]
combined are [...first, ...second]
say combined -- [1, 2, 3, 4, 5, 6]
You can mix spread with individual items:
middle are [3, 4]
full are [1, 2, ...middle, 5, 6]
say full -- [1, 2, 3, 4, 5, 6]
Merging objects
Spread also works for combining objects. If both objects have the same key, the later one wins:
defaults is {color: "blue", size: 10}
overrides is {size: 20, bold: yes}
merged is {...defaults, ...overrides}
say merged -- {color: "blue", size: 20, bold: yes}
Notice that size is 20 (from overrides), not 10 (from defaults), because overrides was spread second.
Lists (Detailed)
We introduced lists earlier, but here is a deeper look at working with them.
Creating lists
numbers are [1, 2, 3, 4, 5]
mixed are ["hello", 42, yes]
empty are []
Accessing items by index
colors are ["red", "green", "blue"]
say colors[0] -- red (first item)
say colors[1] -- green (second item)
say colors[2] -- blue (third item)
Getting the length
say length(colors) -- 3
Iterating over a list
fruits are ["apple", "banana", "cherry"]
for each fruit in fruits:
say "I like {fruit}"
Modifying elements
You can change any item in a list by assigning to its index:
colors are ["red", "green", "blue"]
colors[0] is "purple"
say colors -- ["purple", "green", "blue"]
Adding items
Use push to add an item to the end of a list:
colors are ["red", "green"]
push(colors, "yellow")
say colors -- ["red", "green", "yellow"]
Removing items
Use pop to remove the last item, or splice to remove items at a specific position:
colors are ["red", "green", "blue"]
-- Remove the last item
last is pop(colors)
say last -- blue
say colors -- ["red", "green"]
-- Remove 1 item at index 0
colors.splice(0, 1)
say colors -- ["green"]
Finding items
Check if a list contains a specific item:
colors are ["red", "green", "blue"]
say includes(colors, "red") -- true
say includes(colors, "yellow") -- false
say indexOf(colors, "green") -- 1
Using dot notation
say "hello".length -- 5
Imports
What are imports? As your programs grow, you will want to split your code into multiple files and use code that other people have written. Imports let you bring code from other files into your current file.
Importing your own Quill files
If you have a file called helpers.quill with useful functions, you can import it:
use "helpers.quill"
This brings all definitions from helpers.quill into your current file.
Selective imports
If you only need specific functions from a file, use from:
from "math.quill" use add, subtract
from "utils.quill" use formatDate, parseJSON
This keeps your namespace clean by only bringing in what you actually need.
Importing npm packages
Since Quill compiles to JavaScript, you can use packages from the npm ecosystem:
use "express" as app
from "express" use Router, json
Testing
Why does testing matter? Testing is how you make sure your code works correctly, not just right now, but also after you make changes in the future. Think of tests like a checklist: "Does the add function still return 5 when I give it 2 and 3?"
Writing tests
In Quill, you write tests with the test keyword followed by a description:
test "addition works correctly":
result is add(2, 3)
expect(result).toBe(5)
Multiple tests
to isEven n:
give back n % 2 is 0
test "isEven returns yes for even numbers":
expect(isEven(4)).toBe(yes)
expect(isEven(0)).toBe(yes)
test "isEven returns no for odd numbers":
expect(isEven(3)).toBe(no)
expect(isEven(7)).toBe(no)
Running tests
Run your tests from the terminal:
quill test
Quill will find all test blocks in your project and run them, reporting which ones passed and which ones failed.
Generics
What are generics? Generics let you write type annotations that work with any type of content. For example, instead of writing separate functions for "a list of numbers" and "a list of text," you can write one function that works with "a list of anything."
Use the of keyword to parameterize collection types:
to sum items as list of number -> number:
total is 0
for each item in items:
total is total + item
give back total
to first items as list of text -> text:
give back items[0]
to hasAny items as list of boolean -> boolean:
for each item in items:
if item:
give back yes
give back no
Reactive Web Components
Quill includes a Svelte-inspired component system that lets you build interactive web interfaces directly in .quill files. Components combine state, logic, and a declarative HTML-like template into a single, readable unit.
Core concepts
component Name:— defines a new component.state varName is value— creates reactive state. When the value changes, the UI updates automatically.to render:— defines the template using an HTML-like DSL.- Event handlers such as
onClick,onInput, andonChangeattach behavior to elements. bind:value— two-way binding between an input element and a state variable.mount ComponentName to "#selector"— mounts the component to a DOM element.
Example: Counter
component Counter:
state count is 0
to increment:
count is count + 1
to render:
h1 "Count: {count}"
button onClick=increment "Add one"
mount Counter to "#app"
Example: Todo App
component TodoApp:
state todos is []
state newTodo is ""
to addTodo:
if newTodo is not "":
push(todos, {text: newTodo, done: no})
newTodo is ""
to render:
h1 "My Todos"
input bind:value=newTodo placeholder="Add a task..."
button onClick=addTodo "Add"
ul:
for each todo in todos:
li "{todo.text}"
mount TodoApp to "#app"
Building for the browser
To compile a component file into a JavaScript bundle that runs in a browser, use the --browser flag:
quill build app.quill --browser
This produces an optimised .js file you can load in an HTML page with a <script> tag.
Union & Nullable Types
Sometimes a value can be one of several types, or it might not exist at all. Quill supports union types and nullable types to express this clearly in your type annotations.
Union types
Use the pipe | character to say "this value can be one type or another":
to format value as number | text -> text:
give back toText(value)
say format(42) -- "42"
say format("hi") -- "hi"
Nullable types
Prefix a type with ? to indicate the value might be nothing:
to findUser id as number -> ?object:
if id is 0:
give back nothing
give back {name: "Alice", id: id}
Static checking
Run quill check to validate that union and nullable types are used consistently. The checker will warn you if you pass a text value to a function that only accepts number, or if you forget to handle a nothing case.
Source Maps
When Quill compiles your code to JavaScript, it also generates a source map file (.js.map) alongside the output. Source maps tell the browser how to translate the generated JavaScript back to your original .quill source.
This means you can open your browser's DevTools, set breakpoints, and step through your code while seeing the original Quill source — not the compiled JavaScript. No extra configuration is needed; modern browsers detect source maps automatically.
quill build app.quill --browser
-- produces app.js and app.js.map
--browser, --standalone, etc.). You can disable them with the --no-sourcemap flag if you do not want to ship them in production.
Concurrency
Sometimes your program needs to do more than one thing at the same time. Maybe you want to download three files at once, or process data while also listening for user input. This is called concurrency, doing multiple things that overlap in time.
Think of it like cooking dinner: you can boil pasta on one burner while the sauce simmers on another. You are one person, but you are managing multiple things at once. That is concurrency.
spawn task, Running code in the background
Imagine you ask someone to go fetch a package from the post office while you keep working at your desk. You gave them a task, and you did not stop what you were doing. That is what spawn task does, it starts a piece of code running in the background so your program can keep going.
-- Start a background task
spawn task fetchData:
result is fetch("https://api.example.com/data")
say "Got the data!"
-- This runs immediately -- we didn't wait for fetchData
say "Still working while data is being fetched..."
The code inside spawn task fetchData: runs in the background. Your program continues to the next line without waiting. The task has a name (fetchData) so you can refer to it later if you need the result.
-- A practical example: processing items in the background
spawn task sendEmails:
users is getUsers()
for each user in users:
sendEmail(user.email, "Welcome!")
say "All emails sent."
spawn task generateReport:
data is loadSalesData()
report is buildReport(data)
saveFile("report.pdf", report)
say "Report saved."
say "Both tasks started! The program continues here."
await, Waiting for a task to finish
Sometimes you need the result of a background task before you can continue. The await keyword pauses your code until the task is done, like waiting at the door for your package to arrive before you can open it.
spawn task loadUser:
give back fetch("https://api.example.com/user/1")
-- Wait for the task to finish and get the result
user is await loadUser
say "Hello, {user.name}!"
-- You can await multiple tasks one after another
spawn task getWeather:
give back fetch("https://api.weather.com/today")
spawn task getNews:
give back fetch("https://api.news.com/headlines")
weather is await getWeather
news is await getNews
say "Weather: {weather.summary}"
say "Top headline: {news[0].title}"
Real-world example: fetching data from an API
Here is a complete example that fetches JSON data from a web API and handles errors:
-- Fetch a list of users from an API
try:
response is await fetch("https://jsonplaceholder.typicode.com/users")
users is await response.json()
for each user in users:
say "{user.name} - {user.email}"
if it fails error:
say "Failed to fetch users: {error}"
Running multiple async operations
When you need to fetch several pieces of data, you can use parallel: (covered below) or simply await them one by one:
-- Fetch user data and their posts at the same time
try:
parallel:
userRes is fetch("https://api.example.com/user/1")
postsRes is fetch("https://api.example.com/user/1/posts")
user is await userRes.json()
posts is await postsRes.json()
say "{user.name} has {length(posts)} posts"
if it fails error:
say "Something went wrong: {error}"
parallel:, Doing multiple things at once
What if you want to start several tasks and wait for all of them to finish? The parallel: block does exactly that. Think of it like telling three friends to each go buy one item at the store, you wait until everyone comes back, then you have everything you need.
Under the hood, this works like JavaScript's Promise.all, all the tasks run at the same time, and the block finishes when the last one completes.
-- Fetch three APIs at the same time
parallel:
users is fetch("https://api.example.com/users")
posts is fetch("https://api.example.com/posts")
comments is fetch("https://api.example.com/comments")
-- All three are done now -- use the results
say "Got {length(users)} users, {length(posts)} posts, {length(comments)} comments"
-- Process multiple files at the same time
parallel:
image is resizeImage("photo.jpg", 800, 600)
thumbnail is resizeImage("photo.jpg", 150, 150)
metadata is readExif("photo.jpg")
saveFile("photo-resized.jpg", image)
saveFile("photo-thumb.jpg", thumbnail)
say "Photo taken on: {metadata.date}"
race:, First one wins
Sometimes you want to start several tasks but only care about whichever finishes first. The race: block does exactly that, like starting a race between runners, the first one to cross the finish line wins, and the rest are ignored.
This is useful for timeouts, trying multiple servers, or getting the fastest response.
-- Try two mirrors, use whichever responds first
result is race:
fetch("https://mirror1.example.com/data")
fetch("https://mirror2.example.com/data")
say "Got data from the fastest mirror!"
-- Implement a timeout: if the API doesn't respond in 5 seconds, give up
result is race:
fetch("https://slow-api.example.com/data")
delay(5000) then give back {error: "Timed out"}
if result.error:
say "The request timed out."
otherwise:
say "Got the data!"
Channels, Sending messages between tasks
Channels are like mailboxes that tasks use to send messages to each other. One task puts a message in the mailbox (send), and another task picks it up (receive). This is the safest way for tasks to communicate, instead of sharing variables directly, they pass messages through a channel.
The buffer number controls how many messages can sit in the mailbox before the sender has to wait. Think of it like a physical mailbox, if it can hold 5 letters, the mail carrier can drop off 5 without waiting. But if it is full, they have to wait until someone picks up a letter.
-- Create a channel that can hold 3 messages
channel messages with buffer 3
-- Task 1: produce messages
spawn task producer:
send "Hello" to messages
send "World" to messages
send "Done" to messages
-- Task 2: consume messages
spawn task consumer:
msg1 is receive from messages
msg2 is receive from messages
msg3 is receive from messages
say "{msg1} {msg2} -- {msg3}"
-- Output: Hello World -- Done
-- A worker pool pattern: multiple workers sharing a channel
channel jobs with buffer 10
channel results with buffer 10
-- Start 3 workers
repeat 3 times with i:
spawn task worker:
job is receive from jobs
result is processJob(job)
send result to results
-- Send jobs to the workers
for each item in workItems:
send item to jobs
select:, Waiting on multiple channels
Sometimes a task needs to listen to more than one channel at a time, or give up after a certain amount of time. The select: block lets you do this, it waits and reacts to whichever channel has a message first. Think of it like standing in a room with multiple doors, you wait until any door opens, then go through that one.
channel orders with buffer 5
channel cancellations with buffer 5
-- Wait for either an order or a cancellation
select:
when receive order from orders:
say "New order: {order.item}"
processOrder(order)
when receive cancel from cancellations:
say "Cancellation: {cancel.id}"
refundOrder(cancel)
after 30000:
say "No activity for 30 seconds, checking again..."
The after clause is a timeout, if no channel has a message within that many milliseconds, the timeout code runs instead. This prevents your program from waiting forever.
-- A chat-like system with select
channel userMessages with buffer 10
channel systemAlerts with buffer 5
repeat forever:
select:
when receive msg from userMessages:
say "[User] {msg.author}: {msg.text}"
when receive alert from systemAlerts:
say "[System] {alert.message}"
after 60000:
say "No messages for a minute. Quiet in here!"
Traits & Interfaces
What are traits? Traits are like contracts. They define a set of methods that a class must implement. If a class says it implements a trait, the compiler checks that all the required methods are present. This is useful for writing code that works with any type that has certain capabilities.
Think of traits like job requirements. A job posting says "must be able to write reports and give presentations." Any candidate who meets those requirements can fill the role, it does not matter who they are specifically.
Defining a trait
describe trait Printable:
to display -> text
describe trait Serializable:
to toJSON -> text
to fromJSON data as text
Implementing a trait
describe User implements Printable, Serializable:
name is ""
to display -> text:
give back my.name
to toJSON -> text:
give back "{\"name\": \"{my.name}\"}"
to fromJSON data as text:
say "parsing..."
quill check will tell you exactly which method is missing and where.
Generic Constraints
Generics become even more powerful when you add constraints. The where keyword lets you say "this type parameter must implement a specific trait." This way, you can write generic functions that still know what operations are available.
Basic constraint
to sort items as list of T where T is Comparable:
give back items | sort
to printAll items as list of T where T is Printable:
for each item in items:
say item.display()
Multiple constraints
to process items as list of T where T is Printable, T is Serializable:
for each item in items:
say item.display()
say item.toJSON()
Destructuring
What is destructuring? Destructuring lets you pull values out of objects and lists in a single step, instead of accessing them one by one. It is like opening a package and labeling each item as you take it out.
Object destructuring
person is {name: "Alice", age: 30, city: "Portland"}
-- Pull out name and age
{name, age} is person
say name -- "Alice"
say age -- 30
-- Rest: grab some fields and collect the rest
{name, ...rest} is person
say rest -- {age: 30, city: "Portland"}
List destructuring
items are [1, 2, 3, 4, 5]
-- Pull out the first two
[first, second] are items
say first -- 1
-- Head and tail pattern
[head, ...tail] are items
say head -- 1
say tail -- [2, 3, 4, 5]
Nested destructuring
user is {name: "Bob", address: {city: "Seattle", zip: "98101"}}
{address: {city, zip}} is user
say city -- "Seattle"
say zip -- "98101"
In function parameters
to greet {name, age}:
say "{name} is {age} years old"
greet({name: "Alice", age: 30})
Iterators & Generators
What are generators? A generator is a special function that can pause and produce values one at a time using yield. Instead of computing everything up front and returning a big list, generators produce values lazily, only when you ask for the next one.
Think of a generator like a card dealer. They do not throw the whole deck at you. They deal one card at a time, whenever you ask.
Basic generator
to countUp start:
n is start
loop:
yield n
n is n + 1
-- Produces 1, 2, 3, 4, 5, ... forever
counter is countUp(1)
Fibonacci generator
to fibonacci:
a is 0
b is 1
loop:
yield a
temp is a
a is b
b is temp + b
-- Get the first 10 Fibonacci numbers
fibs is fibonacci() | take 10 | collect
say fibs -- [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
yield compile to JavaScript function* generators. The loop: keyword creates an infinite loop, it runs forever until something breaks out of it.
Lazy Evaluation
Lazy evaluation means values are only computed when they are actually needed. Combined with generators and the pipe operator, you can build powerful data processing pipelines that handle huge (even infinite) data sets efficiently.
Lazy chains
-- Process a million numbers without using a million items of memory
result is range(1, 1000000)
| filter with n: n % 2 is 0
| map_list with n: n * n
| take 10
| collect
say result -- [4, 16, 36, 64, 100, 144, 196, 256, 324, 400]
Available lazy operations
These operations are lazy, they do not process items until you call collect, reduce, first, or last:
filter, keep items that match a conditionmap_list, transform each itemtake n, keep only the first n itemsskip n, skip the first n itemstakeWhile, keep items while a condition is trueskipWhile, skip items while a condition is truezip, combine two iterators into pairsenumerate, add an index to each itemflatten, flatten nested iterators
Terminal operations (these consume the iterator and produce a result):
collect, gather all items into a listreduce, combine all items into a single valuefirst, get the first itemlast, get the last itemany, true if any item matchesevery, true if all items match
Combining with generators
-- Find the first 5 prime Fibonacci numbers
result is fibonacci()
| filter with n: isPrime(n)
| take 5
| collect
Type-Based Pattern Matching
Quill's match expression supports matching on the type of a value, not just its content. Combined with guard clauses, this gives you very expressive branching logic.
Matching by type
match value:
when text t:
say "Got text: {t}"
when number n:
say "Got number: {n}"
when list l:
say "Got a list with {length(l)} items"
when nothing:
say "Got nothing"
Guard clauses
Add an if condition after a when clause to refine the match:
match age:
when n if n is less than 18:
say "Minor"
when n if n is less than 65:
say "Adult"
otherwise:
say "Senior"
Exhaustive checking
When matching on algebraic data types, the compiler checks that you cover every variant. If you miss one, you get a clear error:
define Shape:
Circle of radius
Rectangle of width, height
Triangle of base, height
match shape:
when Circle r:
say "Circle with radius {r}"
when Rectangle w, h:
say "Rectangle {w}x{h}"
-- compiler error: non-exhaustive match, missing Triangle variant
define). For other values, use otherwise: as a catch-all.
Full-Stack in One File
Quill lets you write an entire application, server, database, and UI, in a single file. The compiler separates server-side and client-side code automatically. No configuration needed.
Server block
server:
port is 3000
route get "/api/users":
users is await DB.find({})
respond with users
Database block
database:
connect "sqlite://app.db"
model User:
name as text
email as text
UI + Mount
component App:
state:
users is []
to render:
h1 "My App"
for each user in users:
p "{user.name}"
mount App to "#app"
Running it
Just one command:
quill run app.quill
That gives you a running server, a connected database, and a rendered UI. No package.json, no node_modules, no webpack, no config files.
Async Improvements
Quill's async system goes beyond basic await. You can cancel running tasks, iterate over async streams, and handle parallel operations where some may fail without crashing the whole group.
Cancellation
Sometimes you start a background task and then decide you no longer need it — maybe the user navigated away, or a timeout was reached. Use cancel to stop a running task.
spawn task fetcher:
data is await fetchJSON("/slow-endpoint")
-- Changed our mind, stop the task
cancel fetcher
Async iteration
When data arrives in chunks (like reading a large file or receiving messages from a server), use for await each to process each piece as it arrives.
for await each chunk in stream:
say "Got: {chunk}"
Parallel settled
A regular parallel: block stops everything if one task fails. With parallel settled:, every task runs to completion. Each result is either a Success() or an Error(), so you can handle failures individually.
parallel settled:
a is await fetchJSON("/a")
b is await fetchJSON("/b")
-- a and b are each Success() or Error(), never throws
if a.ok:
say a.value
Error Propagation
Quill has a Result type that represents either a successful value or an error. This lets you handle errors explicitly without exceptions, similar to Rust's Result type but with Quill's English-like syntax.
Success and Error
Functions return Success(value) when things go well and Error(message) when something goes wrong.
to loadUser id as number -> Result of User:
if id is 0:
give back Error("not found")
give back Success({name: "Alice", id: id})
The ? operator
Writing error checks for every function call gets repetitive. The ? operator automatically propagates errors — if the result is an Error, the current function returns that error immediately. If it is a Success, the inner value is unwrapped.
to loadProfile id as number -> Result of Profile:
user is loadUser(id)? -- returns Error early if loadUser fails
give back Success(user.profile)
The try expression
Use try as an expression to provide a fallback value when something fails.
name is try loadUser(42) otherwise "anonymous"
Advanced Destructuring
You already know how to destructure variables ({name, age} is person). Quill also lets you destructure directly inside loops and match expressions, which keeps your code short and readable.
In loops
Instead of pulling out fields inside the loop body, destructure right in the for each line.
for each {name, age} in users:
say "{name} is {age} years old"
In match expressions
Match on the shape of an object by destructuring inside when clauses.
match response:
when {status: 200, body}:
say body
when {status: 404}:
say "Not found"
otherwise:
say "Unexpected status"
Computed Properties
Sometimes you do not know the name of an object key until your program is running. Computed properties let you use a variable or expression as the key by wrapping it in square brackets.
key is "color"
obj is {[key]: "blue", size: 10}
say obj.color -- "blue"
This is useful when building objects dynamically — for example, collecting form fields into an object where the field names come from user input.
Tagged Templates
Tagged templates let you prefix a string with a function name. The function receives the string parts and interpolated values separately, so it can process them — for example, escaping SQL parameters or sanitizing HTML.
-- SQL with automatic parameter escaping
result is query`SELECT * FROM users WHERE age > {minAge}`
-- HTML with sanitization
page is html`<h1>{title}</h1><p>{body}</p>`
-- CSS with scoping
styles is css`
.card:
padding is {spacing}px
color is {theme.text}
`
The built-in tags query, html, and css are part of the standard library. You can also write your own tag functions.
Visibility
By default, all fields and methods in a class are accessible from anywhere. Use private and public to control what outside code can see and use.
describe Account:
private balance is 0
public name is ""
public to deposit amount:
my.balance is my.balance + amount
private to audit:
say "Balance: {my.balance}"
Private fields compile to JavaScript # private fields, so they are truly private at runtime — not just a naming convention.
Enums with Methods
Algebraic data types can have methods attached to them. This is useful when your enum variants carry values and you want to define behavior that works across all variants.
define HttpStatus:
OK is 200
NotFound is 404
ServerError is 500
to isSuccess:
give back my.value is less than 400
to toMessage:
match my:
when OK: give back "OK"
when NotFound: give back "Not Found"
when ServerError: give back "Internal Server Error"
Call methods on an enum value just like on an object: HttpStatus.OK.isSuccess() returns yes.
Type Utilities
Type utilities let you create new types by transforming existing ones. Instead of redefining a type from scratch, you can take an existing type and make all fields optional, pick a subset of fields, or mark everything as read-only.
type PartialUser is Partial of User -- all fields optional
type UserName is Pick of User, "name" | "email" -- only name and email
type SafeUser is Omit of User, "password" -- everything except password
type ScoreMap is Record of text, number -- string keys, number values
type Frozen is Readonly of Config -- no field can be reassigned
type Complete is Required of PartialUser -- all fields required
These work the same way as TypeScript utility types, but with Quill's English-like syntax.
Decorators
Decorators let you attach extra behavior to a function or class without changing its code. Write the decorator name with an @ symbol on the line above. Think of them as labels that tell Quill to wrap the function with additional logic.
-- This route requires authentication and is rate-limited
@authenticated
@rateLimit(100)
to getUsers request:
give back DB.find({})
-- This function logs every call
@log
to processOrder order:
say "Processing {order.id}"
Decorators compile to standard JavaScript decorator syntax. You can use built-in decorators like @log, @authenticated, and @rateLimit, or write your own.
WebSockets
WebSockets allow real-time, two-way communication between a server and its clients. Quill provides a dedicated websocket block that handles connection lifecycle and broadcasting.
websocket "/chat":
on connect client:
say "{client.id} joined"
on message client, data:
broadcast data
on disconnect client:
say "{client.id} left"
The broadcast function sends a message to every connected client. You can also send to a specific client with client.send(data).
Testing Mocks
Mocks let you replace a real function with a fake one during tests. This is useful when you do not want your tests to make real HTTP requests or talk to a real database.
mock fetchJSON with url:
give back {name: "Mock User"}
test "fetches user":
user is await getUser(1)
expect user.name is "Mock User"
expect fetchJSON was called 1 times
After the test finishes, the original function is automatically restored. You can also check how many times a mocked function was called and with what arguments.
Variable Scoping
What is scoping? Scoping is about where a variable can be seen and used in your program. A variable created inside a block (like a function, loop, or if statement) only exists inside that block. Once the block ends, the variable disappears.
Think of it like rooms in a house. A lamp in the bedroom can only light up the bedroom. Code in other rooms cannot see it.
Function scope
Variables created inside a function (including its parameters) are local to that function. Code outside the function cannot see them:
to greet name:
message is "Hello, {name}!"
say message
greet("Alice") -- Hello, Alice!
-- say message -- ERROR: message is not defined here
-- say name -- ERROR: name is not defined here
Block scope (if / for / while)
Variables defined inside if, for each, or while blocks are scoped to that block:
score is 85
if score is greater than 80:
grade is "A"
say grade -- A (works fine inside the block)
-- grade is not accessible here outside the if block
Outer variables are visible inside blocks
Code inside a block can see and modify variables from the enclosing scope:
total is 0
for each n in [10, 20, 30]:
total is total + n -- modifying the outer variable
say total -- 60
User Input
How do I get input from the user? Since Quill compiles to JavaScript, you can read input from the terminal using Node.js built-in modules. Quill provides a convenient way to do this.
Reading a line of input
Use the ask function to prompt the user and get their response:
name is await ask("What is your name? ")
say "Hello, {name}!"
Using input in a loop
say "Type 'quit' to exit."
running is yes
while running:
answer is await ask("Enter a command: ")
if answer is "quit":
say "Goodbye!"
running is no
otherwise:
say "You typed: {answer}"
Using readline directly
If you need more control, you can use the Node.js readline module directly since Quill compiles to JavaScript:
use "readline" as rl
reader is rl.createInterface({
input: process.stdin,
output: process.stdout
})
reader.question("How old are you? ", with answer:
say "You are {answer} years old!"
reader.close()
)
ask function requires await because reading user input is an asynchronous operation. The program pauses until the user types something and presses Enter.
Built-in Template Engine
Quill ships with a built-in template engine for generating HTML programmatically. Instead of concatenating strings or using a separate templating library, you can build type-safe HTML with plain Quill functions.
Core functions
tag(name, attrsOrChildren, children)— Build an HTML tag.nameis the element name,attrsOrChildrenis an object of attributes or a string/array of children, andchildrenis a string or array of child elements.page(options)— Generate a full HTML page. Options:title,body,styles,scripts,lang,charset,viewport.escapeHTML(str)— Escape HTML entities (&,<,>,",') for XSS protection.eachItem(items, fn)— Map items to HTML strings and join them together.showIf(condition, content)— Conditional HTML rendering. Returnscontentwhenconditionis true, empty string otherwise.layout(name, templateFn)— Register a reusable layout template.renderLayout(name, data)— Render a registered layout with the given data.partial(name, templateFn)— Register or render a partial template (reusable HTML fragments).raw(str)— Bypass automatic escaping. Use with caution — only for trusted content.
Shorthand tag builders
For convenience, Quill provides shorthand functions for common HTML elements. Each works exactly like tag() but with the element name pre-filled:
-- All of these shorthand builders are available:
div() span() h1() h2() h3() p() a() img()
ul() ol() li() form() input() button() label() textarea()
table() tr() th() td()
nav() header() footer() section() article() main()
Example
items is ["Apple", "Banana", "Cherry"]
html is page({
title: "My Page",
body: tag("div", {class: "container"}, [
tag("h1", "Hello from Quill!"),
tag("ul", eachItem(items, with item: tag("li", item)))
])
})
say html
Utility CSS Framework
Quill includes a built-in Tailwind-style utility CSS framework. When you use components, the framework is auto-injected — no extra setup required. Apply utility classes via the className attribute.
Available categories
- Layout:
flex,grid,block,hidden,inline-flex - Flexbox:
flex-row,flex-col,flex-wrap,flex-1,items-center,justify-between,gap-* - Grid:
grid-cols-1throughgrid-cols-12,col-span-* - Spacing:
p-0top-12,px-*,py-*,m-0tom-8,mx-auto,mt-*,mb-* - Sizing:
w-full,h-screen,max-w-smtomax-w-7xl,min-h-screen - Typography:
text-xstotext-6xl,font-bold,text-center,uppercase,truncate - Colors:
text-{color}-{shade},bg-{color}-{shade}(gray, red, green, blue, indigo, purple, pink) - Borders:
border,border-2,roundedtorounded-full,border-{color} - Shadows:
shadow-smtoshadow-xl - Transitions:
transition,transition-colors,duration-* - Responsive:
sm:*,md:*,lg:*prefixes - Hover/Focus:
hover:bg-*,hover:text-*,focus:ring-*,focus:outline-none - Pre-built components:
btn,btn-primary,btn-secondary,btn-success,btn-danger,btn-outline,btn-ghost,btn-sm,btn-lg,input,card,badge-*,alert-*,container - Animations:
animate-spin,animate-pulse,animate-bounce,animate-fade-in,animate-slide-up
Example
component Dashboard:
to render:
div className "container mx-auto p-8":
h1 className "text-3xl font-bold mb-6": "Dashboard"
div className "grid grid-cols-3 gap-4":
div className "card shadow-md hover:shadow-lg transition":
h3 className "font-semibold mb-2": "Users"
p className "text-2xl text-blue-600": "1,234"
button className "btn btn-primary mt-4": "View All"
Next Steps
Congratulations! You have learned the fundamentals of Quill. Here is where to go from here:
- Standard Library, explore the built-in functions available to every Quill program
- Testing, learn more about writing and running tests
- Tools, discover the Quill CLI tools for formatting, checking, and building
- Examples, see complete working programs to learn from
The best way to learn is to build something. Start small, a calculator, a to-do list, a guessing game, and gradually tackle bigger projects. Every expert was once a beginner. Happy coding!