Skip to main content

Rust Option Is Not Enough for Partially Loaded Entities

· 4 min read
Philip Z
Architect

Rust's Option<T> is one of the language's best tools. It forces absence into the type system and eliminates an entire class of null-pointer failures.

But an ORM or data runtime has another state that Option<T> cannot express on its own: the field was not loaded.

If both SQL NULL and an unselected column become None, business logic cannot tell a legitimate absence from an incomplete query. TeaQL Rust models the missing state explicitly with EvalResult::Value, EvalResult::Null, and EvalResult::NotLoaded.

Absence and Ignorance Are Different

Suppose an order may legitimately have no cancellation reason. This is a normal nullable value:

match order.eval_cancellation_reason() {
EvalResult::Null => println!("The order was not cancelled"),
EvalResult::Value(reason) => println!("Cancelled: {reason}"),
EvalResult::NotLoaded { .. } => unreachable!("query contract violation"),
}

Now suppose the query selected only the order ID. Returning None for the same access would be false information: the runtime does not know whether a reason exists.

TeaQL preserves that distinction until evaluation.

StateWhat the runtime knowsStrict E behavior
Value(value)Selected and presentreturns the value
NullSelected and absentevaluates to None
NotLoadedNot selectedstructured panic at the boundary

Fluent E Expressions Without Cloning the Graph

The generated E API uses reference-bound wrappers, so a deep traversal does not clone the entity graph:

let merchant_name = E::merchant(&merchant)
.get_name()
.eval();

The result is Some(name) for Value and None for a loaded Null. If a required relation or field is NotLoaded, the terminating operation panics with a structured logic-bug diagnostic.

For a fallback that applies only to Null, the API is deliberately explicit:

let label = E::merchant(&merchant)
.get_name()
.or_if_null("Anonymous".to_string());

or_if_null, or_else_if_null, and or_default_if_null do not catch NotLoaded. A default value must never turn an incomplete query into a plausible business result.

Why Panic Is Appropriate Here

This is not an operational failure such as a timeout or unavailable database. It is a programming error: code accessed data outside the declared query contract.

At an application boundary, silently converting that error to None is more dangerous than stopping the test. A strict panic makes the defect deterministic and places it next to the expression that made the invalid assumption.

The diagnostic identifies:

  • the root entity and identity;
  • the complete attempted access path;
  • the field or relation where traversal stopped;
  • the missing preload;
  • a query-builder correction to review.

This is especially useful in generated APIs because the repair can refer to real generated selection methods instead of asking a coding agent to invent one.

The Test Becomes a Query-Completeness Check

A conventional test often verifies only the final value. A TeaQL E-expression test also verifies that the value was obtained from data the query explicitly requested.

#[test]
fn display_name_requires_the_selected_field() {
let expression = ValueExpression::<'static, String>::new(
EvalResult::NotLoaded {
failed_node: "name".to_owned(),
attempted_path: "name".to_owned(),
},
std::sync::Arc::new("Merchant(id=42)".to_owned()),
);

let result = std::panic::catch_unwind(|| {
expression.or_if_null("Anonymous".to_string())
});
assert!(result.is_err());
}

After the request adds the generated display-name selection, the same test becomes evidence that both the query and the expression agree about their data contract.

Why AI Can Repair This Reliably

An AI agent is good at local transformations when the framework supplies exact evidence. It is much less reliable when a late failure could have originated in dozens of queries.

NotLoaded narrows the task:

root: Merchant(id=42)
access path: name
break point: name
suggested preload: select_name(...)

The agent can inspect the generated request type, add the corresponding selection, compile, and rerun the test. That is a bounded repair loop rather than an open-ended debugging exercise.

Cross-Language Evidence

Rust established this semantic baseline, and the same contract is now generated for Java, Go, Python, .NET, and TypeScript. The cross-language regression suite generated and compiled the language targets and finished with 109 tests, zero failures, and zero errors. Separate Java and .NET database tests verified that a selected SQL NULL remains Null rather than becoming NotLoaded.

The key lesson is broader than TeaQL: a type-safe application should model not only whether a value exists, but whether the program had enough data to ask the question in the first place.