Skip to main content

Go E Expressions: Make Missing Preloads an Explicit Error

· 4 min read
Philip Z
Architect

Go programmers are comfortable with the comma-ok idiom:

value, ok := lookup(key)

That shape is useful for an E expression too, but one boolean cannot explain why a value is absent. In data-access code, there is a critical difference between a loaded null-like value and a field the query never selected.

TeaQL's generated Go E expressions preserve three states and provide two evaluation styles: TryEval() for explicit error handling and Eval() for strict fail-fast business logic.

When present == false Is Not Enough

This looks reasonable:

name, present := E.Task(task).Name().Eval()
if !present {
name = "Unnamed"
}

It is correct when name was loaded and legitimately absent. It is wrong when the task came from an ID-only projection. In the latter case, the fallback fabricates a business value from missing evidence.

TeaQL treats these cases differently:

StateGo result
Value(value, true, nil) from TryEval()
Null/missing path(zeroValue, false, nil)
NotLoaded(zeroValue, false, TeaQLNotLoadedError)

The strict Eval() method has the familiar two-result shape, but panics if the third state is NotLoaded.

Explicit Handling with TryEval

At an application boundary, callers can inspect the error directly:

name, present, err := E.Task(task).Name().TryEval()
if err != nil {
var notLoaded TeaQLNotLoadedError
if errors.As(err, &notLoaded) {
return fmt.Errorf(
"query did not load %s at %s: %w",
notLoaded.BreakPoint,
notLoaded.AccessPath,
err,
)
}
return err
}
if !present {
name = "Unnamed"
}

Inside invariant-heavy business logic, Eval() is intentionally stricter:

name, present := E.Task(task).Name().Eval()

If Name was not loaded, the panic contains the root entity, access path, breakpoint, suggested selection, and a human-readable explanation.

Fallback Does Not Hide a Query Bug

The generated OrElse operation first performs strict evaluation:

label := E.Task(task).Name().OrElse("Unnamed")

It returns the fallback only for a legitimate missing value. If the field was not loaded, the same structured error propagates. This rule prevents a common failure mode in which defensive code makes an incomplete query appear valid.

Load State Comes from Records

Generated Go entities record which keys were present when FromRecord mapped a database or transport record:

task := task.NewTask()
if err := task.FromRecord(record); err != nil {
return err
}

A key present with a null-like database value is loaded. A key absent from the record is NotLoaded. New entities are fully available for ordinary construction, while MarkLoadedOnly supports precise partial-entity tests:

partial := task.NewTask().UpdateId(99).MarkLoadedOnly("id")

_, _, err := E.Task(partial).Name().TryEval()
if err == nil {
t.Fatal("expected an unloaded-field error")
}

The same state is propagated through foreign-key ID accessors and generated reverse-list expressions such as Size, First, and Get.

A Better Failure for Humans and AI Agents

A generic panic such as interface conversion: interface {} is nil provides almost no repair guidance. TeaQLNotLoadedError is a query-contract diagnostic:

TeaQLNotLoadedError:
root=Task(id=99)
access_path=name
break_point=name
suggested_fix=SelectName(...)

That structure matters during test-driven development. A developer immediately knows which request to review. An AI coding agent can read the generated request source, add the exact selection, and rerun the failing test without guessing a method name.

Verification

The generated Go workspace was compiled and executed against the TeaQL Go runtime used by the integration environment. Tests covered scalar evaluation, foreign-key IDs, reverse-list traversal, TryEval, strict panic behavior, and the rule that OrElse cannot hide NotLoaded.

The complete code-generator regression finished with 109 tests, zero failures, and zero errors. Conditional tests that require additional database connection variables remained skipped rather than being counted as passes.

Go's preference for explicit errors fits this design well. The important part is not whether an application chooses TryEval or strict Eval; it is that the runtime never collapses “we do not know” into “the value is empty.”