Back to flin
flin

The None Handling Bug

A null safety edge case that slipped through type inference and crashed at runtime.

Thales & Claude | March 25, 2026 8 min flin
flinbugnonenull-safetytype-systemdebugging

Some bugs block entire features. They sit at a chokepoint in the system's architecture, and until they are resolved, everything downstream remains unusable. The None handling bug was one of these. It was small -- ten lines of code to fix -- but it stood between us and the entire temporal model.

On January 7, 2026, we had 27 temporal integration tests. All 27 were failing. The temporal access operator (@) had been implemented. The version history storage worked. The database correctly maintained entity versions. But the moment you tried to use any of it in real code, the VM threw a TypeError and halted.

The Problem

FLIN's temporal model allows you to access previous versions of an entity using the @ operator:

flinuser = User { name: "Alice" }
save user

user.name = "Bob"
save user

previous = user @ -1      // Get the previous version

The @ operator with a negative offset retrieves earlier versions. user @ -1 means "the version before the current one." When only one version exists, user @ -1 correctly returns None -- there is no previous version.

The bug manifested when you tried to access a property on that None value:

flinprevious = user @ -1           // Returns None (only 1 version exists)
<div>{previous.name}</div>     // TypeError: expected object, found none

This is the null reference problem that has plagued programming languages since Tony Hoare's "billion-dollar mistake." In FLIN's case, the VM's GetField opcode assumed it would always receive an object. When it received None, it threw a type error.

Why This Blocked Everything

The temporal model is fundamentally about querying versions that may or may not exist. Every temporal query can return None:

  • user @ -1 returns None if there is no previous version
  • user @ yesterday returns None if the entity did not exist yesterday
  • user @ -10 returns None if there are fewer than 10 versions

If property access on None throws an error, then every temporal query requires an explicit null check before accessing any field:

flin// Without None propagation: verbose and repetitive
previous = user @ -1
{if previous}
    <div>{previous.name}</div>
{/if}

// For multiple fields: exponentially worse
previous = user @ -1
{if previous}
    <div>
        Name: {previous.name}
        Email: {previous.email}
        Role: {previous.role}
    </div>
{/if}

This pattern would need to wrap every single temporal access in every template. For a feature designed to make time-travel queries elegant, requiring boilerplate null checks everywhere would defeat the purpose entirely.

The Fix

The fix was remarkably simple. We modified two opcodes in the VM: GetField and GetFieldDyn.

rust// OpCode::GetField (src/vm/vm.rs:1484-1494)
if let Value::Object(id) = obj {
    // Normal path: access field on object
    self.push(value)?;
} else if obj == Value::None {
    // NEW: Propagate None instead of throwing error
    self.push(Value::None)?;
} else {
    return Err(RuntimeError::TypeError {
        expected: "object or none",
        found: obj.type_name().to_string(),
    });
}

The same pattern was applied to GetFieldDyn for dynamic property access. When the VM encounters a field access on None, it simply pushes None onto the stack instead of throwing an error.

After the fix:

flinuser = User { name: "Alice" }
save user

previous = user @ -1           // Returns None
<div>{previous.name}</div>     // Returns None (rendered as empty string)

No error. No crash. The template renders, and the None value displays as nothing -- which is exactly the correct behavior when there is no previous version.

The Design Decision

We considered two approaches to None handling.

Option A: Implicit None Propagation -- The approach we chose. None.property returns None, allowing values to flow through chains of property access without interruption. This mirrors JavaScript's optional chaining (null?.property), TypeScript's safe navigation, and Rust's Option::and_then().

Option B: Explicit Checks -- Requiring developers to guard every access with an if block. This is more explicit but dramatically more verbose, especially in templates where temporal queries are most common.

flin// Option A: Clean and intuitive
<div>
    Current: {user.name}
    Previous: {(user @ -1).name}
</div>

// Option B: Verbose and repetitive
<div>
    Current: {user.name}
    {if user @ -1}
        Previous: {(user @ -1).name}
    {/if}
</div>

We chose Option A for several reasons. First, FLIN is designed for rapid development, and requiring null checks for every temporal access contradicts that goal. Second, temporal queries naturally produce None in many valid scenarios -- it is not an exceptional case but a normal one. Third, the behavior matches what developers expect from modern languages.

The trade-off is that None propagation can mask bugs. If a developer accesses user.nmae (a typo) and gets None instead of an error, the typo might go unnoticed. We accepted this trade-off because FLIN's type checker catches field name errors at compile time. The None propagation only affects runtime values that are legitimately None, not field name mistakes.

Testing the Fix

We verified the fix with a dedicated test file:

flinentity User { name: text }
user = User { name: "Alice" }
save user

previous = user @ -1           // Returns None
result = previous.name         // Returns None (no error!)

print(user.name)      // Alice
print(previous)       // none
print(result)         // none

All 1,005 library tests continued to pass. No regressions. The fix was purely additive -- it handled a case that previously threw an error, without changing the behavior of any case that previously succeeded.

The Broader Pattern: None Propagation

The fix we implemented is the first step toward a broader None propagation pattern in FLIN:

None.property   -> None    (implemented)
None + 5        -> None    (future enhancement)
len(None)       -> None    (future enhancement)
None.method()   -> None    (future enhancement)

Each of these extensions follows the same principle: when None flows into an operation, the operation produces None instead of an error. This creates a "None channel" through which absent values can propagate through arbitrary chains of computation without interrupting execution.

The pattern is particularly powerful for temporal queries that chain multiple accesses:

flin// Deep property chain with temporal access
old_address = (user @ -3).profile.address.city
// If user @ -3 is None, the entire chain returns None
// No error at any step

Without None propagation, this would require nested null checks four levels deep. With it, the developer writes exactly what they mean and gets None if any link in the chain is absent.

Impact on the Temporal Model

Before this session, the temporal model was theoretically complete but practically unusable. The version history storage worked. The @ operator worked. The database correctly maintained versions. But the moment you tried to display temporal data in a template -- the primary use case -- the VM crashed.

After this session, basic temporal queries worked end-to-end:

flinentity User { name: text }

user = User { name: "Alice" }
save user                        // version 1

user.name = "Alice Smith"
save user                        // version 2

previous = user @ -1             // { name: "Alice" }
original = user @ -2             // None (no version 0)
current = user @ 0               // { name: "Alice Smith" }

<div>
    Current: {user.name}
    Previous: {previous.name}
</div>

The 10-line fix unblocked 27 integration tests, of which 4 immediately started passing. The remaining 23 were blocked by a separate HTML whitespace rendering issue (addressed in the next article), not by temporal logic errors.

The Power of Small, Strategic Fixes

This session exemplifies a principle we have observed repeatedly during FLIN's development: the most impactful fixes are often the smallest. The None handling bug blocked an entire subsystem -- temporal access, version history, change detection -- and the fix was adding a single conditional branch to two opcodes.

The key was identifying that this small fix sat at a critical architectural chokepoint. The GetField opcode is one of the most frequently executed instructions in the VM. Every property access, every template binding, every field read goes through it. A small change there radiates outward to affect every part of the system.

Ten lines of code. One hour of work. An entire feature subsystem unblocked. Sometimes the most valuable thing a developer can do is not build something new, but remove a single obstacle that was preventing everything else from working.


This is Part 158 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO designed and built a programming language from scratch.

Series Navigation: - [157] The For-Loop Iteration Bug - [158] The None Handling Bug (you are here) - [159] The HTML Whitespace Rendering Bug

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles