Skip to main content

TypeScript undefined Is Not a Query Plan

· 4 min read
Philip Z
Architect

TypeScript can describe optional data precisely at compile time, but JavaScript still has several runtime forms of absence: undefined, null, a missing object key, and an empty collection.

For database-backed models, a missing key has another possible meaning: the query did not select the field.

TeaQL's generated TypeScript E expressions preserve Value, Null, and NotLoaded so that undefined does not silently stand in for an incomplete query plan.

Same Property Value, Different Evidence

These objects both expose an absent name, but they were built from different records:

const partial = new Task({id: '99'});
const loadedNull = new Task({id: '100', name: undefined});

The generated model tracks which keys were supplied. E expressions use that load state:

import {E, TeaQLNotLoadedError} from './generated/E';

if (E.task(loadedNull).name().eval() !== undefined) {
throw new Error('expected a loaded null-like value');
}

try {
E.task(partial).name().eval();
throw new Error('expected NotLoaded');
} catch (error) {
if (!(error instanceof TeaQLNotLoadedError)) throw error;
console.log(error.accessPath); // name
}

The public result for a loaded Null is undefined, which fits normal TypeScript calling code. The internal state remains distinct from a missing preload.

Fallback Cannot Manufacture Evidence

This is a useful UI rule when Name was selected:

const label = E.task(task).name().orElse('Unnamed');

It must not apply when Name was never loaded. TeaQL's orElse evaluates the expression strictly and propagates TeaQLNotLoadedError before considering the fallback.

The same rule applies to hasValue(). An unloaded field is not a value that happens to be absent; it is a violated query contract.

Typed Results from Node or Full-Stack Clients

Generated requests provide load-aware entity methods:

const tasks = await Q.tasks()
.withNameIs('Hello')
.comment('Read the task for display')
.purpose('Render the task detail page')
.executeEntitiesForList(ctx);

const name = E.task(tasks[0]).name().eval();

executeEntitiesForList and executeEntityForOne construct generated models from the returned row keys. This works with the generated Node database clients and with the client-facing request abstraction.

Raw query results remain available when an application needs aggregation rows or a custom shape. Typed mapping is an explicit choice for model-aware E evaluation.

Structured Errors Instead of Late UI Bugs

An unloaded expression throws an error with a structured payload:

{
"error": "TeaQLNotLoadedError",
"root": "Task(id=99)",
"accessPath": ["name"],
"breakPoint": "name",
"missingPreload": ["name"],
"suggestedFix": "selectName(...)",
"severity": "error"
}

Without this distinction, an incomplete backend projection can travel through JSON, render as an empty label, and be discovered only by a user. With E expressions, the component or integration test fails at the first invalid access.

For nested chains and reverse lists, the generated wrappers retain the path through operations such as first(), get(index), and child-field access. The diagnostic points to the breakpoint rather than merely reporting that some value was undefined.

Why AI Agents Benefit

TypeScript is highly productive for AI coding because compilation catches many shape errors. Runtime data completeness is harder: the type may say a property exists even though a particular projection did not return it.

TeaQL supplies the missing runtime contract. When an integration test fails, an agent receives the root, path, breakpoint, and suggested selection. It can read the generated request source, apply the exact preload method, and rerun the test. There is little room—or need—for API-name hallucination.

What We Verified

The generated TypeScript project was compiled with Node 22 and executed with the generated runtime. Tests covered scalar and relation-ID evaluation, reverse list traversal, loaded null-like values, partial entities, fallback propagation, and typed query-to-entity mapping.

The overall code-generator regression completed with 109 tests, zero failures, and zero errors. Environment-dependent database cases were left as explicit conditional skips. Separate database-reader tests in Java and .NET verified the same selected-NULL contract at the SQL boundary.

undefined remains useful in TypeScript application code. It just should not be allowed to erase the difference between “there is no value” and “we never loaded enough data to know.”