Skip to content

Core Concepts#

Most of Jac will be recognizable if you are familiar with another programming language like Python -- Jac supersets Python, so familiar constructs like functions, classes, imports, list comprehensions, and control flow all work as expected. You can explore those in depth in the language reference.

This page focuses on the three concepts that Jac adds beyond traditional programming languages. These are the ideas the rest of the documentation builds on, introduced briefly so you have the vocabulary for the tutorials that follow. Through these concepts three important questions can be answered:

  1. How can one language target frontend, backend, and native binaries at the same time?
  2. How does Jac fully abstract away database organization and interactions and the complexity of multiuser persistent data?
  3. How does Jac abstract away the laborious task of prompt/context engineering for AI and turn it into a compiler/runtime problem?

1. How can one language target frontends, backends, and native binaries at the same time?#

Similar to namespaces, the Jac language introduces the concept of codespaces. A Jac program can contain code that runs in different environments. You denote the codespace either with a block prefix inside a file or with a file extension:

graph LR
    JAC["main.jac"] --> SV["Server (PyPI Ecosystem)
    sv { }"]
    JAC --> CL["Client (NPM Ecosystem)
    cl { }"]
    JAC --> NA["Native (C ABI)
    na { }"]

Inline blocks -- mix codespaces in a single file:

  • sv { } -- code that runs on the server (compiles to Python)
  • cl { } -- code that runs in the browser (compiles to JavaScript)
  • na { } -- code that runs natively compiled on the host machine (compiles to native binary)
  • Code outside any block defaults to the server codespace

File extensions -- set the default top-level codespace for a file, e.g., for a module prog:

  • prog.sv.jac -- top-level code defaults to server
  • prog.cl.jac -- top-level code defaults to client
  • prog.na.jac -- top-level code defaults to native
  • prog.jac -- defaults to the server codespace

Any .jac file can still use all codespace blocks regardless of its extension. The extension only changes what the default is for code outside any block.

Here's a file that uses two codespaces via inline blocks:

# Server codespace (default)
node Todo {
    has title: str, done: bool = False;
}

def:pub add_todo(title: str) -> dict {
    todo = root ++> Todo(title=title);
    return {"id": todo[0].id, "title": todo[0].title};
}

# Client codespace
cl {
    def:pub app -> JsxElement {
        has items: list = [];

        async def add -> None {
            todo = await add_todo("New");
            items = items + [todo];
        }

        return <div>
            <button onClick={lambda -> None { add(); }}>
                Add
            </button>
        </div>;
    }
}

The server definitions are visible to the cl block. When the client calls add_todo(...), the compiler generates the HTTP call, serialization, and routing between codespaces. You write one language; the compiler produces the interop layer.

Codespaces are similar to namespaces, but instead of organizing names, they organize where code executes. Interop between them -- function calls, spawn calls, type sharing -- is handled by the compiler and runtime.

obj vs class -- choosing the right archetype

Cross-codespace interop requires the compiler to fully understand your type's structure. Jac's obj is designed for this: it enforces strict, declarative semantics -- fields declared with has, auto-generated constructors, no runtime monkey-patching -- so the same definition can compile to Python, JavaScript, or native code.

If you need Python-specific class features like metaclasses, @classmethod, @property, or other decorator-heavy patterns, use a regular Python class. Those features are inherently tied to the Python runtime and cannot cross codespace boundaries. Jac provides the static keyword for static methods and fields, which covers the most common use case.


2. How does Jac fully abstract away database organization and interactions and the complexity of multiuser persistent data?#

Most languages store data in variables, objects, or database rows -- and you're responsible for the ORM, the schema, and the queries. Jac adds another option: nodes that live in a graph. You declare your data, connect it, and the runtime handles persistence automatically.

A node is declared like an obj/class, but with a superpower -- nodes can be connected to other nodes with edges, forming a graph:

node Task {
    has title: str;
    has done: bool = False;
}

with entry {
    # Create tasks and connect them to root
    root ++> Task(title="Buy groceries");
    root ++> Task(title="Team standup at 10am");
    root ++> Task(title="Go for a run");
}

The ++> operator creates a node and connects it to an existing node with an edge. Your graph now looks like:

graph LR
    root((root)) --> T1["Task(#quot;Buy groceries#quot;)"]
    root --> T2["Task(#quot;Team standup at 10am#quot;)"]
    root --> T3["Task(#quot;Go for a run#quot;)"]

Persistence through root#

Every Jac program has a built-in root node. Nodes reachable from root are persistent -- they survive process restarts. The runtime generates the storage schema from your node declarations automatically. No database setup, no ORM, no SQL.

When your app serves multiple users, each user gets their own isolated root. User A's tasks and User B's tasks live in completely separate graphs -- same code, isolated data, enforced by the runtime.

Querying the graph#

The [-->] syntax gives you a list of connected nodes, and Jac's filter comprehensions (?...) let you narrow the results:

with entry {
    # Get all nodes connected from root as a list
    everything = [root-->];

    # Filter by node type
    tasks = [root-->](?:Task);

    # Filter by field value
    pending = [root-->](?:Task, done == False);
}

Edges can also be typed with their own data, modeling relationships like schedules, dependencies, or social connections:

edge Scheduled {
    has time: str;
    has priority: int = 1;
}

with entry {
    root +>: Scheduled(time="9:00am", priority=3) :+> Task(title="Morning run");

    # Query through typed edges
    urgent = [root->:Scheduled:priority>=3:->](?:Task);
}

The key insight: instead of designing database tables and writing queries, you declare nodes and connect them. The graph is your data model, and root is the entry point. The runtime takes care of the rest.


3. How does Jac abstract away the laborious task of prompt/context engineering for AI and turn it into a compiler/runtime problem?#

Jac introduces Compiler-Integrated AI through its by and sem keywords. These two keywords allow integrating language models into programs at the language level rather than through library calls.

by -- delegate a function's implementation#

enum Category { WORK, PERSONAL, SHOPPING, HEALTH, OTHER }

def categorize(title: str) -> Category
    by llm();

This function has no body. by llm() tells the compiler to delegate the implementation to a language model. The compiler extracts semantics from the code itself -- the function name, parameter names, types, and return type -- to construct the prompt. A well-named function like categorize with a typed parameter title: str and return type Category already communicates intent.

The return type is enforced. If the return type is an enum, the LLM can only produce one of its values. If it's an obj, every field must be filled. The type annotation serves as the output contract.

sem -- attach semantics to bindings#

The compiler can only infer so much from names and types. sem is the mechanism for providing additional semantic information beyond what exists in the code. It attaches a description to a specific variable binding that the compiler includes in the prompt:

obj Ingredient {
    has name: str;
    has cost: float;
    has carby: bool;
}

sem Ingredient.cost = "Estimated cost in USD";
sem Ingredient.carby = "True if this ingredient is high in carbohydrates";

def plan_shopping(recipe: str) -> list[Ingredient]
    by llm();
sem plan_shopping = "Generate a shopping list for the given recipe.";

Without sem, the LLM has only the names cost and carby to work with. With it, the compiler includes "Estimated cost in USD" and "True if this ingredient is high in carbohydrates" in the prompt, producing more accurate structured output. The sem on plan_shopping itself provides the function-level instruction.

sem is not a comment. It's a compiler directive that attaches semantic meaning to variable bindings -- fields, parameters, functions -- and changes what the LLM sees at runtime. It is the only way to convey intent beyond what the compiler can extract from the code and values in the program.


How the Three Concepts Relate#

  1. Codespaces define where code runs -- server, client, or native
  2. OSP defines how data is structured and traversed -- nodes, edges, walkers, and persistence through root
  3. by and sem define how AI is integrated -- the compiler extracts semantics from code structure, and sem provides additional meaning where names and types aren't sufficient

In practice, these compose: walkers traverse a graph on the server, delegate decisions to an LLM via by llm(), and the results render in a client-side UI -- all within one language.


Quick Reference#

Syntax Meaning
sv { } Server codespace
cl { } Client codespace
na { } Native codespace
node X { has ...; } Declare a graph data type
root Built-in starting node (persistence anchor)
a ++> b Connect node a to node b
[a -->] Get all nodes connected from a
walker W { } Declare mobile computation
visit [-->] Move walker to connected nodes
by llm() Delegate function body to an LLM
sem X.field = "..." Semantic hint for AI understanding

Next Steps#