Jac Basics#
This tutorial covers the core syntax and concepts you need to start writing Jac programs. If you're coming from Python, most things will look familiar -- Jac is a superset of Python, so your existing knowledge applies directly. The key differences are syntactic (braces instead of indentation, semicolons to end statements) and conceptual (graph-native types, the has keyword for fields, with entry for entry points).
By the end of this tutorial, you'll be comfortable writing functions, objects, control flow, imports, and simple graph operations in Jac.
Prerequisites
- Completed: Installation
- Familiar with: Python basics
- Time: ~30 minutes
Jac is a Superset of Python#
Jac supersets Python with new paradigms -- familiar Python concepts all apply. The relationship is similar to TypeScript and JavaScript: everything valid in the base language works, and the superset adds new capabilities on top. The main syntactic differences from Python are:
| Python | Jac |
|---|---|
| Indentation blocks | { } braces |
| No semicolons | ; required |
def func(): |
def func() { } |
if x: |
if x { } |
class Foo: |
obj Foo { } |
Variables and Types#
Jac supports all the same primitive types as Python (str, int, float, bool) and the same collection types (list, dict, set, tuple). Type annotations are optional but recommended -- they enable better IDE support, catch errors during jac check, and make your code self-documenting.
The with entry { } block is Jac's equivalent of Python's if __name__ == "__main__": -- it defines the program's entry point and runs when you execute the file with jac run.
Basic Variables#
with entry {
# Type inference (like Python)
name = "Alice";
age = 30;
pi = 3.14159;
active = True;
# Explicit type annotations (recommended)
name: str = "Alice";
age: int = 30;
pi: float = 3.14159;
active: bool = True;
}
Collections#
with entry {
# Lists
numbers: list[int] = [1, 2, 3, 4, 5];
numbers.append(6);
# Dictionaries
person: dict[str, any] = {
"name": "Alice",
"age": 30
};
# Sets
unique: set[int] = {1, 2, 3};
# Tuples
point: tuple[int, int] = (10, 20);
}
Functions#
Functions in Jac use def just like Python, with the body enclosed in braces instead of an indented block. Return type annotations use -> Type syntax. Jac also has a can keyword for event-triggered abilities on nodes and walkers (covered later in the OSP tutorial), but for regular standalone functions and methods on objects, use def.
Basic Functions#
def greet(name: str) -> str { return f"Hello, {name}!"; }
def add(a: int, b: int) -> int { return a + b; }
with entry { message = greet("World"); print(message); result = add(5, 3); print(result); }
Default Parameters#
def greet(name: str, greeting: str = "Hello") -> str {
return f"{greeting}, {name}!";
}
with entry {
print(greet("Alice")); # Hello, Alice!
print(greet("Bob", "Hi")); # Hi, Bob!
}
Methods with def#
Use def for methods inside objects:
obj Calculator {
has value: int = 0;
def add(n: int) -> int {
self.value += n;
return self.value;
}
def reset() -> None {
self.value = 0;
}
}
with entry {
calc = Calculator();
calc.add(5);
calc.add(3);
print(calc.value); # 8
}
Control Flow#
If/Elif/Else#
def classify_number(n: int) -> str {
if n < 0 {
return "negative";
} elif n == 0 {
return "zero";
} else {
return "positive";
}
}
with entry {
print(classify_number(-5)); # negative
print(classify_number(0)); # zero
print(classify_number(10)); # positive
}
For Loops#
with entry {
# Iterate over list
for item in [1, 2, 3] {
print(item);
}
# Range-based loop
for i in range(5) {
print(i); # 0, 1, 2, 3, 4
}
# Enumerate
names = ["Alice", "Bob", "Carol"];
for (i, name) in enumerate(names) {
print(f"{i}: {name}");
}
}
While Loops#
Match (Pattern Matching)#
Match/case uses Python-style indentation
Match case bodies use case X: with indentation, not braces. This is the one exception to Jac's brace-based block syntax.
Match case bodies use Python-style indentation, not braces:
def describe(value: int) -> str {
match value {
case 0:
return "zero";
case 1 | 2 | 3:
return "small";
case _:
return "medium";
}
}
with entry {
print(describe(0)); # zero
print(describe(2)); # small
print(describe(50)); # medium
}
Objects (Classes)#
Jac uses obj instead of Python's class. Fields are declared with has (replacing Python's __init__ boilerplate), and constructors are auto-generated from has declarations. This means you get dataclass-like convenience by default -- named fields, automatic __init__, and default values -- without extra decorators. Methods use def with implicit self, just like Python.
Basic Object Definition#
obj Person {
has name: str;
has age: int;
has email: str = ""; # default value
def introduce() -> str {
return f"I'm {self.name}, {self.age} years old.";
}
def have_birthday() -> None {
self.age += 1;
}
}
with entry {
alice = Person(name="Alice", age=30);
print(alice.introduce()); # I'm Alice, 30 years old.
alice.have_birthday();
print(alice.age); # 31
}
Inheritance#
obj Animal {
has name: str;
def speak() -> str {
return "...";
}
}
obj Dog(Animal) {
has breed: str = "Unknown";
def speak() -> str {
return "Woof!";
}
}
obj Cat(Animal) {
def speak() -> str {
return "Meow!";
}
}
with entry {
dog = Dog(name="Buddy", breed="Labrador");
cat = Cat(name="Whiskers");
print(f"{dog.name} says {dog.speak()}"); # Buddy says Woof!
print(f"{cat.name} says {cat.speak()}"); # Whiskers says Meow!
}
Enums#
enum Color {
RED,
GREEN,
BLUE
}
enum Status {
PENDING = "pending",
ACTIVE = "active",
COMPLETED = "completed"
}
with entry {
color = Color.RED;
status = Status.ACTIVE;
if color == Color.RED {
print("It's red!");
}
print(status.value); # active
}
Error Handling#
def divide(a: float, b: float) -> float {
if b == 0 {
raise ValueError("Cannot divide by zero");
}
return a / b;
}
with entry {
try {
result = divide(10, 0);
} except ValueError as e {
print(f"Error: {e}");
} finally {
print("Done");
}
}
Importing#
Python Libraries#
From Imports#
import from collections { Counter, defaultdict }
import from typing { Optional, List }
with entry {
counts = Counter(["a", "b", "a", "c", "a"]);
print(counts["a"]); # 3
}
Jac Modules#
# utils.jac
def helper() -> str {
return "I'm a helper";
}
# main.jac
import from utils { helper }
with entry {
print(helper());
}
Global Variables#
The glob keyword declares module-level variables that are accessible from any function in the file. This is Jac's equivalent of Python's module-level variables, but made explicit with a keyword so you can immediately distinguish global state from local variables when reading code.
glob config: dict = {
"debug": True,
"version": "1.0.0"
};
def get_version() -> str {
return config["version"];
}
with entry {
print(get_version()); # 1.0.0
}
Abilities with can#
Inside archetypes like node, obj, and walker, you can define abilities using the can keyword. Abilities are methods that respond to events -- they fire when a walker enters or exits a node:
For regular methods that don't need event triggers, use def inside objects (as shown above). The distinction:
| Keyword | Use For | Example |
|---|---|---|
def |
Regular methods on objects | def calculate() -> int { ... } |
can |
Event-triggered abilities on nodes/walkers | can greet with entry { ... } |
You'll learn more about can in the OSP tutorial.
Access Modifiers#
When you deploy a Jac application as a server (with jac start), access modifiers control which functions and walkers become HTTP endpoints and how authentication is handled. This is one of Jac's most distinctive features: instead of manually defining API routes with decorators (like Flask's @app.route), you simply annotate your functions with :pub or :priv and the framework automatically generates REST endpoints with the right authentication behavior.
# Public endpoint -- auto-generates an HTTP API
def:pub add_task(title: str) -> dict { ...; }
# Private -- requires authentication, per-user data isolation
def:priv get_tasks -> list { ...; }
# Protected -- accessible within the module
def:protect helper -> None { ...; }
| Modifier | Visibility | Use Case |
|---|---|---|
def:pub |
Public HTTP endpoint | APIs anyone can call |
def:priv |
Authenticated endpoint | Per-user data isolation |
def:protect |
Module-internal | Helper functions |
def |
Default (module-level) | Regular functions |
These modifiers also apply to walkers (walker:pub, walker:priv).
Preview: Nodes and Graphs#
Jac's most distinctive feature is its graph-native type system. Beyond regular objects (obj), Jac provides node, edge, and walker types that live in an in-memory graph. Nodes hold data, edges define relationships between them, and walkers traverse the graph executing logic at each step. Here's a quick preview before the full OSP tutorial:
# A node is like an object that can live in a graph
node Task {
has title: str;
has done: bool = False;
}
with entry {
# Connect nodes to the built-in root node
root ++> Task(title="Buy groceries");
root ++> Task(title="Write code");
# Query connected nodes
tasks = [root-->](?:Task);
for t in tasks {
print(t.title);
}
}
Key differences from obj:
nodeinstances can be connected in a graph with++>rootis a built-in starting node -- nodes connected to it persist across restarts[root-->]queries all outgoing connections from root(?:Task)filters by type
Key Takeaways#
| Concept | Python | Jac |
|---|---|---|
| Blocks | Indentation | { } braces |
| Statements | No semicolons | ; required |
| Classes | class |
obj |
| Methods | def inside class |
def inside obj |
| Abilities | N/A | can with event triggers |
| Attributes | In __init__ |
has declarations |
| Entry point | if __name__ == "__main__" |
with entry { } |
| Module variables | Global vars | glob keyword |
| Graph data types | N/A | node, edge |
| Public APIs | Flask routes | def:pub |
Next Steps#
- Object-Spatial Programming - Learn nodes, edges, and walkers
- Testing - Write tests for your Jac code