Typed holes
How ?name holes let agents write a Sakura Lang program incrementally, with the checker reporting what fits in each gap.
Pre-alpha
The source-built compiler runs the core language and much of M3 on JavaScript: contracts, typed effects and failures, std handlers, permissions, LSP and MCP. M3 is still in progress; no release or stable compatibility contract exists. See the roadmap for implemented features and remaining gates.
A typed hole is a placeholder for code you have not written yet. In Sakura Lang it is written ?name: a ? immediately followed by an identifier, with no space. A hole is an expression of a fresh type, so a program with holes can be checked before it is finished.
Holes turn "write the whole function and hope it compiles" into small steps, each with precise feedback.
Writing a hole
fn clamp(x: Int, lo: Int, hi: Int) -> Int
requires ordered: lo <= hi
ensures bounded: lo <= result && result <= hi
{
?body
}sakura check accepts this program and reports one hole record. sakura build and sakura run reject it with V2050, so a hole cannot reach a build by accident.
The name is for you and for tooling. Use one that says what belongs there: ?body, ?user, ?retry_policy.
? also appears as postfix raise on a Result (r?). The two never clash: ?name with no space before the identifier is always a hole, and ? after an operand is always raise.
What a hole reports
After solving the enclosing body, sakura check --json emits one hole record per hole, on its own line. The record is exactly this shape (design §12.2):
| Field | Meaning |
|---|---|
id | h: plus a hash of the name, the file's content hash, and the span. Stable across runs while the text is unchanged. |
name | The identifier after ?. |
snapshot | Hash of the workspace snapshot that was checked. |
span | Where the hole is, in the same shape as a diagnostic span, with role primary. |
expected_type | The type the hole must produce, fully solved where possible, as { "render", "structured" }. |
allowed_effects | The full effect row allowed at this point, as { "render", "row" }. Effects already used elsewhere in the body are not subtracted. |
locals | Every local in scope, innermost last, each with name and type. |
candidates | Up to 10 ranked expressions that fit, each with expr and its fit kind. |
contracts_in_scope | The refinements and contracts assumed at this point. |
For the clamp program above, saved as src/main.sakura, the record looks like this, pretty-printed, with hashes shortened:
{
"schema_version": "1.0",
"kind": "hole",
"id": "h:5d0c8e2b1a7f4396",
"name": "body",
"snapshot": "sha256:…",
"span": { "uri": "file:///…/src/main.sakura", "content_hash": "sha256:…",
"start_byte": 123, "end_byte": 128, "role": "primary" },
"expected_type": { "render": "Int", "structured": null },
"allowed_effects": { "render": "{}", "row": { "labels": [], "tail": null } },
"locals": [
{ "name": "x", "type": { "render": "Int" } },
{ "name": "lo", "type": { "render": "Int" } },
{ "name": "hi", "type": { "render": "Int" } }
],
"candidates": [
{ "expr": "x", "fit": "exact_local" },
{ "expr": "lo", "fit": "exact_local" },
{ "expr": "hi", "fit": "exact_local" },
{ "expr": "Int.min(?a, ?b)", "fit": "function" },
{ "expr": "Int.max(?a, ?b)", "fit": "function" }
],
"contracts_in_scope": ["requires ordered: lo <= hi"]
}A function candidate carries holes for its own arguments, named after its parameters, so filling the hole with it leaves smaller holes to fill next.
How candidates are ranked
- Exact fits from locals come first.
- Then constructors and functions whose result type unifies with the expected type, such as
Int.minorInt.maxhere. Each is tried in a rollback snapshot, so trying a candidate never changes the inference state. - Candidates that would need an effect outside the allowed row are filtered out.
- At most 10 candidates are reported.
A hole never affects inference of other bindings beyond its own fresh type variable. The rest of the body, including effects and contracts, is checked normally around it.
Filling a hole
Following the report, an agent fills the hole:
fn clamp(x: Int, lo: Int, hi: Int) -> Int
requires ordered: lo <= hi
ensures bounded: lo <= result && result <= hi
{
if x < lo { lo } else if x > hi { hi } else { x }
}The ensures clause is checked at runtime on every normal return, in every build mode, including --release. A wrong fill is a D021 defect blamed on clamp, not a silent wrong answer.
Narrower holes
A hole takes its expected type from context. An annotation gives a hole a type when the context does not:
fn greet(name: String) -> Unit ! {Console, Clock} {
let now: Instant = ?now
Console.print("hello, {name}; it is {Instant.format_iso(now)}")
}Here the report expects Instant, and allowed_effects is {Console, Clock}. A function such as Clock.now fits on both counts.
Holes in the MCP server
The MCP server has a hole_context(snapshot, hole_id) tool. It returns the same record for one hole, which is cheaper than re-checking the project. The M3 acceptance tests in design §14.3 require the language server, after replaying the same edits, to match hole_context byte for byte.
Recommended agent workflow
- Write every signature the task needs: types, effect rows,
Fail<...>unions, and contracts. Bodies are holes. - Run
sakura check --json. Fix signature-level errors first; these are what a human reviews. - Write tests for the behavior the task asks for.
- Fill holes one at a time, using
candidates,locals, andcontracts_in_scope. Re-check after each. - When no
holerecords remain, runsakura test --json, then summarize any signature changes for review.
The design does not yet say whether sakura test runs a program that still contains holes. Assume it does not.
Next
Back to the For Agents overview, or see the standard library.