Sprig tutorial
Sprig is for people and coding agents. Sprig does not try to make coding agents smarter; it tries to give them less to guess. The same principle helps human readers: agent-friendly should also mean review-friendly. This course starts with runnable programs and uses source files from the repository. The docs gate compiles and runs those snippets and checks their output.
The published Beta SDK is v0.5.0-beta.1. Start with release status and query the installed SDK with sprig capabilities --json for its exact feature set. The tutorial is tested against the published SDK.
1. Install and run a first program
Install an SDK from the releases page and use JDK 17 or newer. The SDK does not include a JDK. After creating a project, sprig run checks Sprig, generates Java, calls javac, and launches the JVM.
sprig init hello
cd hello
sprig resolve
sprig runExpected output: Hello, Sprig!. Here is that program's actual source:
# Hello world: typed functions and string concatenation.
func greet(name: String) -> String:
return "Hello, " + name + "!"
print(greet("Ada"))2. Bindings and types
Local variables can infer their type from the initializer. let cannot be rebound; var can. Conditions must be Bool: Sprig does not treat integers or strings as truthy values.
# let binds once; var may be reassigned.
let name = "Ada"
var visits = 0
visits += 1
# Local bindings infer their type from the initializer.
let greeting = "Hello, " + name
print(greeting)
print(visits)Exercise: add an immutable name and build a greeting. Then assign a String to an Int, read the diagnostic code, and fix the program.
3. Explicit functions
Every function parameter and return type is written down. Callers can see inputs, outputs and possible failures in the signature.
# Named functions declare parameter and return types, including -> Unit.
func add(a: Int, b: Int) -> Int:
return a + b
func factorial(n: Int) -> Int:
if n <= 1:
return 1
return n * factorial(n - 1)
print(add(2, 3))
print(factorial(5))Exercise: add a function that accepts two Int values and returns the larger one. Add assertions for two boundary cases.
4. Organize data with classes
Class fields have explicit types, and constructors use field names. A field with a default can be omitted; unknown or missing required fields are errors.
class Hero:
let name: String
var health: Int = 100
func heal(amount: Int) -> Unit:
health += amount
func describe() -> String:
return name + " (" + health.toString() + " hp)"
# Class construction uses named fields only.
let hero = Hero(name="Ada", health=80)
hero.heal(15)
print(hero.describe())Exercise: add an area method to Rectangle. Check that an invalid field type is rejected before the program runs.
5. Collections and a small transformation
List[T] is read-only; use MutableList[T] for a phase that changes elements. Explicit conversion creates a new outer collection. This word counter shows a complete small transformation from text to ordered output.
# Word counting: mutable maps, split/trim, loops, sorting.
func count_words(text: String) -> Map[String, Int]:
let counts: MutableMap[String, Int] = {}
for raw_word in text.split(" "):
let word = raw_word.trim().toLowerCase()
if word.isEmpty():
continue
let current = counts.get(word)
if current == null:
counts.set(word, 1)
else:
counts.set(word, current + 1)
return counts.toMap()
let text = "the quick brown fox jumps over the lazy dog the fox"
let counts = count_words(text)
for word in counts.keys():
let count = counts.get(word)
if count != null:
print(word + ": " + count.toString())
let words = counts.keys().toMutableList()
words.sort()
print("sorted keys: " + words.toString())Exercise: make counting case-insensitive and explain how punctuation is handled. Full natural-language tokenization calls for a dedicated text library.
6. Put failures in the type
Functions that can fail declare throws. Callers must catch the error or declare that effect too; failure is not silently replaced by a default value.
# Recoverable failures use typed errors: func ... throws T, throw, try/catch.
func checkout(quantity: Int) -> Int throws Error:
if quantity <= 0:
throw Error("quantity must be positive")
return quantity * 3
try:
print(checkout(4))
catch problem: Error:
print("rejected: " + problem.message)
try:
print(checkout(0))
catch problem: Error:
print("rejected: " + problem.message)Exercise: represent a missing configuration key as an Error, then catch it at the command-line boundary and print useful context.
7. Variants, match and nullability
variant represents a finite set of cases. match must cover every case, and a case binding retains its payload's static type. Nullable values must be checked and narrowed before use.
variant Expr:
Literal(value: Int)
Add(left: Expr, right: Expr)
enum Mode:
Fast
Careful
# match must cover every case; there is no default branch.
func eval(expr: Expr) -> Int:
match expr:
case Expr.Literal as node:
return node.value
case Expr.Add as node:
return eval(node.left) + eval(node.right)
func label(mode: Mode) -> String:
match mode:
case Mode.Fast:
return "fast"
case Mode.Careful:
return "careful"
let tree = Expr.Add(
left=Expr.Literal(value=20),
right=Expr.Literal(value=22)
)
print(eval(tree))
print(label(Mode.Careful))# T? marks expected absence. A checked value narrows to non-null inside the branch.
func find(words: List[String], target: String) -> String?:
for word in words:
if word == target:
return word
return null
let found = find(["alpha", "beta"], "beta")
if found != null:
print("found " + found)
else:
print("not found")
let missing = find(["alpha"], "gamma")
print(missing == null)Exercise: add a Negate case to an expression AST, then check that every visitor handles it.
8. Build a local Task Tracker
Now combine the types, collections, errors, and file/JSON APIs from earlier chapters into a local command-line task list. It reads and writes UTF-8 JSON and uses no network service. Create a project:
sprig init task-tracker
cd task-tracker
# Save the source below as src/main.spr
sprig resolve
sprig run -- add "Read the Sprig tutorial"
sprig run -- add "Build a small tool"
sprig run -- list
sprig run -- done 1
sprig run -- listHere is the complete source. It stores data in tasks.json in the current working directory; set SPRIG_TASKS_FILE to choose another local file. JSON decoding checks field types, and file operations use the standard library's UTF-8 API.
import "@std/files.spr" as files
import "@std/json.spr" as json
import "@std/process.spr" as process
import java.io.IOException as IOException
class Task:
let id: Int
let title: String
let done: Bool
func data_path() -> String:
let configured = process.environment("SPRIG_TASKS_FILE")
if configured != null:
return configured
return "tasks.json"
func required_field(value: json.Value, name: String) -> json.Value throws Error:
match json.find_member(value, name):
case json.Lookup.Found as found:
return found.value
case json.Lookup.Missing:
throw Error("task is missing field " + name)
case json.Lookup.NotObject:
throw Error("task must be a JSON object")
func as_int(value: json.Value) -> Int throws Error:
match value:
case json.Value.Number as number:
let parsed = number.text.toIntOrNull()
if parsed != null:
return parsed
throw Error("task id must be an integer")
case json.Value.Null:
throw Error("task id must be an integer")
case json.Value.Boolean:
throw Error("task id must be an integer")
case json.Value.Text:
throw Error("task id must be an integer")
case json.Value.Array:
throw Error("task id must be an integer")
case json.Value.Object:
throw Error("task id must be an integer")
func as_text(value: json.Value) -> String throws Error:
match value:
case json.Value.Text as text:
return text.value
case json.Value.Null:
throw Error("task title must be text")
case json.Value.Boolean:
throw Error("task title must be text")
case json.Value.Number:
throw Error("task title must be text")
case json.Value.Array:
throw Error("task title must be text")
case json.Value.Object:
throw Error("task title must be text")
func as_bool(value: json.Value) -> Bool throws Error:
match value:
case json.Value.Boolean as boolean:
return boolean.value
case json.Value.Null:
throw Error("task done must be Boolean")
case json.Value.Number:
throw Error("task done must be Boolean")
case json.Value.Text:
throw Error("task done must be Boolean")
case json.Value.Array:
throw Error("task done must be Boolean")
case json.Value.Object:
throw Error("task done must be Boolean")
func decode_task(value: json.Value) -> Task throws Error:
return Task(
id=as_int(required_field(value, "id")),
title=as_text(required_field(value, "title")),
done=as_bool(required_field(value, "done"))
)
func load_tasks() -> List[Task] throws Error, IOException:
let path = data_path()
if not files.exists(path):
return []
let document = json.parse(files.read_utf8(path))
var rows: List[json.Value] = []
match document:
case json.Value.Array as array:
rows = array.values
case json.Value.Null:
throw Error("task file must contain a JSON array")
case json.Value.Boolean:
throw Error("task file must contain a JSON array")
case json.Value.Number:
throw Error("task file must contain a JSON array")
case json.Value.Text:
throw Error("task file must contain a JSON array")
case json.Value.Object:
throw Error("task file must contain a JSON array")
let tasks: MutableList[Task] = []
for value in rows:
tasks.append(decode_task(value))
return tasks.toList()
func save_tasks(tasks: List[Task]) -> Unit throws Error, IOException:
let values: MutableList[json.Value] = []
for task in tasks:
values.append(json.Value.Object(members=[
json.Member(key="id", value=json.Value.Number(text=task.id.toString())),
json.Member(key="title", value=json.Value.Text(value=task.title)),
json.Member(key="done", value=json.Value.Boolean(value=task.done))
]))
let body = json.stringify(json.Value.Array(values=values.toList())) + "\n"
files.write_utf8(data_path(), body)
func usage() -> Unit:
print("Usage: sprig run -- list | add <title> | done <id>")
print("Tasks are stored in ./tasks.json")
let args = process.arguments()
try:
if args.size() == 0:
usage()
elif args.get(0) == "list":
for task in load_tasks():
var marker = " "
if task.done:
marker = "x"
print(task.id.toString() + " [" + marker + "] " + task.title)
elif args.get(0) == "add" and args.size() == 2:
let tasks = load_tasks()
let task = Task(id=tasks.size() + 1, title=args.get(1), done=false)
let updated: MutableList[Task] = tasks.toMutableList()
updated.append(task)
save_tasks(updated.toList())
print("Added " + task.id.toString() + ": " + task.title)
elif args.get(0) == "done" and args.size() == 2:
let raw_id = args.get(1).toIntOrNull()
if raw_id == null:
throw Error("task id must be an integer")
let tasks = load_tasks()
let updated: MutableList[Task] = []
var found = false
for task in tasks:
if task.id == raw_id:
updated.append(Task(id=task.id, title=task.title, done=true))
found = true
else:
updated.append(task)
if not found:
throw Error("no task with id " + raw_id.toString())
save_tasks(updated.toList())
print("Completed " + raw_id.toString())
else:
usage()
throw Error("invalid command")
catch problem: IOException:
throw Error("file operation failed: " + problem.message)
catch problem: Error:
throw problemThis is suitable for learning and small single-process data, not coordinated concurrent writers. The repository's matching source and independent runtime check verify add, list, done and malformed JSON behavior.
9. Ask the compiler for evidence
When checking fails, read the stable diagnostic instead of guessing about types. This example deliberately puts text in an Int:
let count: Int = "three"The expected code is SPR-TYPE-ASSIGN, with expected type Int and actual type String. Use sprig check --json <file> for machine-readable locations and types, and sprig explain SPR-TYPE-ASSIGN --json for repair guidance. Fix the source and check again; do not let a tool insert a conversion that could change the program's meaning.
let count: Int = 3A concise agent workflow is: read AGENTS.md and the current reference → run sprig capabilities --json → make the smallest change → run sprig check --json / sprig test → review the diff. Whether a command is available depends on the installed version's capability output.
10. Call ordinary Java APIs
Sprig targets the JVM and can explicitly import supported Java classes and members. When a signature is unclear, query the local JDK rather than guessing an overload:
sprig api java.time.LocalDate --jsonThen read the real interop example and the JVM interoperability guide. Gradle, Maven or Loom still owns complex framework classpaths. Sprig owns application logic; a small Java adapter can bridge API shapes not yet supported directly.
Where to go next
- Language tour: quick lookup for syntax and implementation boundaries.
- Projects and dependencies: local packages, Git/Maven dependencies and lockfiles.
- Example projects: continue from Task Tracker to JSON CLI, SQLite, Web and Maven.
- Agent workflow and diagnostics reference: query tools, stable diagnostics and reproducible repairs.
- Feature status and known limitations: review implemented capabilities and explicit limits.
