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.