Skip to content

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.

sh
sprig init hello
cd hello
sprig resolve
sprig run

Expected output: Hello, Sprig!. Here is that program's actual source:

spr
# 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.

spr
# 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.

spr
# 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.

spr
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.

spr
# 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.

spr
# 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.

spr
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))
spr
# 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:

sh
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 -- list

Here 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.

spr
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 problem

This 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:

spr
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.

sprig
let count: Int = 3

A 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:

sh
sprig api java.time.LocalDate --json

Then 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 ​

Last updated:

Sprig is licensed under Apache-2.0 · v0.5.0-beta.1 published as a prerelease