Type systems exist to catch errors before they become runtime failures. When a type system works well, it prevents incorrect code from compiling. When it works a little too well, it prevents correct code from compiling too. The entity .get() method bug was a case of the type system doing its job perfectly -- and that perfection revealing a design flaw in the action handler.
On January 16, 2026, the todo application's delete button stopped working. Clicking "Delete" on a todo produced a compilation error:
FLIN action error: Compilation error: Type error at 471:1-25:
Type mismatch: expected Todo, found Todo?The error was clear. Something expected a Todo and received a Todo? (optional Todo). The question was: where in the pipeline was this mismatch occurring, and what was the correct fix?
The Pipeline Problem
When a user clicks the delete button on a todo, the browser sends an action request to the server. The action handler reconstructs the function call from the serialized arguments. For a delete operation on todo with ID 123, the handler generated:
removeTodo(Todo.find(123))This looks reasonable. Todo.find(123) finds the todo with that ID and passes it to removeTodo(). But the type checker caught a subtle problem.
The removeTodo function was declared as:
fn removeTodo(todo: Todo) {
delete todo
}It expects a Todo -- a guaranteed, non-null entity. But Todo.find(123) returns Todo? -- an optional that might be None if no entity with that ID exists.
The type system was correct to flag this. Passing an optional value to a function that expects a definite value is a type error. The entity might not exist. The function does not handle that case. If find returned None and the function tried to delete None, the result would be a runtime error.
The ORM Pattern: find vs. get
Every major ORM framework distinguishes between two kinds of lookup:
| Method | Returns | On Not Found |
|---|---|---|
find(id) | Entity? (optional) | Returns None / null |
get(id) | Entity (guaranteed) | Throws error / raises exception |
- Rails:
findraisesRecordNotFound,find_byreturnsnil - Django:
getraisesDoesNotExist,filterreturns empty queryset - Hibernate:
getreturnsnull,loadthrows if not found
FLIN had find but not get. The action handler needed get -- a lookup that guarantees the entity exists and returns a non-optional type. If the entity does not exist, it should be an error (the user is trying to delete something that no longer exists), not a silent None.
The Implementation
Adding .get() required changes across four layers of the system.
Type Checker
The type checker needed to know that .get() returns the base entity type, not an optional:
// src/typechecker/checker.rs:3840
return match property.as_str() {
"where" => Ok(FlinType::List(Box::new(obj_ty))),
"find" => Ok(FlinType::Optional(Box::new(obj_ty))),
"get" => Ok(obj_ty), // Returns Entity, not Entity?
_ => Ok(obj_ty),
};The distinction is small but critical: find wraps the return type in Optional, while get returns the bare type. This tells the type checker that the value from .get() is guaranteed to be non-null.
Bytecode
A new extended opcode was added for the get operation:
// src/codegen/bytecode.rs
ExtendedOpCode::QueryGet = 0x59 // In FlinDB Operations sectionVM Execution
The VM handler for QueryGet was implemented at line 6357:
ExtendedOpCode::QueryGet => {
let id = self.pop()?.as_int()?;
let entity_type = self.pop_string()?;match self.database.find(&entity_type, id as u64)? { Some(entity) => { let obj = HeapObject::from_entity(entity); let id = self.alloc(obj); self.push(Value::Object(id))?; } None => { return Err(RuntimeError::General(format!( "{}.get({}) failed: entity not found", entity_type, id ))); } } } ```
Unlike find (which pushes None when the entity is not found), get returns an error. This is the fundamental semantic difference: get asserts existence.
Action Handler
Finally, the action handler was updated to use .get() instead of .find():
// src/server/http.rs:1319
// BEFORE: flin_args.push(format!("{}.find({})", entity_type, id));
flin_args.push(format!("{}.get({})", entity_type, id));This single-line change in the action handler was the proximate fix. The entity reference passed to removeTodo is now Todo.get(123) instead of Todo.find(123). The type checker accepts this because get returns Todo, matching the function's parameter type.
The Type Safety Argument
One might ask: why not just make removeTodo accept Todo? and handle the None case?
fn removeTodo(todo: Todo?) {
if todo != none {
delete todo
}
}This would compile, but it is the wrong design. The action handler serializes entity references from the browser. If the browser sends an entity ID, it is because that entity was visible in the UI moments ago. If it no longer exists by the time the action executes, something unexpected happened (concurrent deletion, database corruption, stale cache). This is an exceptional case that should produce an error, not be silently ignored.
Using .get() makes this semantic explicit in the type system. The function says "I need an entity that exists." The lookup says "I guarantee this entity exists, or I will error." The type checker verifies that these two guarantees align.
A Cascade Discovery: Entity Ordering
While testing the delete fix in the browser, we discovered a separate bug: entity ordering was not preserved after page refresh. Creating tasks in order (Task 1, Task 2, Task 3) and then refreshing the page showed them in random order (Task 3, Task 1, Task 2).
The cause was that entities were stored in a HashMap in the database, which does not preserve insertion order. This was not related to the type mismatch bug, but it was discovered during the same testing session because delete testing requires creating multiple entities and observing their state after operations.
The ordering fix was straightforward:
// Sort by (created_at, id) for consistent ordering
entities.sort_by_key(|e| (e.created_at, e.id));Using both created_at and id ensures stable ordering even when multiple entities are created within the same millisecond.
The Design Principle
The .get() vs .find() distinction encodes a design principle that applies beyond ORMs: make the common case safe, and make the assertion explicit.
.find() is safe: it returns an optional, forcing the caller to handle the None case. This is appropriate when the caller cannot guarantee the entity exists -- for example, when searching by user-provided criteria.
.get() is assertive: it guarantees a value, erroring if the guarantee cannot be met. This is appropriate when the caller has strong reason to believe the entity exists -- for example, when operating on an entity reference from the current page state.
Both methods access the same database. Both execute the same query. The difference is entirely in the type contract: what the caller promises, and what the method guarantees in return.
This pattern appears across FLIN's API:
// Safe: returns optional, caller handles None
user = User.find(id) // User?
if user != none { ... }// Assertive: returns value, errors if absent user = User.get(id) // User delete user // always valid if we get here ```
After the fix, all todo operations worked correctly in the browser: add, toggle, delete, and clear completed. Data persisted across refresh. The type system was satisfied. And we had a cleaner API that distinguished between "might exist" and "must exist" with a single method name difference.
---
This is Part 166 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: - [165] The Theme Toggle Bug - [166] The Entity .get() Method Bug (you are here) - [167] Entity Ordering and Time Format Bugs